@openfinclaw/openfinclaw-strategy 0.0.11 → 0.1.1
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/README.md +60 -93
- package/index.test.ts +11 -11
- package/index.ts +18 -979
- package/openclaw.plugin.json +25 -8
- package/package.json +10 -4
- package/skills/openfinclaw/SKILL.md +78 -78
- package/skills/price-check/SKILL.md +118 -0
- package/skills/skill-publish/SKILL.md +4 -4
- package/skills/strategy-builder/SKILL.md +124 -399
- package/skills/strategy-fork/SKILL.md +2 -2
- package/skills/strategy-pack/SKILL.md +12 -12
- package/src/cli.ts +5 -5
- package/src/config.ts +57 -0
- package/src/datahub/client.ts +150 -0
- package/src/datahub/tools.ts +349 -0
- package/src/strategy/client.ts +44 -0
- package/src/{fork.ts → strategy/fork.ts} +12 -11
- package/src/{strategy-storage.ts → strategy/storage.ts} +6 -7
- package/src/strategy/tools.ts +524 -0
- package/src/{validate.ts → strategy/validate.ts} +3 -35
- package/src/types.ts +42 -0
- package/LICENSE +0 -21
- package/src/strategy-storage.test.ts +0 -109
- package/src/validate.test.ts +0 -841
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Strategy package validation for Findoo Backtest Agent (FEP v2.0).
|
|
3
3
|
* Validates fep.yaml structure, strategy script safety, and required fields.
|
|
4
|
-
* @module openfinclaw/validate
|
|
5
4
|
*/
|
|
6
5
|
import { readFile } from "node:fs/promises";
|
|
7
6
|
import path from "node:path";
|
|
8
|
-
import type { FepV2Style, FepV2Timeframe } from "
|
|
7
|
+
import type { FepV2Style, FepV2Timeframe } from "../types.js";
|
|
9
8
|
|
|
10
9
|
/** FEP 版本常量 */
|
|
11
10
|
const FEP_VERSION = "2.0";
|
|
@@ -223,7 +222,6 @@ function validateSymbol(symbol: string): { valid: boolean; market?: string } {
|
|
|
223
222
|
|
|
224
223
|
/**
|
|
225
224
|
* 提取 YAML 块内容
|
|
226
|
-
* 提取指定块名下的所有内容(缩进的子行)
|
|
227
225
|
*/
|
|
228
226
|
function extractYamlBlock(yamlContent: string, blockName: string): string {
|
|
229
227
|
const lines = yamlContent.split("\n");
|
|
@@ -287,7 +285,6 @@ function validateFepYaml(
|
|
|
287
285
|
): { hasUniverse: boolean } {
|
|
288
286
|
let hasUniverse = false;
|
|
289
287
|
|
|
290
|
-
// 检查 fep 版本
|
|
291
288
|
const fepMatch = /^\s*fep\s*:\s*["']?([^"'\s]+)["']?/m.exec(fepStr);
|
|
292
289
|
if (!fepMatch) {
|
|
293
290
|
errors.push("fep.yaml 必须包含 'fep:' 版本声明(例如 fep: \"2.0\")");
|
|
@@ -297,13 +294,11 @@ function validateFepYaml(
|
|
|
297
294
|
);
|
|
298
295
|
}
|
|
299
296
|
|
|
300
|
-
// ── identity 验证 ──
|
|
301
297
|
if (!hasField(fepStr, "identity")) {
|
|
302
298
|
errors.push("fep.yaml 必须包含 'identity:' 节");
|
|
303
299
|
} else {
|
|
304
300
|
const identityBlock = extractYamlBlock(fepStr, "identity");
|
|
305
301
|
|
|
306
|
-
// 必填字段检查
|
|
307
302
|
const requiredIdentityFields = [
|
|
308
303
|
{ field: "id", message: "策略唯一标识" },
|
|
309
304
|
{ field: "name", message: "策略显示名称" },
|
|
@@ -324,7 +319,6 @@ function validateFepYaml(
|
|
|
324
319
|
}
|
|
325
320
|
}
|
|
326
321
|
|
|
327
|
-
// author.name 必填
|
|
328
322
|
if (!hasField(identityBlock, "author")) {
|
|
329
323
|
errors.push("fep.yaml identity 必须包含 'author' 节");
|
|
330
324
|
} else {
|
|
@@ -334,7 +328,6 @@ function validateFepYaml(
|
|
|
334
328
|
}
|
|
335
329
|
}
|
|
336
330
|
|
|
337
|
-
// style 枚举验证
|
|
338
331
|
const styleValue = getFieldValue(identityBlock, "style");
|
|
339
332
|
if (styleValue) {
|
|
340
333
|
const cleanedStyle = styleValue.replace(/["']/g, "");
|
|
@@ -343,7 +336,6 @@ function validateFepYaml(
|
|
|
343
336
|
}
|
|
344
337
|
}
|
|
345
338
|
|
|
346
|
-
// visibility 枚举验证
|
|
347
339
|
const visibilityValue = getFieldValue(identityBlock, "visibility");
|
|
348
340
|
if (visibilityValue) {
|
|
349
341
|
const cleanedVisibility = visibilityValue.replace(/["']/g, "");
|
|
@@ -354,23 +346,20 @@ function validateFepYaml(
|
|
|
354
346
|
}
|
|
355
347
|
}
|
|
356
348
|
|
|
357
|
-
// license 枚举验证
|
|
358
349
|
const licenseValue = getFieldValue(identityBlock, "license");
|
|
359
350
|
if (licenseValue) {
|
|
360
351
|
const cleanedLicense = licenseValue.replace(/["']/g, "");
|
|
361
352
|
if (!VALID_LICENSES.includes(cleanedLicense)) {
|
|
362
|
-
warnings.push(`fep.yaml
|
|
353
|
+
warnings.push(`fep.yaml identity.license 建议: ${VALID_LICENSES.join(", ")}`);
|
|
363
354
|
}
|
|
364
355
|
}
|
|
365
356
|
|
|
366
|
-
// tags 格式验证
|
|
367
357
|
const tagsValue = getFieldValue(identityBlock, "tags");
|
|
368
358
|
if (tagsValue && !tagsValue.startsWith("[")) {
|
|
369
359
|
warnings.push("identity.tags 应使用行内数组格式,如: tags: [trend, btc, crypto]");
|
|
370
360
|
}
|
|
371
361
|
}
|
|
372
362
|
|
|
373
|
-
// ── technical 验证(可选,有默认值)──
|
|
374
363
|
if (hasField(fepStr, "technical")) {
|
|
375
364
|
const technicalBlock = extractYamlBlock(fepStr, "technical");
|
|
376
365
|
|
|
@@ -385,13 +374,11 @@ function validateFepYaml(
|
|
|
385
374
|
}
|
|
386
375
|
}
|
|
387
376
|
|
|
388
|
-
// ── backtest 验证(必填)──
|
|
389
377
|
if (!hasField(fepStr, "backtest")) {
|
|
390
378
|
errors.push("fep.yaml 必须包含 'backtest:' 节");
|
|
391
379
|
} else {
|
|
392
380
|
const backtestBlock = extractYamlBlock(fepStr, "backtest");
|
|
393
381
|
|
|
394
|
-
// symbol 必填
|
|
395
382
|
if (!hasField(backtestBlock, "symbol")) {
|
|
396
383
|
errors.push("fep.yaml backtest 必须包含 'symbol'(交易品种)");
|
|
397
384
|
} else {
|
|
@@ -405,7 +392,6 @@ function validateFepYaml(
|
|
|
405
392
|
}
|
|
406
393
|
}
|
|
407
394
|
|
|
408
|
-
// defaultPeriod 必填
|
|
409
395
|
if (!hasField(backtestBlock, "defaultPeriod")) {
|
|
410
396
|
errors.push("fep.yaml backtest 必须包含 'defaultPeriod'");
|
|
411
397
|
} else {
|
|
@@ -418,12 +404,10 @@ function validateFepYaml(
|
|
|
418
404
|
}
|
|
419
405
|
}
|
|
420
406
|
|
|
421
|
-
// initialCapital 必填
|
|
422
407
|
if (!hasField(backtestBlock, "initialCapital")) {
|
|
423
408
|
errors.push("fep.yaml backtest 必须包含 'initialCapital'(初始资金)");
|
|
424
409
|
}
|
|
425
410
|
|
|
426
|
-
// timeframe 枚举验证(可选)
|
|
427
411
|
const timeframeValue = getFieldValue(backtestBlock, "timeframe");
|
|
428
412
|
if (timeframeValue) {
|
|
429
413
|
const cleanedTimeframe = timeframeValue.replace(/["']/g, "");
|
|
@@ -432,7 +416,6 @@ function validateFepYaml(
|
|
|
432
416
|
}
|
|
433
417
|
}
|
|
434
418
|
|
|
435
|
-
// universe 检测
|
|
436
419
|
if (hasField(backtestBlock, "universe")) {
|
|
437
420
|
hasUniverse = true;
|
|
438
421
|
const universeBlock = extractYamlBlock(backtestBlock, "universe");
|
|
@@ -441,7 +424,6 @@ function validateFepYaml(
|
|
|
441
424
|
}
|
|
442
425
|
}
|
|
443
426
|
|
|
444
|
-
// rebalance 验证(可选)
|
|
445
427
|
if (hasField(backtestBlock, "rebalance")) {
|
|
446
428
|
const rebalanceBlock = extractYamlBlock(backtestBlock, "rebalance");
|
|
447
429
|
const freqValue = getFieldValue(rebalanceBlock, "frequency");
|
|
@@ -454,7 +436,6 @@ function validateFepYaml(
|
|
|
454
436
|
}
|
|
455
437
|
}
|
|
456
438
|
|
|
457
|
-
// ── risk 验证(可选)──
|
|
458
439
|
if (hasField(fepStr, "risk")) {
|
|
459
440
|
const riskBlock = extractYamlBlock(fepStr, "risk");
|
|
460
441
|
const thresholdValue = getFieldValue(riskBlock, "maxDrawdownThreshold");
|
|
@@ -466,7 +447,6 @@ function validateFepYaml(
|
|
|
466
447
|
}
|
|
467
448
|
}
|
|
468
449
|
|
|
469
|
-
// ── paper 验证(可选)──
|
|
470
450
|
if (hasField(fepStr, "paper")) {
|
|
471
451
|
const paperBlock = extractYamlBlock(fepStr, "paper");
|
|
472
452
|
const barIntervalValue = getFieldValue(paperBlock, "barIntervalSeconds");
|
|
@@ -492,7 +472,6 @@ function validateStrategyScript(
|
|
|
492
472
|
): void {
|
|
493
473
|
const codeWithoutComments = removePythonComments(scriptStr);
|
|
494
474
|
|
|
495
|
-
// 检查策略函数
|
|
496
475
|
const hasCompute =
|
|
497
476
|
/\bdef\s+compute\s*\(\s*data\s*\)/.test(scriptStr) ||
|
|
498
477
|
/\bdef\s+compute\s*\(\s*data\s*,\s*context\s*(?:=\s*None)?\s*\)/.test(scriptStr);
|
|
@@ -502,22 +481,18 @@ function validateStrategyScript(
|
|
|
502
481
|
errors.push("scripts/strategy.py 必须定义 compute(data) 或 select(universe) 函数");
|
|
503
482
|
}
|
|
504
483
|
|
|
505
|
-
// 如果有 universe 配置,推荐使用 select
|
|
506
484
|
if (hasUniverse && !hasSelect) {
|
|
507
485
|
warnings.push("backtest 配置了 universe,建议使用 select(universe) 函数实现多标的策略");
|
|
508
486
|
}
|
|
509
487
|
|
|
510
|
-
// 如果没有 universe 但使用 select,给出警告
|
|
511
488
|
if (!hasUniverse && hasSelect && !hasCompute) {
|
|
512
489
|
warnings.push("使用 select(universe) 函数时,建议在 backtest 中配置 universe");
|
|
513
490
|
}
|
|
514
491
|
|
|
515
|
-
// 检查返回值结构(简单检查)
|
|
516
492
|
if (hasCompute && !/\baction\b/.test(scriptStr)) {
|
|
517
493
|
warnings.push("compute(data) 返回值应包含 action 字段(buy/sell/hold/target)");
|
|
518
494
|
}
|
|
519
495
|
|
|
520
|
-
// 检查禁止的 import
|
|
521
496
|
for (const pattern of FORBIDDEN_IMPORT_PATTERNS) {
|
|
522
497
|
if (pattern.test(scriptStr)) {
|
|
523
498
|
const match = pattern.exec(scriptStr);
|
|
@@ -526,7 +501,6 @@ function validateStrategyScript(
|
|
|
526
501
|
}
|
|
527
502
|
}
|
|
528
503
|
|
|
529
|
-
// 检查禁止的函数调用(忽略注释)
|
|
530
504
|
for (const pattern of FORBIDDEN_CALL_PATTERNS) {
|
|
531
505
|
if (pattern.test(codeWithoutComments)) {
|
|
532
506
|
const match = pattern.exec(codeWithoutComments);
|
|
@@ -535,7 +509,6 @@ function validateStrategyScript(
|
|
|
535
509
|
}
|
|
536
510
|
}
|
|
537
511
|
|
|
538
|
-
// 检查破坏回测一致性的模式(忽略注释)
|
|
539
512
|
for (const pattern of BACKTEST_BREAKING_PATTERNS) {
|
|
540
513
|
if (pattern.test(codeWithoutComments)) {
|
|
541
514
|
const match = pattern.exec(codeWithoutComments);
|
|
@@ -549,8 +522,6 @@ function validateStrategyScript(
|
|
|
549
522
|
|
|
550
523
|
/**
|
|
551
524
|
* 验证策略包目录(FEP v2.0)
|
|
552
|
-
* @param dirPath 策略包目录路径
|
|
553
|
-
* @returns 验证结果
|
|
554
525
|
*/
|
|
555
526
|
export async function validateStrategyPackage(dirPath: string): Promise<ValidateResult> {
|
|
556
527
|
const errors: string[] = [];
|
|
@@ -561,7 +532,6 @@ export async function validateStrategyPackage(dirPath: string): Promise<Validate
|
|
|
561
532
|
const scriptDir = path.join(normalizedDir, "scripts");
|
|
562
533
|
const strategyPath = path.join(scriptDir, "strategy.py");
|
|
563
534
|
|
|
564
|
-
// ── 检查必需文件 ──
|
|
565
535
|
let fepContent: string;
|
|
566
536
|
try {
|
|
567
537
|
const raw = await readFile(fepPath, "utf-8");
|
|
@@ -580,10 +550,8 @@ export async function validateStrategyPackage(dirPath: string): Promise<Validate
|
|
|
580
550
|
return { valid: false, errors };
|
|
581
551
|
}
|
|
582
552
|
|
|
583
|
-
// ── 验证 fep.yaml ──
|
|
584
553
|
const { hasUniverse } = validateFepYaml(fepContent, errors, warnings);
|
|
585
554
|
|
|
586
|
-
// ── 验证 strategy.py ──
|
|
587
555
|
validateStrategyScript(strategyContent, hasUniverse, errors, warnings);
|
|
588
556
|
|
|
589
557
|
return {
|
|
@@ -591,4 +559,4 @@ export async function validateStrategyPackage(dirPath: string): Promise<Validate
|
|
|
591
559
|
errors,
|
|
592
560
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
593
561
|
};
|
|
594
|
-
}
|
|
562
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -492,3 +492,45 @@ export interface LeaderboardResponse {
|
|
|
492
492
|
total: number;
|
|
493
493
|
cachedAt: string;
|
|
494
494
|
}
|
|
495
|
+
|
|
496
|
+
// ─────────────────────────────────────────────────────────────
|
|
497
|
+
// DataHub 行情数据类型
|
|
498
|
+
// ─────────────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/** 市场类型 */
|
|
501
|
+
export type MarketType = "crypto" | "equity";
|
|
502
|
+
|
|
503
|
+
/** OHLCV K线数据 */
|
|
504
|
+
export interface OHLCV {
|
|
505
|
+
timestamp: number;
|
|
506
|
+
open: number;
|
|
507
|
+
high: number;
|
|
508
|
+
low: number;
|
|
509
|
+
close: number;
|
|
510
|
+
volume: number;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** 行情快照 */
|
|
514
|
+
export interface Ticker {
|
|
515
|
+
symbol: string;
|
|
516
|
+
market: MarketType;
|
|
517
|
+
last: number;
|
|
518
|
+
volume24h?: number;
|
|
519
|
+
timestamp: number;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ─────────────────────────────────────────────────────────────
|
|
523
|
+
// 统一插件配置
|
|
524
|
+
// ─────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
/** 统一插件配置 */
|
|
527
|
+
export interface UnifiedPluginConfig {
|
|
528
|
+
/** API Key (用于 Hub 和 DataHub) */
|
|
529
|
+
apiKey: string | undefined;
|
|
530
|
+
/** Hub API URL */
|
|
531
|
+
hubApiUrl: string;
|
|
532
|
+
/** DataHub Gateway URL */
|
|
533
|
+
datahubGatewayUrl: string;
|
|
534
|
+
/** 请求超时(毫秒) */
|
|
535
|
+
requestTimeoutMs: number;
|
|
536
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 Peter Steinberger
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
slugifyName,
|
|
4
|
-
extractShortId,
|
|
5
|
-
generateForkDirName,
|
|
6
|
-
generateCreatedDirName,
|
|
7
|
-
parseStrategyId,
|
|
8
|
-
formatDate,
|
|
9
|
-
} from "./strategy-storage.js";
|
|
10
|
-
|
|
11
|
-
describe("slugifyName", () => {
|
|
12
|
-
it("converts to lowercase", () => {
|
|
13
|
-
expect(slugifyName("BTC Strategy")).toBe("btc-strategy");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("replaces spaces with hyphens", () => {
|
|
17
|
-
expect(slugifyName("my cool strategy")).toBe("my-cool-strategy");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("replaces underscores with hyphens", () => {
|
|
21
|
-
expect(slugifyName("my_cool_strategy")).toBe("my-cool-strategy");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("removes special characters", () => {
|
|
25
|
-
expect(slugifyName("BTC@Strategy#123!")).toBe("btcstrategy123");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("limits length to 40 characters", () => {
|
|
29
|
-
const longName = "a".repeat(50);
|
|
30
|
-
expect(slugifyName(longName)).toHaveLength(40);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("handles multiple consecutive spaces", () => {
|
|
34
|
-
expect(slugifyName("my strategy")).toBe("my-strategy");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("strips leading and trailing hyphens", () => {
|
|
38
|
-
expect(slugifyName("-my-strategy-")).toBe("my-strategy");
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("extractShortId", () => {
|
|
43
|
-
it("extracts first 8 chars from UUID", () => {
|
|
44
|
-
expect(extractShortId("34a5792f-7d20-4a15-90f3-26f1c54fa4a6")).toBe("34a5792f");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("handles short input", () => {
|
|
48
|
-
expect(extractShortId("abc")).toBe("abc");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("converts to lowercase", () => {
|
|
52
|
-
expect(extractShortId("ABC12345-XXXX-XXXX-XXXX-XXXXXXXXXXXX")).toBe("abc12345");
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe("generateForkDirName", () => {
|
|
57
|
-
it("combines slug and short ID", () => {
|
|
58
|
-
expect(generateForkDirName("BTC Strategy", "34a5792f-7d20-4a15-90f3-26f1c54fa4a6")).toBe(
|
|
59
|
-
"btc-strategy-34a5792f",
|
|
60
|
-
);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("handles long names", () => {
|
|
64
|
-
const longName = "A".repeat(50);
|
|
65
|
-
const result = generateForkDirName(longName, "12345678-XXXX-XXXX-XXXX-XXXXXXXXXXXX");
|
|
66
|
-
expect(result).toMatch(/^a+-12345678$/);
|
|
67
|
-
expect(result.length).toBeLessThanOrEqual(49); // 40 + 1 + 8
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("generateCreatedDirName", () => {
|
|
72
|
-
it("returns slugified name", () => {
|
|
73
|
-
expect(generateCreatedDirName("My New Strategy")).toBe("my-new-strategy");
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("parseStrategyId", () => {
|
|
78
|
-
it("extracts ID from Hub URL", () => {
|
|
79
|
-
expect(
|
|
80
|
-
parseStrategyId("https://hub.openfinclaw.ai/strategy/34a5792f-7d20-4a15-90f3-26f1c54fa4a6"),
|
|
81
|
-
).toBe("34a5792f-7d20-4a15-90f3-26f1c54fa4a6");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("normalizes full UUID to lowercase", () => {
|
|
85
|
-
expect(parseStrategyId("34A5792F-7D20-4A15-90F3-26F1C54FA4A6")).toBe(
|
|
86
|
-
"34a5792f-7d20-4a15-90f3-26f1c54fa4a6",
|
|
87
|
-
);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("returns short ID as-is (lowercase)", () => {
|
|
91
|
-
expect(parseStrategyId("34A5792F")).toBe("34a5792f");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("handles whitespace", () => {
|
|
95
|
-
expect(parseStrategyId(" 34a5792f ")).toBe("34a5792f");
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe("formatDate", () => {
|
|
100
|
-
it("formats date as YYYY-MM-DD", () => {
|
|
101
|
-
const date = new Date("2026-03-16T10:00:00Z");
|
|
102
|
-
expect(formatDate(date)).toBe("2026-03-16");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("pads month and day", () => {
|
|
106
|
-
const date = new Date("2026-01-05T10:00:00Z");
|
|
107
|
-
expect(formatDate(date)).toBe("2026-01-05");
|
|
108
|
-
});
|
|
109
|
-
});
|