@openfinclaw/openfinclaw-strategy 0.0.11

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.
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Strategy package validation for Findoo Backtest Agent (FEP v2.0).
3
+ * Validates fep.yaml structure, strategy script safety, and required fields.
4
+ * @module openfinclaw/validate
5
+ */
6
+ import { readFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import type { FepV2Style, FepV2Timeframe } from "./types.js";
9
+
10
+ /** FEP 版本常量 */
11
+ const FEP_VERSION = "2.0";
12
+
13
+ /** Symbol 格式正则 */
14
+ const SYMBOL_PATTERNS = {
15
+ crypto: /^[A-Z][A-Z0-9]{1,9}\/[A-Z][A-Z0-9]{1,9}$/,
16
+ aShare: /^\d{6}\.(SZ|SH)$/,
17
+ etf: /^5\d{5}\.SH$/,
18
+ index: /^000\d{3}\.SH$/,
19
+ hkStock: /^\d{5}\.HK$/,
20
+ usStock: /^[A-Z][A-Z0-9]{0,4}$/,
21
+ futures: /^[A-Z]+\d{4}\.[A-Z]+$/,
22
+ };
23
+
24
+ /** 策略风格枚举 */
25
+ const VALID_STYLES: FepV2Style[] = [
26
+ "trend",
27
+ "mean-reversion",
28
+ "momentum",
29
+ "value",
30
+ "growth",
31
+ "breakout",
32
+ "rotation",
33
+ "hybrid",
34
+ ];
35
+
36
+ /** K线周期枚举 */
37
+ const VALID_TIMEFRAMES: FepV2Timeframe[] = ["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"];
38
+
39
+ /** 可见性枚举 */
40
+ const VALID_VISIBILITY = ["public", "private", "unlisted"];
41
+
42
+ /** 许可证枚举 */
43
+ const VALID_LICENSES = ["MIT", "CC-BY-4.0", "proprietary"];
44
+
45
+ /** 禁止的 Python 导入模式(v2.0 扩展黑名单) */
46
+ const FORBIDDEN_IMPORT_PATTERNS = [
47
+ /\bimport\s+os\b/,
48
+ /\bimport\s+sys\b/,
49
+ /\bimport\s+subprocess\b/,
50
+ /\bimport\s+socket\b/,
51
+ /\bimport\s+shutil\b/,
52
+ /\bimport\s+ctypes\b/,
53
+ /\bimport\s+importlib\b/,
54
+ /\bimport\s+signal\b/,
55
+ /\bimport\s+threading\b/,
56
+ /\bimport\s+multiprocessing\b/,
57
+ /\bimport\s+pathlib\b/,
58
+ /\bimport\s+tempfile\b/,
59
+ /\bimport\s+requests\b/,
60
+ /\bimport\s+urllib\b/,
61
+ /\bimport\s+http\b/,
62
+ /\bimport\s+ftplib\b/,
63
+ /\bimport\s+smtplib\b/,
64
+ /\bimport\s+xmlrpc\b/,
65
+ /\bimport\s+pickle\b/,
66
+ /\bimport\s+shelve\b/,
67
+ /\bimport\s+marshal\b/,
68
+ /\bimport\s+concurrent\b/,
69
+ /\bimport\s+asyncio\b/,
70
+ /\bimport\s+io\b/,
71
+ /\bfrom\s+os\b/,
72
+ /\bfrom\s+sys\b/,
73
+ /\bfrom\s+subprocess\b/,
74
+ /\bfrom\s+socket\b/,
75
+ /\bfrom\s+shutil\b/,
76
+ /\bfrom\s+ctypes\b/,
77
+ /\bfrom\s+importlib\b/,
78
+ /\bfrom\s+signal\b/,
79
+ /\bfrom\s+threading\b/,
80
+ /\bfrom\s+multiprocessing\b/,
81
+ /\bfrom\s+pathlib\b/,
82
+ /\bfrom\s+tempfile\b/,
83
+ /\bfrom\s+requests\b/,
84
+ /\bfrom\s+urllib\b/,
85
+ /\bfrom\s+http\b/,
86
+ /\bfrom\s+ftplib\b/,
87
+ /\bfrom\s+smtplib\b/,
88
+ /\bfrom\s+xmlrpc\b/,
89
+ /\bfrom\s+pickle\b/,
90
+ /\bfrom\s+shelve\b/,
91
+ /\bfrom\s+marshal\b/,
92
+ /\bfrom\s+concurrent\b/,
93
+ /\bfrom\s+asyncio\b/,
94
+ /\bfrom\s+io\b/,
95
+ ];
96
+
97
+ /** 禁止的 Python 函数调用模式 */
98
+ const FORBIDDEN_CALL_PATTERNS = [
99
+ /\beval\s*\(/,
100
+ /\bexec\s*\(/,
101
+ /\bcompile\s*\(/,
102
+ /\bopen\s*\(/,
103
+ /\b__import__\s*\(/,
104
+ /\bgetattr\s*\(/,
105
+ /\bsetattr\s*\(/,
106
+ /\bdelattr\s*\(/,
107
+ /\bvars\s*\(/,
108
+ /\bdir\s*\(/,
109
+ /\bbreakpoint\s*\(/,
110
+ /\bexit\s*\(/,
111
+ /\bquit\s*\(/,
112
+ /\binput\s*\(/,
113
+ /\bglobals\s*\(/,
114
+ /\blocals\s*\(/,
115
+ ];
116
+
117
+ /** 破坏回测一致性的模式 */
118
+ const BACKTEST_BREAKING_PATTERNS = [/\bdatetime\s*\.\s*now\s*\(/, /\bdate\s*\.\s*today\s*\(/];
119
+
120
+ /** 验证结果 */
121
+ export type ValidateResult = {
122
+ valid: boolean;
123
+ errors: string[];
124
+ warnings?: string[];
125
+ };
126
+
127
+ /**
128
+ * 移除 Python 代码中的注释
129
+ */
130
+ function removePythonComments(code: string): string {
131
+ const lines = code.split("\n");
132
+ const result: string[] = [];
133
+
134
+ let inString = false;
135
+ let stringChar = "";
136
+
137
+ for (const line of lines) {
138
+ let cleaned = "";
139
+ let i = 0;
140
+
141
+ while (i < line.length) {
142
+ const char = line[i];
143
+ const nextChar = line[i + 1];
144
+
145
+ if (!inString) {
146
+ if (char === '"' || char === "'") {
147
+ if (nextChar === char && line[i + 2] === char) {
148
+ inString = true;
149
+ stringChar = char + char + char;
150
+ cleaned += char + nextChar + line[i + 2];
151
+ i += 3;
152
+ continue;
153
+ }
154
+ inString = true;
155
+ stringChar = char;
156
+ cleaned += char;
157
+ i += 1;
158
+ continue;
159
+ }
160
+ if (char === "#") {
161
+ break;
162
+ }
163
+ cleaned += char;
164
+ i += 1;
165
+ } else {
166
+ cleaned += char;
167
+ if (
168
+ (stringChar.length === 1 && char === stringChar) ||
169
+ (stringChar.length === 3 &&
170
+ char === stringChar[0] &&
171
+ nextChar === stringChar[1] &&
172
+ line[i + 2] === stringChar[2])
173
+ ) {
174
+ inString = false;
175
+ stringChar = "";
176
+ }
177
+ i += 1;
178
+ }
179
+ }
180
+
181
+ result.push(cleaned);
182
+ }
183
+
184
+ return result.join("\n");
185
+ }
186
+
187
+ /**
188
+ * 验证 symbol 格式
189
+ */
190
+ function validateSymbol(symbol: string): { valid: boolean; market?: string } {
191
+ if (SYMBOL_PATTERNS.crypto.test(symbol)) {
192
+ return { valid: true, market: "Crypto" };
193
+ }
194
+ if (SYMBOL_PATTERNS.aShare.test(symbol)) {
195
+ if (symbol.startsWith("6") || symbol.startsWith("0") || symbol.startsWith("3")) {
196
+ if (symbol.startsWith("5") && symbol.endsWith(".SH")) {
197
+ return { valid: true, market: "ETF" };
198
+ }
199
+ if (symbol.startsWith("000") && symbol.endsWith(".SH")) {
200
+ return { valid: true, market: "Index" };
201
+ }
202
+ return { valid: true, market: "CN" };
203
+ }
204
+ return { valid: true, market: "CN" };
205
+ }
206
+ if (SYMBOL_PATTERNS.etf.test(symbol)) {
207
+ return { valid: true, market: "ETF" };
208
+ }
209
+ if (SYMBOL_PATTERNS.index.test(symbol)) {
210
+ return { valid: true, market: "Index" };
211
+ }
212
+ if (SYMBOL_PATTERNS.hkStock.test(symbol)) {
213
+ return { valid: true, market: "HK" };
214
+ }
215
+ if (SYMBOL_PATTERNS.usStock.test(symbol)) {
216
+ return { valid: true, market: "US" };
217
+ }
218
+ if (SYMBOL_PATTERNS.futures.test(symbol)) {
219
+ return { valid: true, market: "Futures" };
220
+ }
221
+ return { valid: false };
222
+ }
223
+
224
+ /**
225
+ * 提取 YAML 块内容
226
+ * 提取指定块名下的所有内容(缩进的子行)
227
+ */
228
+ function extractYamlBlock(yamlContent: string, blockName: string): string {
229
+ const lines = yamlContent.split("\n");
230
+ const result: string[] = [];
231
+ let inBlock = false;
232
+ let blockIndent = -1;
233
+
234
+ for (const line of lines) {
235
+ const trimmed = line.trim();
236
+
237
+ if (!inBlock) {
238
+ if (trimmed === `${blockName}:` || trimmed.startsWith(`${blockName}:`)) {
239
+ inBlock = true;
240
+ blockIndent = line.length - line.trimStart().length;
241
+ const afterColon = trimmed.slice(blockName.length + 1).trim();
242
+ if (afterColon) {
243
+ result.push(afterColon);
244
+ }
245
+ continue;
246
+ }
247
+ } else {
248
+ if (line.trim() === "") {
249
+ continue;
250
+ }
251
+ const currentIndent = line.length - line.trimStart().length;
252
+ if (currentIndent > blockIndent) {
253
+ result.push(line);
254
+ } else if (currentIndent <= blockIndent && trimmed && !trimmed.startsWith("#")) {
255
+ break;
256
+ }
257
+ }
258
+ }
259
+
260
+ return result.join("\n");
261
+ }
262
+
263
+ /**
264
+ * 检查字段是否存在
265
+ */
266
+ function hasField(block: string, fieldName: string): boolean {
267
+ const regex = new RegExp(`^\\s*${fieldName}\\s*:`, "m");
268
+ return regex.test(block);
269
+ }
270
+
271
+ /**
272
+ * 获取字段值
273
+ */
274
+ function getFieldValue(block: string, fieldName: string): string | null {
275
+ const regex = new RegExp(`^\\s*${fieldName}\\s*:\\s*(.+)$`, "m");
276
+ const match = regex.exec(block);
277
+ return match ? match[1].trim() : null;
278
+ }
279
+
280
+ /**
281
+ * 验证 FEP v2.0 fep.yaml 结构
282
+ */
283
+ function validateFepYaml(
284
+ fepStr: string,
285
+ errors: string[],
286
+ warnings: string[],
287
+ ): { hasUniverse: boolean } {
288
+ let hasUniverse = false;
289
+
290
+ // 检查 fep 版本
291
+ const fepMatch = /^\s*fep\s*:\s*["']?([^"'\s]+)["']?/m.exec(fepStr);
292
+ if (!fepMatch) {
293
+ errors.push("fep.yaml 必须包含 'fep:' 版本声明(例如 fep: \"2.0\")");
294
+ } else if (fepMatch[1] !== FEP_VERSION) {
295
+ errors.push(
296
+ `fep.yaml 版本必须为 "${FEP_VERSION}",当前为 "${fepMatch[1]}"。请升级到 FEP v2.0 格式。`,
297
+ );
298
+ }
299
+
300
+ // ── identity 验证 ──
301
+ if (!hasField(fepStr, "identity")) {
302
+ errors.push("fep.yaml 必须包含 'identity:' 节");
303
+ } else {
304
+ const identityBlock = extractYamlBlock(fepStr, "identity");
305
+
306
+ // 必填字段检查
307
+ const requiredIdentityFields = [
308
+ { field: "id", message: "策略唯一标识" },
309
+ { field: "name", message: "策略显示名称" },
310
+ { field: "type", message: "策略类型(strategy)" },
311
+ { field: "version", message: "语义化版本号(如 1.0.0)" },
312
+ { field: "style", message: "策略风格" },
313
+ { field: "visibility", message: "可见性(public/private/unlisted)" },
314
+ { field: "summary", message: "策略简介" },
315
+ { field: "description", message: "策略描述" },
316
+ { field: "license", message: "许可证" },
317
+ { field: "changelog", message: "变更日志" },
318
+ { field: "tags", message: "标签" },
319
+ ];
320
+
321
+ for (const { field, message } of requiredIdentityFields) {
322
+ if (!hasField(identityBlock, field)) {
323
+ errors.push(`fep.yaml identity 必须包含 '${field}'(${message})`);
324
+ }
325
+ }
326
+
327
+ // author.name 必填
328
+ if (!hasField(identityBlock, "author")) {
329
+ errors.push("fep.yaml identity 必须包含 'author' 节");
330
+ } else {
331
+ const authorBlock = extractYamlBlock(identityBlock, "author");
332
+ if (!hasField(authorBlock, "name")) {
333
+ errors.push("fep.yaml identity.author 必须包含 'name'(作者名)");
334
+ }
335
+ }
336
+
337
+ // style 枚举验证
338
+ const styleValue = getFieldValue(identityBlock, "style");
339
+ if (styleValue) {
340
+ const cleanedStyle = styleValue.replace(/["']/g, "");
341
+ if (!VALID_STYLES.includes(cleanedStyle as FepV2Style)) {
342
+ errors.push(`fep.yaml identity.style 必须为以下值之一: ${VALID_STYLES.join(", ")}`);
343
+ }
344
+ }
345
+
346
+ // visibility 枚举验证
347
+ const visibilityValue = getFieldValue(identityBlock, "visibility");
348
+ if (visibilityValue) {
349
+ const cleanedVisibility = visibilityValue.replace(/["']/g, "");
350
+ if (!VALID_VISIBILITY.includes(cleanedVisibility)) {
351
+ errors.push(
352
+ `fep.yaml identity.visibility 必须为以下值之一: ${VALID_VISIBILITY.join(", ")}`,
353
+ );
354
+ }
355
+ }
356
+
357
+ // license 枚举验证
358
+ const licenseValue = getFieldValue(identityBlock, "license");
359
+ if (licenseValue) {
360
+ const cleanedLicense = licenseValue.replace(/["']/g, "");
361
+ if (!VALID_LICENSES.includes(cleanedLicense)) {
362
+ warnings.push(`fep.yaml identitylicense 建议: ${VALID_LICENSES.join(", ")}`);
363
+ }
364
+ }
365
+
366
+ // tags 格式验证
367
+ const tagsValue = getFieldValue(identityBlock, "tags");
368
+ if (tagsValue && !tagsValue.startsWith("[")) {
369
+ warnings.push("identity.tags 应使用行内数组格式,如: tags: [trend, btc, crypto]");
370
+ }
371
+ }
372
+
373
+ // ── technical 验证(可选,有默认值)──
374
+ if (hasField(fepStr, "technical")) {
375
+ const technicalBlock = extractYamlBlock(fepStr, "technical");
376
+
377
+ const langValue = getFieldValue(technicalBlock, "language");
378
+ if (langValue && langValue.replace(/["']/g, "") !== "python") {
379
+ warnings.push('technical.language 建议使用 "python"');
380
+ }
381
+
382
+ const entryValue = getFieldValue(technicalBlock, "entryPoint");
383
+ if (entryValue && !entryValue.endsWith("strategy.py")) {
384
+ warnings.push("technical.entryPoint 建议使用 strategy.py");
385
+ }
386
+ }
387
+
388
+ // ── backtest 验证(必填)──
389
+ if (!hasField(fepStr, "backtest")) {
390
+ errors.push("fep.yaml 必须包含 'backtest:' 节");
391
+ } else {
392
+ const backtestBlock = extractYamlBlock(fepStr, "backtest");
393
+
394
+ // symbol 必填
395
+ if (!hasField(backtestBlock, "symbol")) {
396
+ errors.push("fep.yaml backtest 必须包含 'symbol'(交易品种)");
397
+ } else {
398
+ const symbolValue = getFieldValue(backtestBlock, "symbol");
399
+ if (symbolValue) {
400
+ const cleanedSymbol = symbolValue.replace(/["']/g, "");
401
+ const symbolResult = validateSymbol(cleanedSymbol);
402
+ if (!symbolResult.valid) {
403
+ warnings.push(`backtest.symbol "${cleanedSymbol}" 格式未被识别,请确保正确`);
404
+ }
405
+ }
406
+ }
407
+
408
+ // defaultPeriod 必填
409
+ if (!hasField(backtestBlock, "defaultPeriod")) {
410
+ errors.push("fep.yaml backtest 必须包含 'defaultPeriod'");
411
+ } else {
412
+ const periodBlock = extractYamlBlock(backtestBlock, "defaultPeriod");
413
+ if (!hasField(periodBlock, "startDate")) {
414
+ errors.push("fep.yaml backtest.defaultPeriod 必须包含 'startDate'");
415
+ }
416
+ if (!hasField(periodBlock, "endDate")) {
417
+ errors.push("fep.yaml backtest.defaultPeriod 必须包含 'endDate'");
418
+ }
419
+ }
420
+
421
+ // initialCapital 必填
422
+ if (!hasField(backtestBlock, "initialCapital")) {
423
+ errors.push("fep.yaml backtest 必须包含 'initialCapital'(初始资金)");
424
+ }
425
+
426
+ // timeframe 枚举验证(可选)
427
+ const timeframeValue = getFieldValue(backtestBlock, "timeframe");
428
+ if (timeframeValue) {
429
+ const cleanedTimeframe = timeframeValue.replace(/["']/g, "");
430
+ if (!VALID_TIMEFRAMES.includes(cleanedTimeframe as FepV2Timeframe)) {
431
+ errors.push(`fep.yaml backtest.timeframe 必须为以下值之一: ${VALID_TIMEFRAMES.join(", ")}`);
432
+ }
433
+ }
434
+
435
+ // universe 检测
436
+ if (hasField(backtestBlock, "universe")) {
437
+ hasUniverse = true;
438
+ const universeBlock = extractYamlBlock(backtestBlock, "universe");
439
+ if (!hasField(universeBlock, "symbols")) {
440
+ errors.push("fep.yaml backtest.universe 必须包含 'symbols' 数组");
441
+ }
442
+ }
443
+
444
+ // rebalance 验证(可选)
445
+ if (hasField(backtestBlock, "rebalance")) {
446
+ const rebalanceBlock = extractYamlBlock(backtestBlock, "rebalance");
447
+ const freqValue = getFieldValue(rebalanceBlock, "frequency");
448
+ if (freqValue) {
449
+ const validFreq = ["daily", "weekly", "monthly"];
450
+ if (!validFreq.includes(freqValue.replace(/["']/g, ""))) {
451
+ errors.push(`fep.yaml backtest.rebalance.frequency 必须为: ${validFreq.join(", ")}`);
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ // ── risk 验证(可选)──
458
+ if (hasField(fepStr, "risk")) {
459
+ const riskBlock = extractYamlBlock(fepStr, "risk");
460
+ const thresholdValue = getFieldValue(riskBlock, "maxDrawdownThreshold");
461
+ if (thresholdValue) {
462
+ const num = parseFloat(thresholdValue);
463
+ if (isNaN(num) || num < 0 || num > 100) {
464
+ warnings.push("risk.maxDrawdownThreshold 应为 0-100 之间的数值");
465
+ }
466
+ }
467
+ }
468
+
469
+ // ── paper 验证(可选)──
470
+ if (hasField(fepStr, "paper")) {
471
+ const paperBlock = extractYamlBlock(fepStr, "paper");
472
+ const barIntervalValue = getFieldValue(paperBlock, "barIntervalSeconds");
473
+ if (barIntervalValue) {
474
+ const num = parseInt(barIntervalValue, 10);
475
+ if (isNaN(num) || num < 1) {
476
+ warnings.push("paper.barIntervalSeconds 应为正整数");
477
+ }
478
+ }
479
+ }
480
+
481
+ return { hasUniverse };
482
+ }
483
+
484
+ /**
485
+ * 验证策略脚本
486
+ */
487
+ function validateStrategyScript(
488
+ scriptStr: string,
489
+ hasUniverse: boolean,
490
+ errors: string[],
491
+ warnings: string[],
492
+ ): void {
493
+ const codeWithoutComments = removePythonComments(scriptStr);
494
+
495
+ // 检查策略函数
496
+ const hasCompute =
497
+ /\bdef\s+compute\s*\(\s*data\s*\)/.test(scriptStr) ||
498
+ /\bdef\s+compute\s*\(\s*data\s*,\s*context\s*(?:=\s*None)?\s*\)/.test(scriptStr);
499
+ const hasSelect = /\bdef\s+select\s*\(\s*universe\s*\)/.test(scriptStr);
500
+
501
+ if (!hasCompute && !hasSelect) {
502
+ errors.push("scripts/strategy.py 必须定义 compute(data) 或 select(universe) 函数");
503
+ }
504
+
505
+ // 如果有 universe 配置,推荐使用 select
506
+ if (hasUniverse && !hasSelect) {
507
+ warnings.push("backtest 配置了 universe,建议使用 select(universe) 函数实现多标的策略");
508
+ }
509
+
510
+ // 如果没有 universe 但使用 select,给出警告
511
+ if (!hasUniverse && hasSelect && !hasCompute) {
512
+ warnings.push("使用 select(universe) 函数时,建议在 backtest 中配置 universe");
513
+ }
514
+
515
+ // 检查返回值结构(简单检查)
516
+ if (hasCompute && !/\baction\b/.test(scriptStr)) {
517
+ warnings.push("compute(data) 返回值应包含 action 字段(buy/sell/hold/target)");
518
+ }
519
+
520
+ // 检查禁止的 import
521
+ for (const pattern of FORBIDDEN_IMPORT_PATTERNS) {
522
+ if (pattern.test(scriptStr)) {
523
+ const match = pattern.exec(scriptStr);
524
+ const matchStr = match ? match[0] : pattern.source;
525
+ errors.push(`scripts/strategy.py 包含禁止的导入: ${matchStr}(服务器将拒绝)`);
526
+ }
527
+ }
528
+
529
+ // 检查禁止的函数调用(忽略注释)
530
+ for (const pattern of FORBIDDEN_CALL_PATTERNS) {
531
+ if (pattern.test(codeWithoutComments)) {
532
+ const match = pattern.exec(codeWithoutComments);
533
+ const matchStr = match ? match[0] : pattern.source;
534
+ errors.push(`scripts/strategy.py 包含禁止的函数调用: ${matchStr}(服务器将拒绝)`);
535
+ }
536
+ }
537
+
538
+ // 检查破坏回测一致性的模式(忽略注释)
539
+ for (const pattern of BACKTEST_BREAKING_PATTERNS) {
540
+ if (pattern.test(codeWithoutComments)) {
541
+ const match = pattern.exec(codeWithoutComments);
542
+ const matchStr = match ? match[0] : pattern.source;
543
+ errors.push(
544
+ `scripts/strategy.py 包含破坏回测一致性的调用: ${matchStr}(请使用回测时间而非实时时间)`,
545
+ );
546
+ }
547
+ }
548
+ }
549
+
550
+ /**
551
+ * 验证策略包目录(FEP v2.0)
552
+ * @param dirPath 策略包目录路径
553
+ * @returns 验证结果
554
+ */
555
+ export async function validateStrategyPackage(dirPath: string): Promise<ValidateResult> {
556
+ const errors: string[] = [];
557
+ const warnings: string[] = [];
558
+
559
+ const normalizedDir = path.resolve(dirPath);
560
+ const fepPath = path.join(normalizedDir, "fep.yaml");
561
+ const scriptDir = path.join(normalizedDir, "scripts");
562
+ const strategyPath = path.join(scriptDir, "strategy.py");
563
+
564
+ // ── 检查必需文件 ──
565
+ let fepContent: string;
566
+ try {
567
+ const raw = await readFile(fepPath, "utf-8");
568
+ fepContent = typeof raw === "string" ? raw : String(raw ?? "");
569
+ } catch {
570
+ errors.push(`缺少或无法读取 fep.yaml: ${fepPath}`);
571
+ return { valid: false, errors };
572
+ }
573
+
574
+ let strategyContent: string;
575
+ try {
576
+ const raw = await readFile(strategyPath, "utf-8");
577
+ strategyContent = typeof raw === "string" ? raw : String(raw ?? "");
578
+ } catch {
579
+ errors.push(`缺少或无法读取 scripts/strategy.py: ${strategyPath}`);
580
+ return { valid: false, errors };
581
+ }
582
+
583
+ // ── 验证 fep.yaml ──
584
+ const { hasUniverse } = validateFepYaml(fepContent, errors, warnings);
585
+
586
+ // ── 验证 strategy.py ──
587
+ validateStrategyScript(strategyContent, hasUniverse, errors, warnings);
588
+
589
+ return {
590
+ valid: errors.length === 0,
591
+ errors,
592
+ warnings: warnings.length > 0 ? warnings : undefined,
593
+ };
594
+ }