@dshtrading/strategies 0.1.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/LICENSE +75 -0
- package/lib/api/lib/index.d.ts +461 -0
- package/lib/custom-fs.d.ts +5 -0
- package/lib/custom-fs.js +72 -0
- package/lib/custom.d.ts +28 -0
- package/lib/custom.js +16 -0
- package/lib/engine.d.ts +6 -0
- package/lib/engine.js +165 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +12 -0
- package/lib/paradigms/bollinger-reversion.d.ts +5 -0
- package/lib/paradigms/bollinger-reversion.js +77 -0
- package/lib/paradigms/donchian-breakout.d.ts +5 -0
- package/lib/paradigms/donchian-breakout.js +71 -0
- package/lib/paradigms/ema-crossover.d.ts +5 -0
- package/lib/paradigms/ema-crossover.js +83 -0
- package/lib/paradigms/index.d.ts +12 -0
- package/lib/paradigms/index.js +23 -0
- package/lib/paradigms/momentum-12m.d.ts +5 -0
- package/lib/paradigms/momentum-12m.js +73 -0
- package/lib/paradigms/rsi-reversion.d.ts +5 -0
- package/lib/paradigms/rsi-reversion.js +89 -0
- package/lib/paradigms/sma-baseline.d.ts +5 -0
- package/lib/paradigms/sma-baseline.js +70 -0
- package/lib/plugin.d.ts +44 -0
- package/lib/plugin.js +214 -0
- package/lib/screeners/above-ma.d.ts +1 -0
- package/lib/screeners/above-ma.js +66 -0
- package/lib/screeners/index.d.ts +11 -0
- package/lib/screeners/index.js +18 -0
- package/lib/screeners/ma-bull-align.d.ts +1 -0
- package/lib/screeners/ma-bull-align.js +68 -0
- package/lib/screeners/near-high.d.ts +1 -0
- package/lib/screeners/near-high.js +48 -0
- package/lib/screeners/rsi-oversold.d.ts +1 -0
- package/lib/screeners/rsi-oversold.js +50 -0
- package/lib/screeners/types.d.ts +38 -0
- package/lib/screeners/volume-breakout.d.ts +1 -0
- package/lib/screeners/volume-breakout.js +63 -0
- package/lib/types.d.ts +81 -0
- package/lib/validate-node.js +40 -0
- package/lib/validate.d.ts +28 -0
- package/lib/validate.js +264 -0
- package/package.json +40 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sma } from "@dshtrading/indicators";
|
|
2
|
+
//#region src/screeners/volume-breakout.ts
|
|
3
|
+
/**
|
|
4
|
+
* 放量突破:收盘价创 lookback 日新高,且成交量 ≥ 均量的 volMultiple 倍——
|
|
5
|
+
* 突破有效性的量能确认(无量突破不入选)。量比口径跨市场可比(比值无量纲)。
|
|
6
|
+
*/
|
|
7
|
+
const volumeBreakoutScreener = {
|
|
8
|
+
id: "scr.volume-breakout",
|
|
9
|
+
name: "放量突破",
|
|
10
|
+
summary: "收盘价创 N 日新高且成交量 ≥ 均量 M 倍,量价配合的突破筛选",
|
|
11
|
+
params: [{
|
|
12
|
+
key: "lookback",
|
|
13
|
+
label: "突破窗口(日)",
|
|
14
|
+
default: 20,
|
|
15
|
+
min: 5,
|
|
16
|
+
max: 60,
|
|
17
|
+
step: 5
|
|
18
|
+
}, {
|
|
19
|
+
key: "volMultiple",
|
|
20
|
+
label: "量能倍数",
|
|
21
|
+
default: 2,
|
|
22
|
+
min: 1,
|
|
23
|
+
max: 10,
|
|
24
|
+
step: .5
|
|
25
|
+
}],
|
|
26
|
+
columns: [{
|
|
27
|
+
key: "volRatio",
|
|
28
|
+
label: "量比(倍)"
|
|
29
|
+
}, {
|
|
30
|
+
key: "breakoutPct",
|
|
31
|
+
label: "突破幅度",
|
|
32
|
+
format: "percent"
|
|
33
|
+
}],
|
|
34
|
+
evaluate(bars, params) {
|
|
35
|
+
const lookback = Math.max(5, Math.round(params.lookback ?? 20));
|
|
36
|
+
const volMultiple = Math.max(1, params.volMultiple ?? 2);
|
|
37
|
+
const i = bars.length - 1;
|
|
38
|
+
if (i < lookback) return null;
|
|
39
|
+
const window = bars.slice(i - lookback, i);
|
|
40
|
+
const priorHigh = Math.max(...window.map((b) => b.high));
|
|
41
|
+
const volumes = bars.map((b) => b.volume);
|
|
42
|
+
const avgVolume = sma(volumes, lookback)[i];
|
|
43
|
+
if (avgVolume === void 0 || avgVolume <= 0) return null;
|
|
44
|
+
const close = bars[i].close;
|
|
45
|
+
const volume = bars[i].volume;
|
|
46
|
+
if (!(close > priorHigh) || !(volume >= volMultiple * avgVolume)) return null;
|
|
47
|
+
const volRatio = volume / avgVolume;
|
|
48
|
+
return {
|
|
49
|
+
metrics: {
|
|
50
|
+
volRatio,
|
|
51
|
+
breakoutPct: (close - priorHigh) / priorHigh * 100
|
|
52
|
+
},
|
|
53
|
+
reason: `放量 ${volRatio.toFixed(2)} 倍突破 ${lookback} 日高点`,
|
|
54
|
+
reasonKey: "scr.volume-breakout.reason",
|
|
55
|
+
reasonParams: {
|
|
56
|
+
ratio: volRatio.toFixed(2),
|
|
57
|
+
n: lookback
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { volumeBreakoutScreener };
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Kline as Kline$1 } from "@dshtrading/indicators";
|
|
2
|
+
//#region src/types.d.ts
|
|
3
|
+
type StrategyHorizon = 'short' | 'swing' | 'long';
|
|
4
|
+
type SignalAction = 'entry' | 'exit';
|
|
5
|
+
interface StrategySignal {
|
|
6
|
+
/** bars 下标:信号在 bars[i] 收盘确认,engine 按 bars[i+1].open 成交 */
|
|
7
|
+
readonly index: number;
|
|
8
|
+
readonly time: number;
|
|
9
|
+
readonly action: SignalAction;
|
|
10
|
+
/** v1 只有 long/flat 两态(不做做空);预留词汇避免后续破坏性变更 */
|
|
11
|
+
readonly direction: 'long' | 'flat';
|
|
12
|
+
readonly price: number;
|
|
13
|
+
readonly reason: string;
|
|
14
|
+
/** reason 的词典键(client-ui-strategies 词典约定 strat.<id>.reason.<kind>),
|
|
15
|
+
* 视图按当前语言渲染 t(reasonKey, reasonParams);缺省回退 reason 原文。 */
|
|
16
|
+
readonly reasonKey?: string;
|
|
17
|
+
/** reasonKey 的 {placeholder} 插值参数(数值/枚举,与语言无关)。 */
|
|
18
|
+
readonly reasonParams?: Readonly<Record<string, string | number>>;
|
|
19
|
+
}
|
|
20
|
+
interface StrategyParamSpec {
|
|
21
|
+
readonly key: string;
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly default: number;
|
|
24
|
+
readonly min: number;
|
|
25
|
+
readonly max: number;
|
|
26
|
+
readonly step: number;
|
|
27
|
+
}
|
|
28
|
+
interface StrategyDefinition {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly horizon: StrategyHorizon;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly summary: string;
|
|
33
|
+
readonly params: readonly StrategyParamSpec[];
|
|
34
|
+
/** 纯函数:无 IO/随机/全局态;同一输入必须同一输出(回测确定性) */
|
|
35
|
+
compute(bars: readonly Kline$1[], params: Readonly<Record<string, number>>): StrategySignal[];
|
|
36
|
+
}
|
|
37
|
+
interface TradeRecord {
|
|
38
|
+
readonly entryIndex: number;
|
|
39
|
+
readonly entryTime: number;
|
|
40
|
+
readonly entryPrice: number;
|
|
41
|
+
readonly exitIndex: number;
|
|
42
|
+
readonly exitTime: number;
|
|
43
|
+
readonly exitPrice: number;
|
|
44
|
+
readonly returnPercent: number;
|
|
45
|
+
readonly profit: number;
|
|
46
|
+
readonly holdingBars: number;
|
|
47
|
+
readonly exitReason: string;
|
|
48
|
+
/** exitReason 的词典键 + 插值参数(来自离场 signal 的 reasonKey/Params)。 */
|
|
49
|
+
readonly exitReasonKey?: string;
|
|
50
|
+
readonly exitReasonParams?: Readonly<Record<string, string | number>>;
|
|
51
|
+
}
|
|
52
|
+
interface EquityPoint {
|
|
53
|
+
readonly time: number;
|
|
54
|
+
readonly equity: number;
|
|
55
|
+
readonly drawdownPercent: number;
|
|
56
|
+
}
|
|
57
|
+
interface BacktestMetrics {
|
|
58
|
+
readonly totalReturn: number;
|
|
59
|
+
readonly cagr: number;
|
|
60
|
+
readonly maxDrawdown: number;
|
|
61
|
+
readonly sharpe: number;
|
|
62
|
+
readonly winRate: number;
|
|
63
|
+
readonly profitFactor: number;
|
|
64
|
+
readonly tradeCount: number;
|
|
65
|
+
readonly exposure: number;
|
|
66
|
+
}
|
|
67
|
+
interface BacktestResult {
|
|
68
|
+
readonly signals: readonly StrategySignal[];
|
|
69
|
+
readonly trades: readonly TradeRecord[];
|
|
70
|
+
readonly equity: readonly EquityPoint[];
|
|
71
|
+
readonly metrics: BacktestMetrics;
|
|
72
|
+
readonly initialCapital: number;
|
|
73
|
+
readonly finalCapital: number;
|
|
74
|
+
}
|
|
75
|
+
interface BacktestOptions {
|
|
76
|
+
readonly initialCapital?: number;
|
|
77
|
+
readonly feeRate?: number;
|
|
78
|
+
readonly slippage?: number;
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
export { BacktestMetrics, BacktestOptions, BacktestResult, EquityPoint, type Kline$1 as Kline, SignalAction, StrategyDefinition, StrategyHorizon, StrategyParamSpec, StrategySignal, TradeRecord };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { validateCustomStrategy } from "./validate.js";
|
|
2
|
+
import * as vm from "node:vm";
|
|
3
|
+
//#region src/validate-node.ts
|
|
4
|
+
/**
|
|
5
|
+
* Node.js 宿主端专用策略校验执行器(node:vm 100ms 超时熔断,仿 indicators
|
|
6
|
+
* 的 validate-node.ts——issue #31 规格:Node 侧 vm 沙箱 + 超时熔断)。
|
|
7
|
+
*/
|
|
8
|
+
const nodeStrategyComputeRunner = (computeSource, bars, params, timeoutMs = 100) => {
|
|
9
|
+
const sandbox = {
|
|
10
|
+
bars,
|
|
11
|
+
params,
|
|
12
|
+
result: null,
|
|
13
|
+
Math,
|
|
14
|
+
Array,
|
|
15
|
+
Object,
|
|
16
|
+
Number,
|
|
17
|
+
String,
|
|
18
|
+
Boolean,
|
|
19
|
+
Date
|
|
20
|
+
};
|
|
21
|
+
const trimmed = computeSource.trim();
|
|
22
|
+
let code;
|
|
23
|
+
if (/^(?:\([a-zA-Z0-9_,\s]*\)|[a-zA-Z0-9_]+)\s*=>/.test(trimmed) || /^function\b/.test(trimmed)) code = `"use strict"; const fn = (${trimmed}); result = fn(bars, params);`;
|
|
24
|
+
else code = `"use strict"; const fn = (function(bars, params) { ${trimmed} }); result = fn(bars, params);`;
|
|
25
|
+
const script = new vm.Script(code);
|
|
26
|
+
const context = vm.createContext(sandbox);
|
|
27
|
+
try {
|
|
28
|
+
script.runInContext(context, { timeout: timeoutMs });
|
|
29
|
+
return sandbox.result;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || String(error?.message).includes("timed out")) throw new Error(`策略试算执行超时(超过 ${timeoutMs}ms),可能存在死循环(如 while/for 未退出)`);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/** Node.js 宿主端策略校验器:自动启用 node:vm 超时熔断保护。 */
|
|
36
|
+
function validateCustomStrategyNode(raw) {
|
|
37
|
+
return validateCustomStrategy(raw, { runner: nodeStrategyComputeRunner });
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { nodeStrategyComputeRunner, validateCustomStrategyNode };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { StrategyDefinition, StrategySignal } from "./types.js";
|
|
2
|
+
import { CustomStrategyRecord } from "./custom.js";
|
|
3
|
+
import { AsyncComputeRunner, Kline } from "@dshtrading/indicators";
|
|
4
|
+
//#region src/validate.d.ts
|
|
5
|
+
type StrategyValidationResult = {
|
|
6
|
+
ok: true;
|
|
7
|
+
definition: StrategyDefinition;
|
|
8
|
+
record: CustomStrategyRecord;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
reason: string;
|
|
12
|
+
};
|
|
13
|
+
/** 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。 */
|
|
14
|
+
declare function compileStrategySource(source: string): (bars: readonly Kline[], params: Readonly<Record<string, number>>) => StrategySignal[];
|
|
15
|
+
/**
|
|
16
|
+
* 信号序列专用校验(引擎语义对齐:i 收盘确认、i+1 开盘成交可复算)。
|
|
17
|
+
* 返回 undefined = 通过;否则返回人话诊断(模型与 UI 直接可读)。
|
|
18
|
+
*/
|
|
19
|
+
declare function validateSignalSequence(signals: readonly unknown[], bars: readonly Kline[]): string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* 自定义策略校验(异步——试算走可等待 runner,默认浏览器 Worker 超时熔断;
|
|
22
|
+
* Node 宿主侧传 validate-node.ts 的 vm 熔断 runner)。
|
|
23
|
+
*/
|
|
24
|
+
declare function validateCustomStrategy(raw: unknown, options?: {
|
|
25
|
+
runner?: AsyncComputeRunner;
|
|
26
|
+
}): Promise<StrategyValidationResult>;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { StrategyValidationResult, compileStrategySource, validateCustomStrategy, validateSignalSequence };
|
package/lib/validate.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { createSampleBars, workerComputeRunner } from "@dshtrading/indicators";
|
|
2
|
+
//#region src/validate.ts
|
|
3
|
+
/**
|
|
4
|
+
* 自定义策略安全校验器(issue #31 / P2)。浏览器端与宿主两侧共用(纯库,零 Node 依赖):
|
|
5
|
+
*
|
|
6
|
+
* 1. 结构校验:id / title / horizon / summary / paramsJson / computeSource
|
|
7
|
+
* 2. 源码体积与编译检查(16KB 上限,语法必须可编译)
|
|
8
|
+
* 3. 多场景样例试算(复用 indicators 的特征 K 线:上涨/下跌/平盘/缺口/极短)
|
|
9
|
+
* 4. **信号序列专用校验**(不复用指标的等长断言——信号序列语义不同):
|
|
10
|
+
* - index 为整数、落在 [0, bars.length) 且严格单调递增;
|
|
11
|
+
* - time 与 bars[index].openTime 逐位一致;
|
|
12
|
+
* - action ∈ {entry, exit}、direction ∈ {long, flat} 且与 action 配对
|
|
13
|
+
* (entry→long、exit→flat);
|
|
14
|
+
* - price 为有限数且等于 bars[index].close(i 收盘确认价,浮点容差);
|
|
15
|
+
* - 序列可复算:从 flat 起步、entry/exit 严格交替(exit 时必须持仓、entry
|
|
16
|
+
* 时必须空仓)——与回测引擎的成交语义一致(i 收盘确认、i+1 开盘成交)。
|
|
17
|
+
* 5. 可插拔 runner:浏览器默认 Worker 超时熔断(indicators 的 workerComputeRunner),
|
|
18
|
+
* Node 侧传 vm 熔断 runner(validate-node.ts)。
|
|
19
|
+
*/
|
|
20
|
+
const ID_PATTERN = /^[a-z0-9_][a-z0-9_-]{1,31}$/;
|
|
21
|
+
const PARAM_KEY_PATTERN = /^[a-zA-Z0-9_]{1,16}$/;
|
|
22
|
+
const MAX_SOURCE_LENGTH = 16384;
|
|
23
|
+
const MAX_PARAMS_COUNT = 8;
|
|
24
|
+
const MAX_SUMMARY_LENGTH = 120;
|
|
25
|
+
const MAX_REASON_LENGTH = 200;
|
|
26
|
+
/** 6 大范式 id 是系统保留名(custom_* 前缀或自定义名避免冲突)。 */
|
|
27
|
+
const RESERVED_IDS = /* @__PURE__ */ new Set([
|
|
28
|
+
"donchian-breakout",
|
|
29
|
+
"rsi-reversion",
|
|
30
|
+
"ema-crossover",
|
|
31
|
+
"bollinger-reversion",
|
|
32
|
+
"sma-baseline",
|
|
33
|
+
"momentum-12m"
|
|
34
|
+
]);
|
|
35
|
+
const HORIZONS = [
|
|
36
|
+
"short",
|
|
37
|
+
"swing",
|
|
38
|
+
"long"
|
|
39
|
+
];
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 100;
|
|
41
|
+
/** 将策略源码解析为可执行纯函数(浏览器端 new Function;Node 侧走 vm runner 试算)。 */
|
|
42
|
+
function compileStrategySource(source) {
|
|
43
|
+
const trimmed = source.trim();
|
|
44
|
+
if (/^(?:\([a-zA-Z0-9_,\s]*\)|[a-zA-Z0-9_]+)\s*=>/.test(trimmed) || /^function\b/.test(trimmed)) return new Function(`"use strict"; return (${trimmed});`)();
|
|
45
|
+
return new Function("bars", "params", `"use strict";\n${trimmed}`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 信号序列专用校验(引擎语义对齐:i 收盘确认、i+1 开盘成交可复算)。
|
|
49
|
+
* 返回 undefined = 通过;否则返回人话诊断(模型与 UI 直接可读)。
|
|
50
|
+
*/
|
|
51
|
+
function validateSignalSequence(signals, bars) {
|
|
52
|
+
let position = "flat";
|
|
53
|
+
let prevIndex = -1;
|
|
54
|
+
for (let i = 0; i < signals.length; i++) {
|
|
55
|
+
const s = signals[i];
|
|
56
|
+
if (typeof s !== "object" || s === null) return `signals[${i}] 必须是非空对象`;
|
|
57
|
+
const signal = s;
|
|
58
|
+
if (typeof signal.index !== "number" || !Number.isInteger(signal.index) || signal.index < 0 || signal.index >= bars.length) return `signals[${i}].index 必须是落在 [0, ${bars.length - 1}] 的整数(bar 下标),收到: ${String(signal.index)}`;
|
|
59
|
+
if (signal.index <= prevIndex) return `signals[${i}].index (${signal.index}) 必须严格大于前一个信号的 index (${prevIndex})——信号按 bar 下标单调递增`;
|
|
60
|
+
prevIndex = signal.index;
|
|
61
|
+
if (signal.time !== bars[signal.index].openTime) return `signals[${i}].time (${String(signal.time)}) 与 bars[${signal.index}].openTime (${String(bars[signal.index].openTime)}) 不一致——信号 time 必须等于确认 bar 的 openTime`;
|
|
62
|
+
if (signal.action !== "entry" && signal.action !== "exit") return `signals[${i}].action 必须是 "entry" 或 "exit",收到: ${JSON.stringify(signal.action)}`;
|
|
63
|
+
if (signal.direction !== "long" && signal.direction !== "flat") return `signals[${i}].direction 必须是 "long" 或 "flat",收到: ${JSON.stringify(signal.direction)}`;
|
|
64
|
+
const expectedDirection = signal.action === "entry" ? "long" : "flat";
|
|
65
|
+
if (signal.direction !== expectedDirection) return `signals[${i}].direction (${signal.direction}) 与 action (${signal.action}) 不配对——entry 对应 long,exit 对应 flat`;
|
|
66
|
+
if (typeof signal.price !== "number" || !Number.isFinite(signal.price)) return `signals[${i}].price 必须是有限数字(确认时收盘价),收到: ${String(signal.price)}`;
|
|
67
|
+
const close = bars[signal.index].close;
|
|
68
|
+
if (Math.abs(signal.price - close) > Math.max(1e-9, Math.abs(close) * 1e-6)) return `signals[${i}].price (${signal.price}) 与确认 bar 收盘价 (${close}) 不一致——信号在 i 收盘确认,price 必须等于 bars[i].close`;
|
|
69
|
+
if (typeof signal.reason !== "string" || !signal.reason.trim()) return `signals[${i}].reason 必须是非空字符串(人话解释)`;
|
|
70
|
+
if (signal.reason.length > MAX_REASON_LENGTH) return `signals[${i}].reason 超长(${signal.reason.length} > ${MAX_REASON_LENGTH})`;
|
|
71
|
+
if (signal.action === "entry" && position === "long") return `signals[${i}] 在已持仓(前一个 entry 尚未 exit)时再次 entry——引擎不会重复开仓,序列不可复算`;
|
|
72
|
+
if (signal.action === "exit" && position === "flat") return `signals[${i}] 在空仓时 exit——没有可平的头寸,序列不可复算(首信号必须是 entry)`;
|
|
73
|
+
position = signal.action === "entry" ? "long" : "flat";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 结构校验(无试算)——browser/node 校验器共用。
|
|
78
|
+
*/
|
|
79
|
+
function checkCustomStrategyStructure(raw) {
|
|
80
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: "策略配置必须是一个非空对象"
|
|
83
|
+
};
|
|
84
|
+
const input = raw;
|
|
85
|
+
const id = typeof input.id === "string" ? input.id.trim().toLowerCase() : "";
|
|
86
|
+
if (!id) return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: "缺少策略 id"
|
|
89
|
+
};
|
|
90
|
+
if (!ID_PATTERN.test(id)) return {
|
|
91
|
+
ok: false,
|
|
92
|
+
reason: `策略 id "${id}" 不合法:必须由 2-32 位小写字母、数字、下划线或连字符组成`
|
|
93
|
+
};
|
|
94
|
+
if (RESERVED_IDS.has(id)) return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: `策略 id "${id}" 是系统范式保留名称,请使用其他名称(如 custom_${id})`
|
|
97
|
+
};
|
|
98
|
+
const title = typeof input.title === "string" ? input.title.trim() : "";
|
|
99
|
+
if (!title || title.length > 32) return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: "策略 title 必须是 1-32 字符的非空字符串"
|
|
102
|
+
};
|
|
103
|
+
const horizon = input.horizon;
|
|
104
|
+
if (!HORIZONS.includes(horizon)) return {
|
|
105
|
+
ok: false,
|
|
106
|
+
reason: `策略 horizon 必须是 "short"、"swing" 或 "long",收到: ${JSON.stringify(input.horizon)}`
|
|
107
|
+
};
|
|
108
|
+
const summary = typeof input.summary === "string" ? input.summary.trim() : "";
|
|
109
|
+
if (!summary || summary.length > MAX_SUMMARY_LENGTH) return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: `策略 summary 必须是 1-${MAX_SUMMARY_LENGTH} 字符的非空字符串`
|
|
112
|
+
};
|
|
113
|
+
let params = [];
|
|
114
|
+
if (input.paramsJson !== void 0) {
|
|
115
|
+
if (typeof input.paramsJson !== "string") return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: "策略 paramsJson 必须是字符串(StrategyParamSpec[] 的 JSON)"
|
|
118
|
+
};
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(input.paramsJson);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason: `paramsJson 不是合法 JSON: ${String(error?.message ?? error)}`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(parsed)) return {
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: "paramsJson 解析结果必须是数组(StrategyParamSpec[])"
|
|
131
|
+
};
|
|
132
|
+
if (parsed.length > MAX_PARAMS_COUNT) return {
|
|
133
|
+
ok: false,
|
|
134
|
+
reason: `策略参数数量超出上限(至多 ${MAX_PARAMS_COUNT} 个)`
|
|
135
|
+
};
|
|
136
|
+
for (let index = 0; index < parsed.length; index++) {
|
|
137
|
+
const p = parsed[index];
|
|
138
|
+
if (typeof p !== "object" || p === null) return {
|
|
139
|
+
ok: false,
|
|
140
|
+
reason: `paramsJson[${index}] 必须是一个对象`
|
|
141
|
+
};
|
|
142
|
+
const spec = p;
|
|
143
|
+
const key = typeof spec.key === "string" ? spec.key.trim() : "";
|
|
144
|
+
if (!PARAM_KEY_PATTERN.test(key)) return {
|
|
145
|
+
ok: false,
|
|
146
|
+
reason: `paramsJson[${index}].key "${key}" 不合法:必须是 1-16 位字母数字或下划线`
|
|
147
|
+
};
|
|
148
|
+
const label = typeof spec.label === "string" ? spec.label.trim() : key;
|
|
149
|
+
const defVal = Number(spec.default);
|
|
150
|
+
const minVal = Number(spec.min);
|
|
151
|
+
const maxVal = Number(spec.max);
|
|
152
|
+
if (!Number.isFinite(defVal) || !Number.isFinite(minVal) || !Number.isFinite(maxVal)) return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: `paramsJson[${index}] (${key}) 的 default、min、max 必须是有限数字`
|
|
155
|
+
};
|
|
156
|
+
if (minVal >= maxVal) return {
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: `paramsJson[${index}] (${key}) 的 min (${minVal}) 必须严格小于 max (${maxVal})`
|
|
159
|
+
};
|
|
160
|
+
if (defVal < minVal || defVal > maxVal) return {
|
|
161
|
+
ok: false,
|
|
162
|
+
reason: `paramsJson[${index}] (${key}) 的 default (${defVal}) 必须在 [min, max] (${minVal}..${maxVal}) 范围内`
|
|
163
|
+
};
|
|
164
|
+
params.push({
|
|
165
|
+
key,
|
|
166
|
+
label,
|
|
167
|
+
default: defVal,
|
|
168
|
+
min: minVal,
|
|
169
|
+
max: maxVal,
|
|
170
|
+
step: 1
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const computeSource = typeof input.computeSource === "string" ? input.computeSource.trim() : "";
|
|
175
|
+
if (!computeSource) return {
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: "缺少 computeSource 源码"
|
|
178
|
+
};
|
|
179
|
+
if (computeSource.length > MAX_SOURCE_LENGTH) return {
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: `computeSource 源码长度 (${computeSource.length} B) 超出限制 (${MAX_SOURCE_LENGTH} B / 16KB)`
|
|
182
|
+
};
|
|
183
|
+
try {
|
|
184
|
+
compileStrategySource(computeSource);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
reason: `源码语法错误或无法编译: ${String(error?.message ?? error)}`
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
id,
|
|
194
|
+
title,
|
|
195
|
+
horizon,
|
|
196
|
+
summary,
|
|
197
|
+
params,
|
|
198
|
+
computeSource,
|
|
199
|
+
input
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 自定义策略校验(异步——试算走可等待 runner,默认浏览器 Worker 超时熔断;
|
|
204
|
+
* Node 宿主侧传 validate-node.ts 的 vm 熔断 runner)。
|
|
205
|
+
*/
|
|
206
|
+
async function validateCustomStrategy(raw, options) {
|
|
207
|
+
const checked = checkCustomStrategyStructure(raw);
|
|
208
|
+
if (!checked.ok) return checked;
|
|
209
|
+
const { id, title, horizon, summary, params, computeSource, input } = checked;
|
|
210
|
+
const sampleScenarios = createSampleBars();
|
|
211
|
+
const scenarioNames = [
|
|
212
|
+
"uptrend",
|
|
213
|
+
"downtrend",
|
|
214
|
+
"flat",
|
|
215
|
+
"gap",
|
|
216
|
+
"short"
|
|
217
|
+
];
|
|
218
|
+
const runner = options?.runner ?? workerComputeRunner;
|
|
219
|
+
const defaultParamsMap = {};
|
|
220
|
+
for (const p of params) defaultParamsMap[p.key] = p.default;
|
|
221
|
+
for (const scenario of scenarioNames) {
|
|
222
|
+
const bars = sampleScenarios[scenario];
|
|
223
|
+
let signals;
|
|
224
|
+
try {
|
|
225
|
+
signals = await runner(computeSource, bars, { ...defaultParamsMap }, DEFAULT_TIMEOUT_MS);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
reason: `在 ${scenario} 样例数据上试算执行报错: ${String(error?.message ?? error)}`
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(signals)) return {
|
|
233
|
+
ok: false,
|
|
234
|
+
reason: `compute 返回值必须是 StrategySignal[] 数组,${scenario} 场景实际返回类型为: ${typeof signals}`
|
|
235
|
+
};
|
|
236
|
+
const sequenceReason = validateSignalSequence(signals, bars);
|
|
237
|
+
if (sequenceReason !== void 0) return {
|
|
238
|
+
ok: false,
|
|
239
|
+
reason: `信号序列校验失败(${scenario} 场景): ${sequenceReason}`
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
definition: {
|
|
245
|
+
id,
|
|
246
|
+
horizon,
|
|
247
|
+
name: title,
|
|
248
|
+
summary,
|
|
249
|
+
params,
|
|
250
|
+
compute: compileStrategySource(computeSource)
|
|
251
|
+
},
|
|
252
|
+
record: {
|
|
253
|
+
id,
|
|
254
|
+
title,
|
|
255
|
+
horizon,
|
|
256
|
+
summary,
|
|
257
|
+
paramsJson: JSON.stringify(params),
|
|
258
|
+
computeSource,
|
|
259
|
+
createdAt: typeof input.createdAt === "number" ? input.createdAt : Date.now()
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
//#endregion
|
|
264
|
+
export { compileStrategySource, validateCustomStrategy, validateSignalSequence };
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dshtrading/strategies",
|
|
3
|
+
"description": "Trading strategies kernel for dsh-trading: pure math backtesting engine, strategy contracts, and 6 reference paradigms (short, swing, long). Pure library (zero runtime dependencies, browser-packable).",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./plugin": {
|
|
14
|
+
"types": "./lib/plugin.d.ts",
|
|
15
|
+
"default": "./lib/plugin.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib"
|
|
21
|
+
],
|
|
22
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@dshtrading/indicators": "0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@deepseek-ai/cordis": ">=4.0.0",
|
|
28
|
+
"@deepseek-ai/dsh-tools": ">=0.1.2-alpha.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"tsdown": "^0.22.0",
|
|
32
|
+
"vitest": "^3.0.0",
|
|
33
|
+
"typescript": "^5.5.0",
|
|
34
|
+
"@dshtrading/api": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"test": "vitest run"
|
|
39
|
+
}
|
|
40
|
+
}
|