@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.
package/src/cli.ts ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * CLI commands for strategy management.
3
+ */
4
+ import type { Command } from "commander";
5
+ import { forkStrategy, fetchStrategyInfo } from "./fork.js";
6
+ import { listLocalStrategies, findLocalStrategy, removeLocalStrategy } from "./strategy-storage.js";
7
+ import type { SkillApiConfig, LeaderboardResponse, BoardType } from "./types.js";
8
+
9
+ type Logger = {
10
+ info: (message: string) => void;
11
+ warn: (message: string) => void;
12
+ error: (message: string) => void;
13
+ };
14
+
15
+ export function registerStrategyCli(params: {
16
+ program: Command;
17
+ config: SkillApiConfig;
18
+ logger: Logger;
19
+ }) {
20
+ const { program, config } = params;
21
+
22
+ const root = program
23
+ .command("strategy")
24
+ .description("Strategy management: fork from Hub, list local, validate (FEP v2.0)");
25
+
26
+ // ── strategy leaderboard ──
27
+ root
28
+ .command("leaderboard [boardType]")
29
+ .description("Query strategy leaderboard from Hub (no API key required)")
30
+ .option("-l, --limit <number>", "Number of results (max 100)", "20")
31
+ .option("-o, --offset <number>", "Offset for pagination", "0")
32
+ .action(
33
+ async (boardType: BoardType = "composite", options: { limit?: string; offset?: string }) => {
34
+ const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 100);
35
+ const offset = Math.max(Number(options.offset) || 0, 0);
36
+
37
+ const url = new URL(`${config.baseUrl}/api/v1/skill/leaderboard/${boardType}`);
38
+ url.searchParams.set("limit", String(limit));
39
+ url.searchParams.set("offset", String(offset));
40
+
41
+ try {
42
+ const response = await fetch(url.toString(), {
43
+ method: "GET",
44
+ headers: { Accept: "application/json" },
45
+ signal: AbortSignal.timeout(config.requestTimeoutMs),
46
+ });
47
+
48
+ if (!response.ok) {
49
+ console.error(`✗ 请求失败: HTTP ${response.status}`);
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+
54
+ const data = (await response.json()) as LeaderboardResponse;
55
+ const boardNames: Record<string, string> = {
56
+ composite: "综合榜",
57
+ returns: "收益榜",
58
+ risk: "风控榜",
59
+ popular: "人气榜",
60
+ rising: "新星榜",
61
+ };
62
+
63
+ console.log(
64
+ `${boardNames[boardType] || boardType} Top ${data.strategies.length} (共 ${data.total} 个策略):`,
65
+ );
66
+ console.log("");
67
+
68
+ for (const s of data.strategies) {
69
+ const perf = s.performance || {};
70
+ const returnStr =
71
+ typeof perf.returnSincePublish === "number"
72
+ ? `收益: ${(perf.returnSincePublish * 100).toFixed(1)}%`
73
+ : "收益: --";
74
+ const sharpeStr =
75
+ typeof perf.sharpeRatio === "number"
76
+ ? `夏普: ${perf.sharpeRatio.toFixed(2)}`
77
+ : "夏普: --";
78
+ const ddStr =
79
+ typeof perf.maxDrawdown === "number"
80
+ ? `回撤: ${(perf.maxDrawdown * 100).toFixed(1)}%`
81
+ : "回撤: --";
82
+ const author = s.author?.displayName || "未知";
83
+
84
+ const truncatedName = s.name.length > 35 ? s.name.slice(0, 32) + "..." : s.name;
85
+ const hubUrl = `https://hub.openfinclaw.ai/strategy/${s.id}`;
86
+ const nameLink = `[${truncatedName}](${hubUrl})`;
87
+ console.log(
88
+ `#${String(s.rank).padStart(2)} ${nameLink} ${returnStr} ${sharpeStr} ${ddStr} 作者: ${author}`,
89
+ );
90
+ }
91
+
92
+ console.log("");
93
+ console.log("使用 openclaw strategy show <id> --remote 查看详情");
94
+ console.log("使用 openclaw strategy fork <id> 下载策略(需要 API Key)");
95
+ } catch (err) {
96
+ console.error(`✗ 请求失败: ${err instanceof Error ? err.message : String(err)}`);
97
+ process.exitCode = 1;
98
+ }
99
+ },
100
+ );
101
+
102
+ // ── strategy fork ──
103
+ root
104
+ .command("fork <strategy-id>")
105
+ .description("Fork a strategy from hub.openfinclaw.ai to local directory")
106
+ .option("-d, --dir <path>", "Custom target directory")
107
+ .option("--date <date>", "Date directory (YYYY-MM-DD, default: today)")
108
+ .option("-y, --yes", "Skip confirmation", false)
109
+ .action(async (strategyId: string, options: { dir?: string; date?: string; yes?: boolean }) => {
110
+ const result = await forkStrategy(config, strategyId, {
111
+ targetDir: options.dir,
112
+ dateDir: options.date,
113
+ skipConfirm: options.yes,
114
+ });
115
+
116
+ if (result.success) {
117
+ console.log("✓ 策略 Fork 成功!");
118
+ console.log("");
119
+ console.log(` 名称: ${result.sourceName}`);
120
+ console.log(` 本地路径: ${result.localPath}`);
121
+ console.log("");
122
+ console.log("下一步:");
123
+ console.log(` 编辑: code ${result.localPath}/scripts/strategy.py`);
124
+ console.log(` 验证: openfinclaw strategy validate ${result.localPath}`);
125
+ console.log(` 发布: openfinclaw strategy publish ${result.localPath}`);
126
+ } else {
127
+ console.error(`✗ Fork 失败: ${result.error}`);
128
+ process.exitCode = 1;
129
+ }
130
+ });
131
+
132
+ // ── strategy list ──
133
+ root
134
+ .command("list")
135
+ .description("List all local strategies")
136
+ .option("--json", "Output as JSON", false)
137
+ .action(async (options: { json?: boolean }) => {
138
+ const strategies = await listLocalStrategies();
139
+
140
+ if (options.json) {
141
+ console.log(JSON.stringify(strategies, null, 2));
142
+ return;
143
+ }
144
+
145
+ if (strategies.length === 0) {
146
+ console.log("本地暂无策略。");
147
+ console.log("");
148
+ console.log("使用 'openfinclaw strategy fork <id>' 从 Hub 下载策略。");
149
+ return;
150
+ }
151
+
152
+ console.log(`本地策略列表 (共 ${strategies.length} 个):`);
153
+ console.log("");
154
+
155
+ let currentDate = "";
156
+ for (const s of strategies) {
157
+ if (s.dateDir !== currentDate) {
158
+ currentDate = s.dateDir;
159
+ console.log(`${s.dateDir}/`);
160
+ }
161
+ const typeLabel = s.type === "forked" ? "(forked)" : "(created)";
162
+ const name = s.name.length > 40 ? s.name.slice(0, 37) + "..." : s.name;
163
+ const displayName =
164
+ s.displayName.length > 20 ? s.displayName.slice(0, 17) + "..." : s.displayName;
165
+ console.log(` ${name.padEnd(40)} ${displayName.padEnd(20)} ${typeLabel}`);
166
+ }
167
+ });
168
+
169
+ // ── strategy show ──
170
+ root
171
+ .command("show <name-or-id>")
172
+ .description("Show strategy details")
173
+ .option("--remote", "Fetch latest info from Hub", false)
174
+ .option("--json", "Output as JSON", false)
175
+ .action(async (nameOrId: string, options: { remote?: boolean; json?: boolean }) => {
176
+ const local = await findLocalStrategy(nameOrId);
177
+
178
+ if (!local && !options.remote) {
179
+ console.error(`✗ 本地策略未找到: ${nameOrId}`);
180
+ console.error(" 使用 --remote 从 Hub 获取信息");
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+
185
+ if (options.remote && local?.sourceId) {
186
+ const infoResult = await fetchStrategyInfo(config, local.sourceId);
187
+ if (infoResult.success && infoResult.data) {
188
+ const info = infoResult.data;
189
+ if (options.json) {
190
+ console.log(JSON.stringify({ local, hub: info }, null, 2));
191
+ return;
192
+ }
193
+ printStrategyInfo(local, info);
194
+ return;
195
+ }
196
+ }
197
+
198
+ if (local) {
199
+ if (options.json) {
200
+ console.log(JSON.stringify(local, null, 2));
201
+ return;
202
+ }
203
+ printLocalStrategy(local);
204
+ return;
205
+ }
206
+
207
+ console.error(`✗ 策略未找到: ${nameOrId}`);
208
+ process.exitCode = 1;
209
+ });
210
+
211
+ // ── strategy remove ──
212
+ root
213
+ .command("remove <name-or-id>")
214
+ .alias("rm")
215
+ .description("Remove a local strategy")
216
+ .option("-f, --force", "Force removal without confirmation", false)
217
+ .action(async (nameOrId: string, options: { force?: boolean }) => {
218
+ const local = await findLocalStrategy(nameOrId);
219
+ if (!local) {
220
+ console.error(`✗ 策略未找到: ${nameOrId}`);
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+
225
+ if (!options.force) {
226
+ console.log(`即将删除策略: ${local.displayName}`);
227
+ console.log(` 路径: ${local.localPath}`);
228
+ console.log("");
229
+ console.log("使用 --force 确认删除");
230
+ return;
231
+ }
232
+
233
+ const result = await removeLocalStrategy(nameOrId);
234
+ if (result.success) {
235
+ console.log("✓ 策略已删除");
236
+ } else {
237
+ console.error(`✗ 删除失败: ${result.error}`);
238
+ process.exitCode = 1;
239
+ }
240
+ });
241
+
242
+ // ── strategy validate ──
243
+ root
244
+ .command("validate <path>")
245
+ .description("Validate a local strategy package (FEP v2.0)")
246
+ .action(async (_path: string) => {
247
+ console.log("验证功能请使用 skill_validate 工具");
248
+ console.log(" 调用 skill_validate 并传入目录路径");
249
+ });
250
+ }
251
+
252
+ function printLocalStrategy(s: {
253
+ name: string;
254
+ displayName: string;
255
+ localPath: string;
256
+ dateDir: string;
257
+ type: string;
258
+ sourceId?: string;
259
+ createdAt: string;
260
+ }) {
261
+ console.log("本地策略信息:");
262
+ console.log("");
263
+ console.log(` 名称: ${s.displayName}`);
264
+ console.log(` 目录: ${s.name}`);
265
+ console.log(` 路径: ${s.localPath}`);
266
+ console.log(` 日期: ${s.dateDir}`);
267
+ console.log(` 类型: ${s.type === "forked" ? "Fork 自 Hub" : "自建"}`);
268
+ if (s.sourceId) {
269
+ console.log(` 来源 ID: ${s.sourceId}`);
270
+ }
271
+ console.log(` 创建时间: ${s.createdAt}`);
272
+ }
273
+
274
+ function printStrategyInfo(
275
+ local: { name: string; displayName: string; localPath: string; sourceId?: string },
276
+ hub: {
277
+ id: string;
278
+ name: string;
279
+ version?: string;
280
+ author?: { displayName?: string };
281
+ market?: string;
282
+ description?: string;
283
+ backtestResult?: {
284
+ totalReturn?: number;
285
+ sharpe?: number;
286
+ maxDrawdown?: number;
287
+ winRate?: number;
288
+ };
289
+ },
290
+ ) {
291
+ console.log("策略信息:");
292
+ console.log("");
293
+ console.log("本地:");
294
+ console.log(` 路径: ${local.localPath}`);
295
+ console.log("");
296
+ console.log("Hub:");
297
+ console.log(` ID: ${hub.id}`);
298
+ console.log(` 名称: ${hub.name}`);
299
+ if (hub.version) console.log(` 版本: ${hub.version}`);
300
+ if (hub.author?.displayName) console.log(` 作者: ${hub.author.displayName}`);
301
+ if (hub.market) console.log(` 市场: ${hub.market}`);
302
+ if (hub.description) console.log(` 描述: ${hub.description}`);
303
+
304
+ if (hub.backtestResult) {
305
+ console.log("");
306
+ console.log("绩效:");
307
+ const perf = hub.backtestResult;
308
+ if (typeof perf.totalReturn === "number") {
309
+ console.log(` 总收益率: ${(perf.totalReturn * 100).toFixed(2)}%`);
310
+ }
311
+ if (typeof perf.sharpe === "number") {
312
+ console.log(` 夏普比率: ${perf.sharpe.toFixed(3)}`);
313
+ }
314
+ if (typeof perf.maxDrawdown === "number") {
315
+ console.log(` 最大回撤: ${(perf.maxDrawdown * 100).toFixed(2)}%`);
316
+ }
317
+ if (typeof perf.winRate === "number") {
318
+ console.log(` 胜率: ${(perf.winRate * 100).toFixed(1)}%`);
319
+ }
320
+ }
321
+ }
package/src/fork.ts ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Strategy fork core logic.
3
+ * Handles downloading and extracting strategies from Hub.
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import {
8
+ getStrategiesRoot,
9
+ createDateDir,
10
+ generateForkDirName,
11
+ writeForkMeta,
12
+ parseStrategyId,
13
+ formatDate,
14
+ } from "./strategy-storage.js";
15
+ import type {
16
+ SkillApiConfig,
17
+ ForkOptions,
18
+ ForkResult,
19
+ HubPublicEntry,
20
+ ForkAndDownloadResponse,
21
+ } from "./types.js";
22
+ import type { ForkMeta } from "./types.js";
23
+
24
+ const HUB_BASE_URL = "https://hub.openfinclaw.ai";
25
+
26
+ /**
27
+ * Fetch public strategy info from Hub API.
28
+ * GET /api/v1/skill/public/{id}
29
+ */
30
+ export async function fetchStrategyInfo(
31
+ config: SkillApiConfig,
32
+ strategyId: string,
33
+ ): Promise<{ success: boolean; data?: HubPublicEntry; error?: string }> {
34
+ const url = new URL(`${config.baseUrl}/api/v1/skill/public/${strategyId}`);
35
+
36
+ const headers: Record<string, string> = {
37
+ Accept: "application/json",
38
+ };
39
+ if (config.apiKey) {
40
+ headers["Authorization"] = `Bearer ${config.apiKey}`;
41
+ }
42
+
43
+ try {
44
+ const response = await fetch(url.toString(), {
45
+ method: "GET",
46
+ headers,
47
+ signal: AbortSignal.timeout(config.requestTimeoutMs),
48
+ });
49
+
50
+ const rawText = await response.text();
51
+ let data: unknown;
52
+
53
+ if (rawText && rawText.trim().startsWith("{")) {
54
+ try {
55
+ data = JSON.parse(rawText);
56
+ } catch {
57
+ data = { raw: rawText };
58
+ }
59
+ }
60
+
61
+ if (response.status >= 200 && response.status < 300) {
62
+ return { success: true, data: data as HubPublicEntry };
63
+ }
64
+
65
+ const errorData = data as { error?: { message?: string }; message?: string; detail?: string };
66
+ return {
67
+ success: false,
68
+ error:
69
+ errorData.error?.message ??
70
+ errorData.message ??
71
+ errorData.detail ??
72
+ `HTTP ${response.status}`,
73
+ };
74
+ } catch (err) {
75
+ return {
76
+ success: false,
77
+ error: err instanceof Error ? err.message : String(err),
78
+ };
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Fork strategy and get download URL from Hub.
84
+ * POST /api/v1/skill/entries/{id}/fork-and-download
85
+ */
86
+ export async function forkAndDownloadFromHub(
87
+ config: SkillApiConfig,
88
+ strategyId: string,
89
+ options?: ForkOptions,
90
+ ): Promise<{ success: boolean; data?: ForkAndDownloadResponse; error?: string }> {
91
+ if (!config.apiKey) {
92
+ return {
93
+ success: false,
94
+ error: "API key is required for fork operation. Set SKILL_API_KEY environment variable.",
95
+ };
96
+ }
97
+
98
+ const url = new URL(`${config.baseUrl}/api/v1/skill/entries/${strategyId}/fork-and-download`);
99
+
100
+ const body: Record<string, unknown> = {};
101
+ if (options?.name) body.name = options.name;
102
+ if (options?.slug) body.slug = options.slug;
103
+ if (options?.description) body.description = options.description;
104
+ body.forkConfig = {
105
+ keepGenes: options?.keepGenes ?? true,
106
+ overrideParams: {},
107
+ };
108
+
109
+ try {
110
+ const response = await fetch(url.toString(), {
111
+ method: "POST",
112
+ headers: {
113
+ Authorization: `Bearer ${config.apiKey}`,
114
+ "Content-Type": "application/json",
115
+ },
116
+ body: JSON.stringify(body),
117
+ signal: AbortSignal.timeout(config.requestTimeoutMs),
118
+ });
119
+
120
+ const rawText = await response.text();
121
+ let data: unknown;
122
+
123
+ if (rawText && rawText.trim().startsWith("{")) {
124
+ try {
125
+ data = JSON.parse(rawText);
126
+ } catch {
127
+ data = { raw: rawText };
128
+ }
129
+ }
130
+
131
+ if (response.status >= 200 && response.status < 300) {
132
+ return { success: true, data: data as ForkAndDownloadResponse };
133
+ }
134
+
135
+ const errorData = data as {
136
+ error?: { code?: string; message?: string };
137
+ code?: string;
138
+ message?: string;
139
+ };
140
+ return {
141
+ success: false,
142
+ error:
143
+ errorData.error?.message ??
144
+ errorData.message ??
145
+ errorData.error?.code ??
146
+ `HTTP ${response.status}`,
147
+ };
148
+ } catch (err) {
149
+ return {
150
+ success: false,
151
+ error: err instanceof Error ? err.message : String(err),
152
+ };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Download ZIP from signed URL.
158
+ */
159
+ export async function downloadFromSignedUrl(
160
+ signedUrl: string,
161
+ timeoutMs: number,
162
+ ): Promise<{ success: boolean; data?: Buffer; error?: string }> {
163
+ try {
164
+ const response = await fetch(signedUrl, {
165
+ method: "GET",
166
+ signal: AbortSignal.timeout(timeoutMs),
167
+ });
168
+
169
+ if (response.status >= 200 && response.status < 300) {
170
+ const arrayBuffer = await response.arrayBuffer();
171
+ return { success: true, data: Buffer.from(arrayBuffer) };
172
+ }
173
+
174
+ return { success: false, error: `HTTP ${response.status}` };
175
+ } catch (err) {
176
+ return {
177
+ success: false,
178
+ error: err instanceof Error ? err.message : String(err),
179
+ };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Extract ZIP buffer to directory.
185
+ */
186
+ export async function extractZipToDir(
187
+ zipBuffer: Buffer,
188
+ targetDir: string,
189
+ ): Promise<{ success: boolean; error?: string }> {
190
+ try {
191
+ fs.mkdirSync(targetDir, { recursive: true });
192
+
193
+ const admZip = await import("adm-zip").then((m) => m.default || m);
194
+ const zip = new admZip(zipBuffer);
195
+ zip.extractAllTo(targetDir, true);
196
+
197
+ return { success: true };
198
+ } catch (err) {
199
+ return {
200
+ success: false,
201
+ error: err instanceof Error ? err.message : String(err),
202
+ };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Fork a strategy from Hub to local directory.
208
+ * Flow: fetchStrategyInfo → forkAndDownloadFromHub → downloadFromSignedUrl → extract
209
+ */
210
+ export async function forkStrategy(
211
+ config: SkillApiConfig,
212
+ strategyIdInput: string,
213
+ options?: ForkOptions,
214
+ ): Promise<ForkResult> {
215
+ const strategyId = parseStrategyId(strategyIdInput);
216
+
217
+ const infoResult = await fetchStrategyInfo(config, strategyId);
218
+ if (!infoResult.success || !infoResult.data) {
219
+ return {
220
+ success: false,
221
+ localPath: "",
222
+ sourceId: strategyId,
223
+ sourceShortId: strategyId.slice(0, 8),
224
+ sourceName: "",
225
+ sourceVersion: "",
226
+ error: infoResult.error ?? "Failed to fetch strategy info",
227
+ };
228
+ }
229
+
230
+ const info = infoResult.data;
231
+ const shortId = strategyId.slice(0, 8);
232
+
233
+ const forkResult = await forkAndDownloadFromHub(config, strategyId, options);
234
+ if (!forkResult.success || !forkResult.data) {
235
+ return {
236
+ success: false,
237
+ localPath: "",
238
+ sourceId: strategyId,
239
+ sourceShortId: shortId,
240
+ sourceName: info.name,
241
+ sourceVersion: info.version ?? "1.0.0",
242
+ error: forkResult.error ?? "Failed to fork strategy",
243
+ };
244
+ }
245
+
246
+ const forkData = forkResult.data;
247
+ const forkEntryId = forkData.entry.id;
248
+ const forkEntrySlug = forkData.entry.slug;
249
+ const forkName = forkData.entry.name;
250
+
251
+ let targetDir: string;
252
+ if (options?.targetDir) {
253
+ targetDir = options.targetDir;
254
+ } else {
255
+ const root = getStrategiesRoot();
256
+ const dateDir = createDateDir(root, options?.dateDir);
257
+ const dirName = generateForkDirName(forkName, forkEntryId);
258
+ targetDir = path.join(dateDir, dirName);
259
+ }
260
+
261
+ if (fs.existsSync(targetDir)) {
262
+ return {
263
+ success: false,
264
+ localPath: targetDir,
265
+ sourceId: strategyId,
266
+ sourceShortId: shortId,
267
+ sourceName: info.name,
268
+ sourceVersion: info.version ?? "1.0.0",
269
+ forkEntryId,
270
+ forkEntrySlug,
271
+ error: `Directory already exists: ${targetDir}`,
272
+ };
273
+ }
274
+
275
+ const downloadResult = await downloadFromSignedUrl(
276
+ forkData.download.url,
277
+ config.requestTimeoutMs,
278
+ );
279
+ if (!downloadResult.success || !downloadResult.data) {
280
+ return {
281
+ success: false,
282
+ localPath: "",
283
+ sourceId: strategyId,
284
+ sourceShortId: shortId,
285
+ sourceName: info.name,
286
+ sourceVersion: info.version ?? "1.0.0",
287
+ forkEntryId,
288
+ forkEntrySlug,
289
+ error: downloadResult.error ?? "Failed to download strategy",
290
+ };
291
+ }
292
+
293
+ const extractResult = await extractZipToDir(downloadResult.data, targetDir);
294
+ if (!extractResult.success) {
295
+ return {
296
+ success: false,
297
+ localPath: targetDir,
298
+ sourceId: strategyId,
299
+ sourceShortId: shortId,
300
+ sourceName: info.name,
301
+ sourceVersion: info.version ?? "1.0.0",
302
+ forkEntryId,
303
+ forkEntrySlug,
304
+ error: extractResult.error ?? "Failed to extract strategy",
305
+ };
306
+ }
307
+
308
+ const meta: ForkMeta = {
309
+ sourceId: strategyId,
310
+ sourceShortId: shortId,
311
+ sourceName: info.name,
312
+ sourceVersion: info.version ?? "1.0.0",
313
+ sourceAuthor: info.author?.displayName,
314
+ forkedAt: forkData.forkedAt ?? new Date().toISOString(),
315
+ forkDateDir: options?.dateDir ?? formatDate(new Date()),
316
+ hubUrl: `${HUB_BASE_URL}/strategy/${strategyId}`,
317
+ localPath: targetDir,
318
+ forkEntryId,
319
+ forkEntrySlug,
320
+ };
321
+
322
+ writeForkMeta(targetDir, meta);
323
+
324
+ return {
325
+ success: true,
326
+ localPath: targetDir,
327
+ sourceId: strategyId,
328
+ sourceShortId: shortId,
329
+ sourceName: info.name,
330
+ sourceVersion: info.version ?? "1.0.0",
331
+ forkEntryId,
332
+ forkEntrySlug,
333
+ creditsEarned: forkData.creditsEarned,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Build Hub URL for a strategy.
339
+ */
340
+ export function buildHubUrl(strategyId: string): string {
341
+ return `${HUB_BASE_URL}/strategy/${strategyId}`;
342
+ }