@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/index.ts ADDED
@@ -0,0 +1,1005 @@
1
+ /**
2
+ * OpenFinClaw — Skill publishing, strategy validation, and fork tools.
3
+ * Tools: skill_leaderboard, skill_get_info, skill_validate, skill_fork, skill_list_local, skill_publish, skill_publish_verify.
4
+ * Supports FEP v2.0 protocol for strategy packages.
5
+ */
6
+ import { readFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { Type } from "@sinclair/typebox";
9
+ import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
10
+ import { registerStrategyCli } from "./src/cli.js";
11
+ import { forkStrategy, fetchStrategyInfo } from "./src/fork.js";
12
+ import { listLocalStrategies, findLocalStrategy } from "./src/strategy-storage.js";
13
+ import type { BoardType, LeaderboardResponse, LeaderboardStrategy } from "./src/types.js";
14
+ import { validateStrategyPackage } from "./src/validate.js";
15
+
16
+ /** JSON tool result helper. */
17
+ function json(payload: unknown) {
18
+ return {
19
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
20
+ details: payload,
21
+ };
22
+ }
23
+
24
+ /** Resolved plugin config for skill API. */
25
+ type SkillApiConfig = {
26
+ baseUrl: string;
27
+ apiKey: string | undefined;
28
+ requestTimeoutMs: number;
29
+ };
30
+
31
+ function readEnv(keys: string[]): string | undefined {
32
+ for (const key of keys) {
33
+ const value = process.env[key]?.trim();
34
+ if (value) return value;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * Resolve config from plugin config and env.
41
+ */
42
+ function resolveConfig(api: OpenClawPluginApi): SkillApiConfig {
43
+ const raw = api.pluginConfig as Record<string, unknown> | undefined;
44
+ const baseUrl =
45
+ (typeof raw?.skillApiUrl === "string" ? raw.skillApiUrl : undefined) ??
46
+ readEnv(["SKILL_API_URL", "SKILL_API_BASE_URL"]) ??
47
+ "https://hub.openfinclaw.ai";
48
+ const apiKey =
49
+ (typeof raw?.skillApiKey === "string" ? raw.skillApiKey : undefined) ??
50
+ readEnv(["SKILL_API_KEY"]);
51
+ const timeoutRaw = raw?.requestTimeoutMs ?? readEnv(["SKILL_REQUEST_TIMEOUT_MS"]);
52
+ const requestTimeoutMs =
53
+ Number(timeoutRaw) >= 5000 && Number(timeoutRaw) <= 300_000
54
+ ? Math.floor(Number(timeoutRaw))
55
+ : 60_000;
56
+
57
+ return {
58
+ baseUrl: baseUrl.replace(/\/$/, ""),
59
+ apiKey: apiKey && apiKey.length > 0 ? apiKey : undefined,
60
+ requestTimeoutMs,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * HTTP request helper with Bearer auth.
66
+ * Base path is /api/v1.
67
+ */
68
+ async function skillApiRequest(
69
+ config: SkillApiConfig,
70
+ method: "GET" | "POST",
71
+ pathSegments: string,
72
+ options?: { body?: Record<string, unknown>; searchParams?: Record<string, string> },
73
+ ): Promise<{ status: number; data: unknown }> {
74
+ const url = new URL(`${config.baseUrl}/api/v1${pathSegments}`);
75
+ if (options?.searchParams) {
76
+ for (const [k, v] of Object.entries(options.searchParams)) {
77
+ url.searchParams.set(k, v);
78
+ }
79
+ }
80
+
81
+ const headers: Record<string, string> = {
82
+ "Content-Type": "application/json",
83
+ };
84
+ if (config.apiKey) {
85
+ headers["Authorization"] = `Bearer ${config.apiKey}`;
86
+ }
87
+
88
+ let body: string | undefined;
89
+ if (options?.body) {
90
+ body = JSON.stringify(options.body);
91
+ }
92
+
93
+ const response = await fetch(url.toString(), {
94
+ method,
95
+ headers,
96
+ body,
97
+ signal: AbortSignal.timeout(config.requestTimeoutMs),
98
+ });
99
+
100
+ const rawText = await response.text();
101
+ let data: unknown = rawText;
102
+ if (rawText && rawText.trim().startsWith("{")) {
103
+ try {
104
+ data = JSON.parse(rawText);
105
+ } catch {
106
+ data = { raw: rawText };
107
+ }
108
+ }
109
+
110
+ return { status: response.status, data };
111
+ }
112
+
113
+ const openfinclawPlugin = {
114
+ id: "openfinclaw",
115
+ name: "OpenFinClaw",
116
+ description:
117
+ "Strategy publishing, fork, and validation tools. Publish strategy ZIPs to Hub, fork public strategies to local, and validate strategy packages.",
118
+
119
+ register(api: OpenClawPluginApi) {
120
+ const config = resolveConfig(api);
121
+
122
+ // ── skill_publish ──
123
+ api.registerTool(
124
+ {
125
+ name: "skill_publish",
126
+ label: "Publish skill to server",
127
+ description:
128
+ "Publish a strategy ZIP to the skill server. The server will automatically run backtest. Returns submissionId and backtestTaskId for polling. Use skill_publish_verify to check status and get report when completed.",
129
+ parameters: Type.Object({
130
+ filePath: Type.String({
131
+ description: "Path to the strategy ZIP file (must contain fep.yaml)",
132
+ }),
133
+ visibility: Type.Optional(
134
+ Type.Unsafe<"public" | "private" | "unlisted">({
135
+ type: "string",
136
+ enum: ["public", "private", "unlisted"],
137
+ description: "Override visibility from fep.yaml: public, private, or unlisted",
138
+ }),
139
+ ),
140
+ }),
141
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
142
+ try {
143
+ const filePath = String(params.filePath ?? "").trim();
144
+ if (!filePath) {
145
+ return json({ success: false, error: "filePath is required" });
146
+ }
147
+
148
+ if (!config.apiKey) {
149
+ return json({
150
+ success: false,
151
+ error:
152
+ "API key not configured. Set skillApiKey in plugin config or SKILL_API_KEY env.",
153
+ });
154
+ }
155
+
156
+ const resolvedPath = api.resolvePath(filePath);
157
+ const buf = await readFile(resolvedPath);
158
+ const base64Content = buf.toString("base64");
159
+
160
+ const body: Record<string, unknown> = { content: base64Content };
161
+ if (
162
+ params.visibility === "public" ||
163
+ params.visibility === "private" ||
164
+ params.visibility === "unlisted"
165
+ ) {
166
+ body.visibility = params.visibility;
167
+ }
168
+
169
+ const { status, data } = await skillApiRequest(config, "POST", "/skill/publish", {
170
+ body,
171
+ });
172
+
173
+ if (status >= 200 && status < 300) {
174
+ const resp = data as {
175
+ slug?: string;
176
+ entryId?: string;
177
+ version?: string;
178
+ status?: string;
179
+ message?: string;
180
+ submissionId?: string;
181
+ backtestTaskId?: string | null;
182
+ backtestStatus?: string | null;
183
+ backtestReport?: unknown;
184
+ creditsEarned?: { action?: string; amount?: number; message?: string };
185
+ };
186
+
187
+ const lines: string[] = [];
188
+ lines.push("Skill 发布成功!");
189
+ lines.push("");
190
+ lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
191
+ lines.push(`- Entry ID: ${resp.entryId ?? "(未知)"}`);
192
+ lines.push(`- Version: ${resp.version ?? "(未知)"}`);
193
+ lines.push(`- Status: ${resp.status ?? "(未知)"}`);
194
+ if (resp.message) {
195
+ lines.push(`- Message: ${resp.message}`);
196
+ }
197
+ lines.push("");
198
+ lines.push(`- Submission ID: ${resp.submissionId ?? "(未知)"}`);
199
+ if (resp.backtestTaskId) {
200
+ lines.push(`- Backtest Task ID: ${resp.backtestTaskId}`);
201
+ }
202
+ if (resp.backtestStatus) {
203
+ lines.push(`- Backtest Status: ${resp.backtestStatus}`);
204
+ }
205
+ if (resp.creditsEarned) {
206
+ lines.push("");
207
+ lines.push("积分奖励:");
208
+ if (resp.creditsEarned.amount) {
209
+ lines.push(`- 获得 ${resp.creditsEarned.amount} FC`);
210
+ }
211
+ if (resp.creditsEarned.message) {
212
+ lines.push(`- ${resp.creditsEarned.message}`);
213
+ }
214
+ }
215
+ lines.push("");
216
+ lines.push("使用 skill_publish_verify 工具查询回测状态和获取完整报告。");
217
+
218
+ return {
219
+ content: [{ type: "text" as const, text: lines.join("\n") }],
220
+ details: { success: true, ...resp },
221
+ };
222
+ }
223
+
224
+ return json({
225
+ success: false,
226
+ status,
227
+ error:
228
+ (data as { code?: string; message?: string })?.message ??
229
+ (data as { detail?: string })?.detail ??
230
+ data,
231
+ });
232
+ } catch (err) {
233
+ return json({
234
+ success: false,
235
+ error: err instanceof Error ? err.message : String(err),
236
+ });
237
+ }
238
+ },
239
+ },
240
+ { names: ["skill_publish"] },
241
+ );
242
+
243
+ // ── skill_publish_verify ──
244
+ api.registerTool(
245
+ {
246
+ name: "skill_publish_verify",
247
+ label: "Verify skill publish result",
248
+ description:
249
+ "Check publish and backtest status by submissionId or backtestTaskId. Returns full backtest report when completed. Poll this until backtestStatus is completed, failed, or rejected.",
250
+ parameters: Type.Object({
251
+ submissionId: Type.Optional(
252
+ Type.String({
253
+ description: "Submission ID from skill_publish response (entry_versions.id)",
254
+ }),
255
+ ),
256
+ backtestTaskId: Type.Optional(
257
+ Type.String({
258
+ description: "Backtest task ID from skill_publish response",
259
+ }),
260
+ ),
261
+ }),
262
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
263
+ try {
264
+ const submissionId = String(params.submissionId ?? "").trim() || undefined;
265
+ const backtestTaskId = String(params.backtestTaskId ?? "").trim() || undefined;
266
+
267
+ if (!submissionId && !backtestTaskId) {
268
+ return json({
269
+ success: false,
270
+ error: "Either submissionId or backtestTaskId is required",
271
+ });
272
+ }
273
+
274
+ if (!config.apiKey) {
275
+ return json({
276
+ success: false,
277
+ error:
278
+ "API key not configured. Set skillApiKey in plugin config or SKILL_API_KEY env.",
279
+ });
280
+ }
281
+
282
+ const searchParams: Record<string, string> = {};
283
+ if (submissionId) searchParams.submissionId = submissionId;
284
+ if (backtestTaskId) searchParams.backtestTaskId = backtestTaskId;
285
+
286
+ const { status, data } = await skillApiRequest(config, "GET", "/skill/publish/verify", {
287
+ searchParams,
288
+ });
289
+
290
+ if (status >= 200 && status < 300) {
291
+ const resp = data as {
292
+ submissionId?: string | null;
293
+ entryId?: string | null;
294
+ slug?: string | null;
295
+ version?: string | null;
296
+ strategyUploaded?: boolean;
297
+ backtestTaskId?: string | null;
298
+ backtestStatus?: string | null;
299
+ backtestCompleted?: boolean;
300
+ backtestReportInDb?: boolean;
301
+ backtestReport?: {
302
+ alpha?: number | null;
303
+ task_id?: string;
304
+ metadata?: {
305
+ id?: string;
306
+ name?: string;
307
+ tags?: string[];
308
+ type?: string;
309
+ style?: string;
310
+ author?: { name?: string };
311
+ market?: string;
312
+ license?: string;
313
+ summary?: string;
314
+ version?: string;
315
+ archetype?: string;
316
+ frequency?: string;
317
+ riskLevel?: string;
318
+ visibility?: string;
319
+ description?: string;
320
+ assetClasses?: string[];
321
+ parameters?: Array<{
322
+ name: string;
323
+ type: string;
324
+ label?: string;
325
+ default?: unknown;
326
+ range?: { min?: number; max?: number; step?: number };
327
+ }>;
328
+ };
329
+ integrity?: {
330
+ fepHash?: string;
331
+ codeHash?: string;
332
+ contentCID?: string;
333
+ contentHash?: string;
334
+ publishedAt?: string;
335
+ timestampProof?: string;
336
+ };
337
+ performance?: {
338
+ hints?: string[];
339
+ calmar?: number;
340
+ sharpe?: number;
341
+ sortino?: number;
342
+ winRate?: number;
343
+ finalEquity?: number;
344
+ maxDrawdown?: number;
345
+ totalReturn?: number;
346
+ totalTrades?: number;
347
+ profitFactor?: number | null;
348
+ maxDrawdownStart?: string;
349
+ maxDrawdownEnd?: string;
350
+ monthlyReturns?:
351
+ | Record<string, number>
352
+ | Array<{ month: string; return: number }>;
353
+ annualizedReturn?: number;
354
+ returnsVolatility?: number;
355
+ riskReturnRatio?: number;
356
+ expectancy?: number;
357
+ avgWinner?: number;
358
+ avgLoser?: number;
359
+ maxWinner?: number;
360
+ maxLoser?: number;
361
+ longRatio?: number;
362
+ pnlTotal?: number;
363
+ startingBalance?: number;
364
+ endingBalance?: number;
365
+ backtestStart?: string;
366
+ backtestEnd?: string;
367
+ totalOrders?: number;
368
+ recentValidation?: {
369
+ decay?: {
370
+ sharpeDecay30d?: number;
371
+ sharpeDecay90d?: number;
372
+ warning?: string;
373
+ };
374
+ recent?: Array<{
375
+ period?: string;
376
+ window?: string;
377
+ sharpe?: number;
378
+ finalEquity?: number;
379
+ maxDrawdown?: number;
380
+ totalReturn?: number;
381
+ totalTrades?: number;
382
+ }>;
383
+ historical?: {
384
+ period?: string;
385
+ sharpe?: number;
386
+ finalEquity?: number;
387
+ maxDrawdown?: number;
388
+ totalReturn?: number;
389
+ totalTrades?: number;
390
+ };
391
+ };
392
+ };
393
+ equityCurve?: Array<{ date: string; equity: number }>;
394
+ drawdownCurve?: Array<{ date: string; drawdown: number }>;
395
+ trades?: Array<{
396
+ open_date: string;
397
+ close_date: string;
398
+ side: string;
399
+ quantity: number;
400
+ avg_open: number;
401
+ avg_close: number;
402
+ realized_pnl: string;
403
+ return_pct: number;
404
+ }>;
405
+ equity_curve?: unknown;
406
+ trade_journal?: unknown;
407
+ };
408
+ };
409
+
410
+ const lines: string[] = [];
411
+ lines.push("发布验证结果:");
412
+ lines.push("");
413
+ lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
414
+ lines.push(`- Version: ${resp.version ?? "(未知)"}`);
415
+ lines.push(`- Strategy Uploaded: ${resp.strategyUploaded ? "是" : "否"}`);
416
+ lines.push("");
417
+ lines.push(`- Backtest Task ID: ${resp.backtestTaskId ?? "(无)"}`);
418
+ lines.push(`- Backtest Status: ${resp.backtestStatus ?? "(未知)"}`);
419
+ lines.push(`- Backtest Completed: ${resp.backtestCompleted ? "是" : "否"}`);
420
+ lines.push(`- Report in DB: ${resp.backtestReportInDb ? "是" : "否"}`);
421
+
422
+ if (resp.backtestStatus === "completed" && resp.backtestReport?.performance) {
423
+ const perf = resp.backtestReport.performance;
424
+ const report = resp.backtestReport;
425
+ lines.push("");
426
+ lines.push("回测报告摘要:");
427
+
428
+ // ── 收益指标 ──
429
+ if (typeof perf.totalReturn === "number") {
430
+ lines.push(`- 总收益率: ${(perf.totalReturn * 100).toFixed(2)}%`);
431
+ }
432
+ if (typeof perf.annualizedReturn === "number") {
433
+ lines.push(`- 年化收益: ${(perf.annualizedReturn * 100).toFixed(2)}%`);
434
+ }
435
+ if (typeof perf.pnlTotal === "number") {
436
+ lines.push(`- 总盈亏: ${perf.pnlTotal.toFixed(2)}`);
437
+ }
438
+
439
+ // ── 风险指标 ──
440
+ if (typeof perf.sharpe === "number") {
441
+ lines.push(`- 夏普比率: ${perf.sharpe.toFixed(3)}`);
442
+ }
443
+ if (typeof perf.sortino === "number") {
444
+ lines.push(`- 索提诺比率: ${perf.sortino.toFixed(3)}`);
445
+ }
446
+ if (typeof perf.calmar === "number") {
447
+ lines.push(`- 卡玛比率: ${perf.calmar.toFixed(3)}`);
448
+ }
449
+ if (typeof perf.maxDrawdown === "number") {
450
+ lines.push(`- 最大回撤: ${(perf.maxDrawdown * 100).toFixed(2)}%`);
451
+ }
452
+ if (typeof perf.returnsVolatility === "number") {
453
+ lines.push(`- 收益波动率: ${(perf.returnsVolatility * 100).toFixed(2)}%`);
454
+ }
455
+ if (typeof perf.riskReturnRatio === "number") {
456
+ lines.push(`- 风险回报比: ${perf.riskReturnRatio.toFixed(3)}`);
457
+ }
458
+
459
+ // ── 交易指标 ──
460
+ if (typeof perf.winRate === "number") {
461
+ lines.push(`- 胜率: ${perf.winRate.toFixed(1)}%`);
462
+ }
463
+ if (typeof perf.profitFactor === "number") {
464
+ lines.push(`- 盈亏比: ${perf.profitFactor.toFixed(2)}`);
465
+ }
466
+ if (typeof perf.expectancy === "number") {
467
+ lines.push(`- 期望收益: ${perf.expectancy.toFixed(2)}`);
468
+ }
469
+ if (typeof perf.avgWinner === "number") {
470
+ lines.push(`- 平均盈利: ${perf.avgWinner.toFixed(2)}`);
471
+ }
472
+ if (typeof perf.avgLoser === "number") {
473
+ lines.push(`- 平均亏损: ${perf.avgLoser.toFixed(2)}`);
474
+ }
475
+ if (typeof perf.longRatio === "number") {
476
+ lines.push(`- 多头占比: ${(perf.longRatio * 100).toFixed(1)}%`);
477
+ }
478
+
479
+ // ── 交易统计 ──
480
+ if (typeof perf.totalTrades === "number") {
481
+ lines.push(`- 交易笔数: ${perf.totalTrades}`);
482
+ }
483
+ if (typeof perf.totalOrders === "number") {
484
+ lines.push(`- 总订单数: ${perf.totalOrders}`);
485
+ }
486
+
487
+ // ── 资金信息 ──
488
+ if (typeof perf.startingBalance === "number") {
489
+ lines.push(`- 初始资金: ${perf.startingBalance.toFixed(2)}`);
490
+ }
491
+ if (typeof perf.endingBalance === "number") {
492
+ lines.push(`- 最终资金: ${perf.endingBalance.toFixed(2)}`);
493
+ }
494
+ if (typeof perf.finalEquity === "number") {
495
+ lines.push(`- 期末权益: ${perf.finalEquity.toFixed(2)}`);
496
+ }
497
+
498
+ // ── 回测周期 ──
499
+ if (perf.backtestStart || perf.backtestEnd) {
500
+ lines.push(
501
+ `- 回测周期: ${perf.backtestStart ?? "?"} ~ ${perf.backtestEnd ?? "?"}`,
502
+ );
503
+ }
504
+
505
+ // ── 时序数据统计 ──
506
+ if (report.equityCurve && Array.isArray(report.equityCurve)) {
507
+ lines.push(`- 权益曲线点数: ${report.equityCurve.length}`);
508
+ }
509
+ if (report.drawdownCurve && Array.isArray(report.drawdownCurve)) {
510
+ lines.push(`- 回撤曲线点数: ${report.drawdownCurve.length}`);
511
+ }
512
+ if (report.trades && Array.isArray(report.trades)) {
513
+ lines.push(`- 交易记录数: ${report.trades.length}`);
514
+ }
515
+
516
+ // ── 提示 ──
517
+ if (perf.hints && perf.hints.length > 0) {
518
+ lines.push("");
519
+ lines.push("提示:");
520
+ for (const hint of perf.hints) {
521
+ lines.push(`- ${hint}`);
522
+ }
523
+ }
524
+ if (perf.recentValidation?.decay?.warning) {
525
+ lines.push("");
526
+ lines.push(`⚠️ 衰减警告: ${perf.recentValidation.decay.warning}`);
527
+ }
528
+ } else if (resp.backtestStatus === "failed" || resp.backtestStatus === "rejected") {
529
+ lines.push("");
530
+ lines.push(
531
+ `回测${resp.backtestStatus === "failed" ? "失败" : "被拒绝"},请检查策略代码。`,
532
+ );
533
+ } else if (
534
+ resp.backtestStatus === "submitted" ||
535
+ resp.backtestStatus === "queued" ||
536
+ resp.backtestStatus === "processing"
537
+ ) {
538
+ lines.push("");
539
+ lines.push("回测进行中,请稍后再次查询...");
540
+ }
541
+
542
+ return {
543
+ content: [{ type: "text" as const, text: lines.join("\n") }],
544
+ details: { success: true, ...resp },
545
+ };
546
+ }
547
+
548
+ return json({
549
+ success: false,
550
+ status,
551
+ error:
552
+ (data as { code?: string; message?: string })?.message ??
553
+ (data as { detail?: string })?.detail ??
554
+ data,
555
+ });
556
+ } catch (err) {
557
+ return json({
558
+ success: false,
559
+ error: err instanceof Error ? err.message : String(err),
560
+ });
561
+ }
562
+ },
563
+ },
564
+ { names: ["skill_publish_verify"] },
565
+ );
566
+
567
+ // ── skill_validate ──
568
+ api.registerTool(
569
+ {
570
+ name: "skill_validate",
571
+ label: "Validate strategy package (FEP v2.0)",
572
+ description:
573
+ "Validate a strategy package directory per FEP v2.0 before zipping and publishing. " +
574
+ "Checks: fep.yaml with identity (id, name, type, version, style, visibility, summary, description, license, author, changelog, tags), " +
575
+ "backtest (symbol, defaultPeriod, initialCapital); scripts/strategy.py with compute(data) or select(universe) and no forbidden imports. " +
576
+ "Only publish after validation passes.",
577
+ parameters: Type.Object({
578
+ dirPath: Type.String({
579
+ description:
580
+ "Path to strategy package directory (must contain fep.yaml and scripts/strategy.py)",
581
+ }),
582
+ }),
583
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
584
+ try {
585
+ const dirPath = String(params.dirPath ?? "").trim();
586
+ if (!dirPath) {
587
+ return json({ success: false, valid: false, errors: ["dirPath is required"] });
588
+ }
589
+ const resolved = api.resolvePath(dirPath);
590
+ const result = await validateStrategyPackage(resolved);
591
+ return json({
592
+ success: result.valid,
593
+ valid: result.valid,
594
+ errors: result.errors,
595
+ warnings: result.warnings,
596
+ });
597
+ } catch (err) {
598
+ return json({
599
+ success: false,
600
+ valid: false,
601
+ errors: [err instanceof Error ? err.message : String(err)],
602
+ });
603
+ }
604
+ },
605
+ },
606
+ { names: ["skill_validate"] },
607
+ );
608
+
609
+ // ── skill_leaderboard ──
610
+ api.registerTool(
611
+ {
612
+ name: "skill_leaderboard",
613
+ label: "Get Hub leaderboard",
614
+ description:
615
+ "Query strategy leaderboard from hub.openfinclaw.ai. No API key required. Board types: composite (default, FCS score), returns (profit), risk (risk control), popular (subscribers), rising (new strategies). Use this to discover top strategies before using skill_get_info or skill_fork.",
616
+ parameters: Type.Object({
617
+ boardType: Type.Optional(
618
+ Type.Unsafe<BoardType>({
619
+ type: "string",
620
+ enum: ["composite", "returns", "risk", "popular", "rising"],
621
+ description:
622
+ "Leaderboard type: composite (default, FCS score), returns (profit), risk (risk control), popular (subscribers), rising (new strategies within 30 days)",
623
+ }),
624
+ ),
625
+ limit: Type.Optional(
626
+ Type.Number({
627
+ description: "Number of results (max 100, default 20)",
628
+ minimum: 1,
629
+ maximum: 100,
630
+ }),
631
+ ),
632
+ offset: Type.Optional(
633
+ Type.Number({
634
+ description: "Offset for pagination (default 0)",
635
+ minimum: 0,
636
+ }),
637
+ ),
638
+ }),
639
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
640
+ try {
641
+ const boardType = (params.boardType as BoardType) || "composite";
642
+ const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 100);
643
+ const offset = Math.max(Number(params.offset) || 0, 0);
644
+
645
+ const url = new URL(`${config.baseUrl}/api/v1/skill/leaderboard/${boardType}`);
646
+ url.searchParams.set("limit", String(limit));
647
+ url.searchParams.set("offset", String(offset));
648
+
649
+ const response = await fetch(url.toString(), {
650
+ method: "GET",
651
+ headers: { Accept: "application/json" },
652
+ signal: AbortSignal.timeout(config.requestTimeoutMs),
653
+ });
654
+
655
+ const rawText = await response.text();
656
+ let data: unknown;
657
+
658
+ if (rawText && rawText.trim().startsWith("{")) {
659
+ try {
660
+ data = JSON.parse(rawText);
661
+ } catch {
662
+ data = { raw: rawText };
663
+ }
664
+ }
665
+
666
+ if (response.status < 200 || response.status >= 300) {
667
+ const errorData = data as { error?: { message?: string }; message?: string };
668
+ return json({
669
+ success: false,
670
+ error: errorData.error?.message ?? errorData.message ?? `HTTP ${response.status}`,
671
+ });
672
+ }
673
+
674
+ const leaderboard = data as LeaderboardResponse;
675
+ const boardNames: Record<string, string> = {
676
+ composite: "综合榜",
677
+ returns: "收益榜",
678
+ risk: "风控榜",
679
+ popular: "人气榜",
680
+ rising: "新星榜",
681
+ };
682
+
683
+ const lines: string[] = [];
684
+ lines.push(
685
+ `${boardNames[boardType] || boardType} Top ${leaderboard.strategies.length} (共 ${leaderboard.total} 个策略):`,
686
+ );
687
+ lines.push("");
688
+
689
+ for (const s of leaderboard.strategies) {
690
+ const perf = s.performance || {};
691
+ const returnStr =
692
+ typeof perf.returnSincePublish === "number"
693
+ ? `收益: ${(perf.returnSincePublish * 100).toFixed(1)}%`
694
+ : "收益: --";
695
+ const sharpeStr =
696
+ typeof perf.sharpeRatio === "number"
697
+ ? `夏普: ${perf.sharpeRatio.toFixed(2)}`
698
+ : "夏普: --";
699
+ const ddStr =
700
+ typeof perf.maxDrawdown === "number"
701
+ ? `回撤: ${(perf.maxDrawdown * 100).toFixed(1)}%`
702
+ : "回撤: --";
703
+ const author = s.author?.displayName || "未知";
704
+
705
+ const truncatedName = s.name.length > 35 ? s.name.slice(0, 32) + "..." : s.name;
706
+ const hubUrl = `https://hub.openfinclaw.ai/strategy/${s.id}`;
707
+ const nameLink = `[${truncatedName}](${hubUrl})`;
708
+ lines.push(
709
+ `#${String(s.rank).padStart(2)} ${nameLink} ${returnStr} ${sharpeStr} ${ddStr} 作者: ${author}`,
710
+ );
711
+ }
712
+
713
+ lines.push("");
714
+ lines.push("使用 skill_get_info <id> 查看策略详情");
715
+ lines.push("使用 skill_fork <id> 下载策略到本地(需要 API Key)");
716
+
717
+ return {
718
+ content: [{ type: "text" as const, text: lines.join("\n") }],
719
+ details: { success: true, ...leaderboard },
720
+ };
721
+ } catch (err) {
722
+ return json({
723
+ success: false,
724
+ error: err instanceof Error ? err.message : String(err),
725
+ });
726
+ }
727
+ },
728
+ },
729
+ { names: ["skill_leaderboard"] },
730
+ );
731
+
732
+ // ── skill_fork ──
733
+ api.registerTool(
734
+ {
735
+ name: "skill_fork",
736
+ label: "Fork strategy from Hub",
737
+ description:
738
+ "Fork a public strategy from hub.openfinclaw.ai to local directory. Creates a new entry on Hub and downloads the code locally. Returns the local path and fork entry ID. Use this when user wants to download, clone, or fork a strategy from Hub. Requires API key.",
739
+ parameters: Type.Object({
740
+ strategyId: Type.String({
741
+ description:
742
+ "Strategy ID from Hub (UUID or Hub URL like https://hub.openfinclaw.ai/strategy/{id})",
743
+ }),
744
+ name: Type.Optional(
745
+ Type.String({
746
+ description: "Name for the forked strategy. Default: original name + '(Fork)'",
747
+ }),
748
+ ),
749
+ slug: Type.Optional(
750
+ Type.String({
751
+ description:
752
+ "URL-friendly slug for the forked strategy. Auto-generated if not provided.",
753
+ }),
754
+ ),
755
+ keepGenes: Type.Optional(
756
+ Type.Boolean({
757
+ description: "Whether to inherit gene combinations. Default: true",
758
+ }),
759
+ ),
760
+ targetDir: Type.Optional(
761
+ Type.String({
762
+ description:
763
+ "Custom target directory. Default: ~/.openfinclaw/workspace/strategies/{date}/{name}-{shortId}/",
764
+ }),
765
+ ),
766
+ dateDir: Type.Optional(
767
+ Type.String({
768
+ description: "Date directory (YYYY-MM-DD). Default: today",
769
+ }),
770
+ ),
771
+ }),
772
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
773
+ try {
774
+ const strategyId = String(params.strategyId ?? "").trim();
775
+ if (!strategyId) {
776
+ return json({ success: false, error: "strategyId is required" });
777
+ }
778
+
779
+ if (!config.apiKey) {
780
+ return json({
781
+ success: false,
782
+ error:
783
+ "API key is required for fork operation. Set skillApiKey in plugin config or SKILL_API_KEY env.",
784
+ });
785
+ }
786
+
787
+ const result = await forkStrategy(config, strategyId, {
788
+ name: params.name ? String(params.name) : undefined,
789
+ slug: params.slug ? String(params.slug) : undefined,
790
+ keepGenes: typeof params.keepGenes === "boolean" ? params.keepGenes : undefined,
791
+ targetDir: params.targetDir ? String(params.targetDir) : undefined,
792
+ dateDir: params.dateDir ? String(params.dateDir) : undefined,
793
+ });
794
+
795
+ if (result.success) {
796
+ const lines: string[] = [];
797
+ lines.push("策略 Fork 成功!");
798
+ lines.push("");
799
+ lines.push(`- 原策略: ${result.sourceName} (${result.sourceId})`);
800
+ lines.push(`- Fork Entry ID: ${result.forkEntryId}`);
801
+ if (result.forkEntrySlug) {
802
+ lines.push(`- Fork Slug: ${result.forkEntrySlug}`);
803
+ }
804
+ lines.push(`- 本地路径: ${result.localPath}`);
805
+
806
+ if (result.creditsEarned) {
807
+ lines.push("");
808
+ lines.push("积分奖励:");
809
+ lines.push(`- 获得 ${result.creditsEarned.amount} FC`);
810
+ if (result.creditsEarned.message) {
811
+ lines.push(`- ${result.creditsEarned.message}`);
812
+ }
813
+ }
814
+
815
+ lines.push("");
816
+ lines.push("下一步:");
817
+ lines.push(`- 编辑策略: code ${result.localPath}/scripts/strategy.py`);
818
+ lines.push(`- 验证修改: openfinclaw strategy validate ${result.localPath}`);
819
+ lines.push(`- 发布新版本: openfinclaw strategy publish ${result.localPath}`);
820
+
821
+ return {
822
+ content: [{ type: "text" as const, text: lines.join("\n") }],
823
+ details: result,
824
+ };
825
+ }
826
+
827
+ return json({
828
+ success: false,
829
+ error: result.error ?? "Failed to fork strategy",
830
+ });
831
+ } catch (err) {
832
+ return json({
833
+ success: false,
834
+ error: err instanceof Error ? err.message : String(err),
835
+ });
836
+ }
837
+ },
838
+ },
839
+ { names: ["skill_fork"] },
840
+ );
841
+
842
+ // ── skill_list_local ──
843
+ api.registerTool(
844
+ {
845
+ name: "skill_list_local",
846
+ label: "List local strategies",
847
+ description:
848
+ "List all strategies downloaded or created locally, organized by date. Shows strategy name, type (forked/created), and local path.",
849
+ parameters: Type.Object({}),
850
+ async execute() {
851
+ try {
852
+ const strategies = await listLocalStrategies();
853
+
854
+ if (strategies.length === 0) {
855
+ return {
856
+ content: [
857
+ {
858
+ type: "text" as const,
859
+ text: "本地暂无策略。\n\n使用 skill_fork 从 Hub 下载策略,或使用 skill_validate 验证本地策略目录。",
860
+ },
861
+ ],
862
+ details: { success: true, strategies: [] },
863
+ };
864
+ }
865
+
866
+ const lines: string[] = [];
867
+ lines.push(`本地策略列表 (共 ${strategies.length} 个):`);
868
+ lines.push("");
869
+
870
+ let currentDate = "";
871
+ for (const s of strategies) {
872
+ if (s.dateDir !== currentDate) {
873
+ currentDate = s.dateDir;
874
+ lines.push(`${s.dateDir}/`);
875
+ }
876
+ const typeLabel = s.type === "forked" ? "(forked)" : "(created)";
877
+ lines.push(
878
+ ` ${s.name.padEnd(40)} ${s.displayName.slice(0, 20).padEnd(20)} ${typeLabel}`,
879
+ );
880
+ }
881
+
882
+ return {
883
+ content: [{ type: "text" as const, text: lines.join("\n") }],
884
+ details: { success: true, strategies },
885
+ };
886
+ } catch (err) {
887
+ return json({
888
+ success: false,
889
+ error: err instanceof Error ? err.message : String(err),
890
+ });
891
+ }
892
+ },
893
+ },
894
+ { names: ["skill_list_local"] },
895
+ );
896
+
897
+ // ── skill_get_info ──
898
+ api.registerTool(
899
+ {
900
+ name: "skill_get_info",
901
+ label: "Get strategy info from Hub",
902
+ description:
903
+ "Fetch detailed information about a strategy from hub.openfinclaw.ai. No API key required for public strategies. Returns performance metrics (return, sharpe, max drawdown, win rate). Use this before forking to preview the strategy.",
904
+ parameters: Type.Object({
905
+ strategyId: Type.String({
906
+ description:
907
+ "Strategy ID from Hub (UUID or Hub URL like https://hub.openfinclaw.ai/strategy/{id})",
908
+ }),
909
+ }),
910
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
911
+ try {
912
+ const strategyId = String(params.strategyId ?? "").trim();
913
+ if (!strategyId) {
914
+ return json({ success: false, error: "strategyId is required" });
915
+ }
916
+
917
+ const result = await fetchStrategyInfo(config, strategyId);
918
+
919
+ if (result.success && result.data) {
920
+ const info = result.data;
921
+ const lines: string[] = [];
922
+ lines.push("策略信息:");
923
+ lines.push("");
924
+ lines.push(`- ID: ${info.id}`);
925
+ lines.push(`- 名称: ${info.name}`);
926
+ if (info.slug) lines.push(`- Slug: ${info.slug}`);
927
+ if (info.version) lines.push(`- 版本: ${info.version}`);
928
+ if (info.author?.displayName) lines.push(`- 作者: ${info.author.displayName}`);
929
+ if (info.description) lines.push(`- 描述: ${info.description}`);
930
+ if (info.summary) lines.push(`- 摘要: ${info.summary}`);
931
+ if (info.tags?.length) lines.push(`- 标签: ${info.tags.join(", ")}`);
932
+ if (info.tier) lines.push(`- 等级: ${info.tier}`);
933
+
934
+ if (info.stats) {
935
+ lines.push("");
936
+ lines.push("统计:");
937
+ if (typeof info.stats.fcsScore === "number") {
938
+ lines.push(`- FCS 评分: ${info.stats.fcsScore.toFixed(1)}`);
939
+ }
940
+ if (typeof info.stats.forkCount === "number") {
941
+ lines.push(`- Fork 次数: ${info.stats.forkCount}`);
942
+ }
943
+ if (typeof info.stats.downloadCount === "number") {
944
+ lines.push(`- 下载次数: ${info.stats.downloadCount}`);
945
+ }
946
+ }
947
+
948
+ if (info.backtestResult) {
949
+ lines.push("");
950
+ lines.push("绩效指标:");
951
+ const perf = info.backtestResult;
952
+ if (typeof perf.totalReturn === "number") {
953
+ lines.push(`- 总收益率: ${(perf.totalReturn * 100).toFixed(2)}%`);
954
+ }
955
+ if (typeof perf.sharpe === "number") {
956
+ lines.push(`- 夏普比率: ${perf.sharpe.toFixed(3)}`);
957
+ }
958
+ if (typeof perf.maxDrawdown === "number") {
959
+ lines.push(`- 最大回撤: ${(perf.maxDrawdown * 100).toFixed(2)}%`);
960
+ }
961
+ if (typeof perf.winRate === "number") {
962
+ lines.push(`- 胜率: ${(perf.winRate * 100).toFixed(1)}%`);
963
+ }
964
+ }
965
+
966
+ lines.push("");
967
+ lines.push(`Hub URL: https://hub.openfinclaw.ai/strategy/${info.id}`);
968
+ lines.push("");
969
+ lines.push("使用 skill_fork 下载此策略到本地。");
970
+
971
+ return {
972
+ content: [{ type: "text" as const, text: lines.join("\n") }],
973
+ details: { success: true, ...info },
974
+ };
975
+ }
976
+
977
+ return json({
978
+ success: false,
979
+ error: result.error ?? "Failed to fetch strategy info",
980
+ });
981
+ } catch (err) {
982
+ return json({
983
+ success: false,
984
+ error: err instanceof Error ? err.message : String(err),
985
+ });
986
+ }
987
+ },
988
+ },
989
+ { names: ["skill_get_info"] },
990
+ );
991
+
992
+ // ── CLI commands ──
993
+ api.registerCli(
994
+ ({ program }) =>
995
+ registerStrategyCli({
996
+ program,
997
+ config,
998
+ logger: api.logger,
999
+ }),
1000
+ { commands: ["strategy"] },
1001
+ );
1002
+ },
1003
+ };
1004
+
1005
+ export default openfinclawPlugin;