@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,109 @@
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
+ });
@@ -0,0 +1,303 @@
1
+ import fs from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import type { ForkMeta, CreatedMeta, LocalStrategy, StrategyPerformance } from "./types.js";
5
+
6
+ const WORKSPACE_DIRNAME = "workspace";
7
+ const STRATEGIES_DIRNAME = "strategies";
8
+ const FORK_META_FILENAME = ".fork-meta.json";
9
+ const CREATED_META_FILENAME = ".created-meta.json";
10
+ const FEP_FILENAME = "fep.yaml";
11
+
12
+ /**
13
+ * Get the root strategies directory.
14
+ * Default: ~/.openfinclaw/workspace/strategies/
15
+ * Also checks legacy ~/.openclaw/workspace/strategies/
16
+ */
17
+ export function getStrategiesRoot(): string {
18
+ const home = homedir();
19
+ const newDir = path.join(home, ".openfinclaw", WORKSPACE_DIRNAME, STRATEGIES_DIRNAME);
20
+ const legacyDir = path.join(home, ".openclaw", WORKSPACE_DIRNAME, STRATEGIES_DIRNAME);
21
+
22
+ if (fs.existsSync(newDir)) {
23
+ return newDir;
24
+ }
25
+ if (fs.existsSync(legacyDir)) {
26
+ return legacyDir;
27
+ }
28
+ return newDir;
29
+ }
30
+
31
+ /**
32
+ * Generate a slugified directory name from strategy name.
33
+ * - Lowercase
34
+ * - Spaces/underscores to hyphens
35
+ * - Remove special characters
36
+ * - Max 40 characters
37
+ */
38
+ export function slugifyName(name: string): string {
39
+ return name
40
+ .toLowerCase()
41
+ .replace(/[\s_]+/g, "-")
42
+ .replace(/[^a-z0-9-]/g, "")
43
+ .replace(/-+/g, "-")
44
+ .replace(/^-|-$/g, "")
45
+ .slice(0, 40);
46
+ }
47
+
48
+ /**
49
+ * Extract short ID (first 8 chars) from full UUID.
50
+ */
51
+ export function extractShortId(uuid: string): string {
52
+ const match = /^([a-f0-9]{8})/i.exec(uuid);
53
+ return match ? match[1].toLowerCase() : uuid.slice(0, 8).toLowerCase();
54
+ }
55
+
56
+ /**
57
+ * Generate directory name for a forked strategy.
58
+ * Format: {slugified-name}-{short-id}
59
+ */
60
+ export function generateForkDirName(name: string, sourceId: string): string {
61
+ const slug = slugifyName(name);
62
+ const shortId = extractShortId(sourceId);
63
+ return `${slug}-${shortId}`;
64
+ }
65
+
66
+ /**
67
+ * Generate directory name for a created strategy.
68
+ * Format: {slugified-name}
69
+ */
70
+ export function generateCreatedDirName(name: string): string {
71
+ return slugifyName(name);
72
+ }
73
+
74
+ /**
75
+ * Create date directory under strategies root.
76
+ * Returns the full path to the date directory.
77
+ */
78
+ export function createDateDir(baseDir: string, date?: string): string {
79
+ const dateStr = date ?? formatDate(new Date());
80
+ const datePath = path.join(baseDir, dateStr);
81
+ fs.mkdirSync(datePath, { recursive: true });
82
+ return datePath;
83
+ }
84
+
85
+ /**
86
+ * Format date as YYYY-MM-DD.
87
+ */
88
+ export function formatDate(date: Date): string {
89
+ const year = date.getFullYear();
90
+ const month = String(date.getMonth() + 1).padStart(2, "0");
91
+ const day = String(date.getDate()).padStart(2, "0");
92
+ return `${year}-${month}-${day}`;
93
+ }
94
+
95
+ /**
96
+ * Check if a directory is a valid strategy directory.
97
+ */
98
+ export function isStrategyDir(dirPath: string): boolean {
99
+ const fepPath = path.join(dirPath, FEP_FILENAME);
100
+ const forkMetaPath = path.join(dirPath, FORK_META_FILENAME);
101
+ const createdMetaPath = path.join(dirPath, CREATED_META_FILENAME);
102
+
103
+ try {
104
+ return (
105
+ fs.existsSync(fepPath) && (fs.existsSync(forkMetaPath) || fs.existsSync(createdMetaPath))
106
+ );
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Read fork metadata from a strategy directory.
114
+ */
115
+ export function readForkMeta(dirPath: string): ForkMeta | null {
116
+ const metaPath = path.join(dirPath, FORK_META_FILENAME);
117
+ try {
118
+ const content = fs.readFileSync(metaPath, "utf-8");
119
+ return JSON.parse(content) as ForkMeta;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Read created metadata from a strategy directory.
127
+ */
128
+ export function readCreatedMeta(dirPath: string): CreatedMeta | null {
129
+ const metaPath = path.join(dirPath, CREATED_META_FILENAME);
130
+ try {
131
+ const content = fs.readFileSync(metaPath, "utf-8");
132
+ return JSON.parse(content) as CreatedMeta;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Write fork metadata to a strategy directory.
140
+ */
141
+ export function writeForkMeta(dirPath: string, meta: ForkMeta): void {
142
+ const metaPath = path.join(dirPath, FORK_META_FILENAME);
143
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
144
+ }
145
+
146
+ /**
147
+ * Write created metadata to a strategy directory.
148
+ */
149
+ export function writeCreatedMeta(dirPath: string, meta: CreatedMeta): void {
150
+ const metaPath = path.join(dirPath, CREATED_META_FILENAME);
151
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
152
+ }
153
+
154
+ /**
155
+ * List all date directories under strategies root.
156
+ */
157
+ export function listDateDirs(): string[] {
158
+ const root = getStrategiesRoot();
159
+ if (!fs.existsSync(root)) {
160
+ return [];
161
+ }
162
+
163
+ const entries = fs.readdirSync(root, { withFileTypes: true });
164
+ return entries
165
+ .filter((e) => e.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(e.name))
166
+ .map((e) => e.name)
167
+ .sort((a, b) => b.localeCompare(a));
168
+ }
169
+
170
+ /**
171
+ * List all local strategies.
172
+ */
173
+ export async function listLocalStrategies(): Promise<LocalStrategy[]> {
174
+ const root = getStrategiesRoot();
175
+ if (!fs.existsSync(root)) {
176
+ return [];
177
+ }
178
+
179
+ const strategies: LocalStrategy[] = [];
180
+ const dateDirs = listDateDirs();
181
+
182
+ for (const dateDir of dateDirs) {
183
+ const datePath = path.join(root, dateDir);
184
+ const entries = fs.readdirSync(datePath, { withFileTypes: true });
185
+
186
+ for (const entry of entries) {
187
+ if (!entry.isDirectory()) continue;
188
+
189
+ const strategyPath = path.join(datePath, entry.name);
190
+ const localStrategy = await buildLocalStrategy(strategyPath, dateDir);
191
+ if (localStrategy) {
192
+ strategies.push(localStrategy);
193
+ }
194
+ }
195
+ }
196
+
197
+ return strategies;
198
+ }
199
+
200
+ /**
201
+ * Build LocalStrategy from a directory path.
202
+ */
203
+ async function buildLocalStrategy(dirPath: string, dateDir: string): Promise<LocalStrategy | null> {
204
+ if (!isStrategyDir(dirPath)) {
205
+ return null;
206
+ }
207
+
208
+ const forkMeta = readForkMeta(dirPath);
209
+ const createdMeta = readCreatedMeta(dirPath);
210
+
211
+ if (forkMeta) {
212
+ return {
213
+ name: path.basename(dirPath),
214
+ displayName: forkMeta.sourceName,
215
+ localPath: dirPath,
216
+ dateDir,
217
+ type: "forked",
218
+ sourceId: forkMeta.sourceId,
219
+ createdAt: forkMeta.forkedAt,
220
+ };
221
+ }
222
+
223
+ if (createdMeta) {
224
+ return {
225
+ name: path.basename(dirPath),
226
+ displayName: createdMeta.displayName ?? createdMeta.name,
227
+ localPath: dirPath,
228
+ dateDir,
229
+ type: "created",
230
+ createdAt: createdMeta.createdAt,
231
+ };
232
+ }
233
+
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * Find a local strategy by name or short ID.
239
+ */
240
+ export async function findLocalStrategy(nameOrId: string): Promise<LocalStrategy | null> {
241
+ const strategies = await listLocalStrategies();
242
+
243
+ const normalized = nameOrId.toLowerCase();
244
+
245
+ return (
246
+ strategies.find(
247
+ (s) =>
248
+ s.name.toLowerCase() === normalized ||
249
+ s.name.toLowerCase().startsWith(normalized) ||
250
+ s.sourceId?.toLowerCase().startsWith(normalized) ||
251
+ (s.sourceId && extractShortId(s.sourceId).toLowerCase() === normalized),
252
+ ) ?? null
253
+ );
254
+ }
255
+
256
+ /**
257
+ * Remove a local strategy.
258
+ */
259
+ export async function removeLocalStrategy(
260
+ nameOrId: string,
261
+ ): Promise<{ success: boolean; error?: string }> {
262
+ const strategy = await findLocalStrategy(nameOrId);
263
+ if (!strategy) {
264
+ return { success: false, error: `Strategy not found: ${nameOrId}` };
265
+ }
266
+
267
+ try {
268
+ fs.rmSync(strategy.localPath, { recursive: true, force: true });
269
+ return { success: true };
270
+ } catch (err) {
271
+ return {
272
+ success: false,
273
+ error: err instanceof Error ? err.message : String(err),
274
+ };
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Parse strategy ID from various formats.
280
+ * Supports: UUID, short ID, Hub URL
281
+ */
282
+ export function parseStrategyId(input: string): string {
283
+ const trimmed = input.trim();
284
+
285
+ const urlMatch = /strategy\/([a-f0-9-]{36})/i.exec(trimmed);
286
+ if (urlMatch) {
287
+ return urlMatch[1].toLowerCase();
288
+ }
289
+
290
+ const shortIdMatch = /^([a-f0-9]{8})$/i.exec(trimmed);
291
+ if (shortIdMatch) {
292
+ return shortIdMatch[1].toLowerCase();
293
+ }
294
+
295
+ const uuidMatch = /^([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.exec(
296
+ trimmed,
297
+ );
298
+ if (uuidMatch) {
299
+ return uuidMatch[1].toLowerCase();
300
+ }
301
+
302
+ return trimmed.toLowerCase();
303
+ }