@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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub API client for strategy operations.
|
|
3
|
+
* Handles HTTP requests to hub.openfinclaw.ai
|
|
4
|
+
*/
|
|
5
|
+
import type { UnifiedPluginConfig } from "../types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HTTP request helper for Hub API.
|
|
9
|
+
*/
|
|
10
|
+
export async function hubApiRequest(
|
|
11
|
+
config: UnifiedPluginConfig,
|
|
12
|
+
method: "GET" | "POST",
|
|
13
|
+
pathSegments: string,
|
|
14
|
+
options?: { body?: Record<string, unknown>; searchParams?: Record<string, string> },
|
|
15
|
+
): Promise<{ status: number; data: unknown }> {
|
|
16
|
+
const url = new URL(`${config.hubApiUrl}/api/v1${pathSegments}`);
|
|
17
|
+
if (options?.searchParams) {
|
|
18
|
+
for (const [k, v] of Object.entries(options.searchParams)) {
|
|
19
|
+
url.searchParams.set(k, v);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
24
|
+
if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
25
|
+
|
|
26
|
+
const response = await fetch(url.toString(), {
|
|
27
|
+
method,
|
|
28
|
+
headers,
|
|
29
|
+
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
30
|
+
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const rawText = await response.text();
|
|
34
|
+
let data: unknown = rawText;
|
|
35
|
+
if (rawText && rawText.trim().startsWith("{")) {
|
|
36
|
+
try {
|
|
37
|
+
data = JSON.parse(rawText);
|
|
38
|
+
} catch {
|
|
39
|
+
data = { raw: rawText };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { status: response.status, data };
|
|
44
|
+
}
|
|
@@ -11,15 +11,15 @@ import {
|
|
|
11
11
|
writeForkMeta,
|
|
12
12
|
parseStrategyId,
|
|
13
13
|
formatDate,
|
|
14
|
-
} from "./
|
|
14
|
+
} from "./storage.js";
|
|
15
15
|
import type {
|
|
16
|
-
|
|
16
|
+
UnifiedPluginConfig,
|
|
17
17
|
ForkOptions,
|
|
18
18
|
ForkResult,
|
|
19
19
|
HubPublicEntry,
|
|
20
20
|
ForkAndDownloadResponse,
|
|
21
|
-
} from "
|
|
22
|
-
import type { ForkMeta } from "
|
|
21
|
+
} from "../types.js";
|
|
22
|
+
import type { ForkMeta } from "../types.js";
|
|
23
23
|
|
|
24
24
|
const HUB_BASE_URL = "https://hub.openfinclaw.ai";
|
|
25
25
|
|
|
@@ -28,10 +28,10 @@ const HUB_BASE_URL = "https://hub.openfinclaw.ai";
|
|
|
28
28
|
* GET /api/v1/skill/public/{id}
|
|
29
29
|
*/
|
|
30
30
|
export async function fetchStrategyInfo(
|
|
31
|
-
config:
|
|
31
|
+
config: UnifiedPluginConfig,
|
|
32
32
|
strategyId: string,
|
|
33
33
|
): Promise<{ success: boolean; data?: HubPublicEntry; error?: string }> {
|
|
34
|
-
const url = new URL(`${config.
|
|
34
|
+
const url = new URL(`${config.hubApiUrl}/api/v1/skill/public/${strategyId}`);
|
|
35
35
|
|
|
36
36
|
const headers: Record<string, string> = {
|
|
37
37
|
Accept: "application/json",
|
|
@@ -84,18 +84,19 @@ export async function fetchStrategyInfo(
|
|
|
84
84
|
* POST /api/v1/skill/entries/{id}/fork-and-download
|
|
85
85
|
*/
|
|
86
86
|
export async function forkAndDownloadFromHub(
|
|
87
|
-
config:
|
|
87
|
+
config: UnifiedPluginConfig,
|
|
88
88
|
strategyId: string,
|
|
89
89
|
options?: ForkOptions,
|
|
90
90
|
): Promise<{ success: boolean; data?: ForkAndDownloadResponse; error?: string }> {
|
|
91
91
|
if (!config.apiKey) {
|
|
92
92
|
return {
|
|
93
93
|
success: false,
|
|
94
|
-
error:
|
|
94
|
+
error:
|
|
95
|
+
"API key is required for fork operation. Set OPENFINCLAW_API_KEY environment variable.",
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
const url = new URL(`${config.
|
|
99
|
+
const url = new URL(`${config.hubApiUrl}/api/v1/skill/entries/${strategyId}/fork-and-download`);
|
|
99
100
|
|
|
100
101
|
const body: Record<string, unknown> = {};
|
|
101
102
|
if (options?.name) body.name = options.name;
|
|
@@ -208,7 +209,7 @@ export async function extractZipToDir(
|
|
|
208
209
|
* Flow: fetchStrategyInfo → forkAndDownloadFromHub → downloadFromSignedUrl → extract
|
|
209
210
|
*/
|
|
210
211
|
export async function forkStrategy(
|
|
211
|
-
config:
|
|
212
|
+
config: UnifiedPluginConfig,
|
|
212
213
|
strategyIdInput: string,
|
|
213
214
|
options?: ForkOptions,
|
|
214
215
|
): Promise<ForkResult> {
|
|
@@ -339,4 +340,4 @@ export async function forkStrategy(
|
|
|
339
340
|
*/
|
|
340
341
|
export function buildHubUrl(strategyId: string): string {
|
|
341
342
|
return `${HUB_BASE_URL}/strategy/${strategyId}`;
|
|
342
|
-
}
|
|
343
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local strategy storage management.
|
|
3
|
+
* Handles reading/writing strategy metadata and listing local strategies.
|
|
4
|
+
*/
|
|
1
5
|
import fs from "node:fs";
|
|
2
6
|
import { homedir } from "node:os";
|
|
3
7
|
import path from "node:path";
|
|
4
|
-
import type { ForkMeta, CreatedMeta, LocalStrategy
|
|
8
|
+
import type { ForkMeta, CreatedMeta, LocalStrategy } from "../types.js";
|
|
5
9
|
|
|
6
10
|
const WORKSPACE_DIRNAME = "workspace";
|
|
7
11
|
const STRATEGIES_DIRNAME = "strategies";
|
|
@@ -30,10 +34,6 @@ export function getStrategiesRoot(): string {
|
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* Generate a slugified directory name from strategy name.
|
|
33
|
-
* - Lowercase
|
|
34
|
-
* - Spaces/underscores to hyphens
|
|
35
|
-
* - Remove special characters
|
|
36
|
-
* - Max 40 characters
|
|
37
37
|
*/
|
|
38
38
|
export function slugifyName(name: string): string {
|
|
39
39
|
return name
|
|
@@ -73,7 +73,6 @@ export function generateCreatedDirName(name: string): string {
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Create date directory under strategies root.
|
|
76
|
-
* Returns the full path to the date directory.
|
|
77
76
|
*/
|
|
78
77
|
export function createDateDir(baseDir: string, date?: string): string {
|
|
79
78
|
const dateStr = date ?? formatDate(new Date());
|
|
@@ -300,4 +299,4 @@ export function parseStrategyId(input: string): string {
|
|
|
300
299
|
}
|
|
301
300
|
|
|
302
301
|
return trimmed.toLowerCase();
|
|
303
|
-
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy tools registration.
|
|
3
|
+
* Tools: skill_publish, skill_publish_verify, skill_validate, skill_leaderboard, skill_fork, skill_list_local, skill_get_info
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { Type } from "@sinclair/typebox";
|
|
7
|
+
import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
|
|
8
|
+
import { hubApiRequest } from "./client.js";
|
|
9
|
+
import { forkStrategy, fetchStrategyInfo } from "./fork.js";
|
|
10
|
+
import { listLocalStrategies } from "./storage.js";
|
|
11
|
+
import { validateStrategyPackage } from "./validate.js";
|
|
12
|
+
import type { UnifiedPluginConfig, BoardType, LeaderboardResponse } from "../types.js";
|
|
13
|
+
|
|
14
|
+
/** JSON tool result helper. */
|
|
15
|
+
function json(payload: unknown) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
|
|
18
|
+
details: payload,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NO_API_KEY =
|
|
23
|
+
"API key not configured. Set apiKey in plugin config or OPENFINCLAW_API_KEY env var.";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register strategy tools.
|
|
27
|
+
*/
|
|
28
|
+
export function registerStrategyTools(
|
|
29
|
+
api: OpenClawPluginApi,
|
|
30
|
+
config: UnifiedPluginConfig,
|
|
31
|
+
): void {
|
|
32
|
+
// ── skill_publish ──
|
|
33
|
+
api.registerTool(
|
|
34
|
+
{
|
|
35
|
+
name: "skill_publish",
|
|
36
|
+
label: "Publish skill to server",
|
|
37
|
+
description:
|
|
38
|
+
"Publish a strategy ZIP to the skill server. The server will automatically run backtest. Returns submissionId and backtestTaskId for polling.",
|
|
39
|
+
parameters: Type.Object({
|
|
40
|
+
filePath: Type.String({
|
|
41
|
+
description: "Path to the strategy ZIP file (must contain fep.yaml)",
|
|
42
|
+
}),
|
|
43
|
+
visibility: Type.Optional(
|
|
44
|
+
Type.Unsafe<"public" | "private" | "unlisted">({
|
|
45
|
+
type: "string",
|
|
46
|
+
enum: ["public", "private", "unlisted"],
|
|
47
|
+
description: "Override visibility from fep.yaml",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
}),
|
|
51
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
52
|
+
try {
|
|
53
|
+
const filePath = String(params.filePath ?? "").trim();
|
|
54
|
+
if (!filePath) return json({ success: false, error: "filePath is required" });
|
|
55
|
+
|
|
56
|
+
if (!config.apiKey) {
|
|
57
|
+
return json({
|
|
58
|
+
success: false,
|
|
59
|
+
error: NO_API_KEY,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolvedPath = api.resolvePath(filePath);
|
|
64
|
+
const buf = await readFile(resolvedPath);
|
|
65
|
+
const base64Content = buf.toString("base64");
|
|
66
|
+
|
|
67
|
+
const body: Record<string, unknown> = { content: base64Content };
|
|
68
|
+
if (
|
|
69
|
+
params.visibility === "public" ||
|
|
70
|
+
params.visibility === "private" ||
|
|
71
|
+
params.visibility === "unlisted"
|
|
72
|
+
) {
|
|
73
|
+
body.visibility = params.visibility;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { status, data } = await hubApiRequest(config, "POST", "/skill/publish", {
|
|
77
|
+
body,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (status >= 200 && status < 300) {
|
|
81
|
+
const resp = data as {
|
|
82
|
+
slug?: string;
|
|
83
|
+
entryId?: string;
|
|
84
|
+
version?: string;
|
|
85
|
+
status?: string;
|
|
86
|
+
message?: string;
|
|
87
|
+
submissionId?: string;
|
|
88
|
+
backtestTaskId?: string | null;
|
|
89
|
+
backtestStatus?: string | null;
|
|
90
|
+
creditsEarned?: { action?: string; amount?: number; message?: string };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
lines.push("Skill 发布成功!");
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
|
|
97
|
+
lines.push(`- Entry ID: ${resp.entryId ?? "(未知)"}`);
|
|
98
|
+
lines.push(`- Version: ${resp.version ?? "(未知)"}`);
|
|
99
|
+
if (resp.backtestTaskId) lines.push(`- Backtest Task ID: ${resp.backtestTaskId}`);
|
|
100
|
+
if (resp.creditsEarned?.amount) lines.push(`- 获得 ${resp.creditsEarned.amount} FC`);
|
|
101
|
+
lines.push("");
|
|
102
|
+
lines.push("使用 skill_publish_verify 工具查询回测状态和获取完整报告。");
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
106
|
+
details: { success: true, ...resp },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return json({
|
|
111
|
+
success: false,
|
|
112
|
+
status,
|
|
113
|
+
error:
|
|
114
|
+
(data as { code?: string; message?: string })?.message ??
|
|
115
|
+
(data as { detail?: string })?.detail ??
|
|
116
|
+
data,
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return json({
|
|
120
|
+
success: false,
|
|
121
|
+
error: err instanceof Error ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{ names: ["skill_publish"] },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// ── skill_publish_verify ──
|
|
130
|
+
api.registerTool(
|
|
131
|
+
{
|
|
132
|
+
name: "skill_publish_verify",
|
|
133
|
+
label: "Verify skill publish result",
|
|
134
|
+
description:
|
|
135
|
+
"Check publish and backtest status by submissionId or backtestTaskId. Returns full backtest report when completed.",
|
|
136
|
+
parameters: Type.Object({
|
|
137
|
+
submissionId: Type.Optional(
|
|
138
|
+
Type.String({ description: "Submission ID from skill_publish response" }),
|
|
139
|
+
),
|
|
140
|
+
backtestTaskId: Type.Optional(
|
|
141
|
+
Type.String({ description: "Backtest task ID from skill_publish response" }),
|
|
142
|
+
),
|
|
143
|
+
}),
|
|
144
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
145
|
+
try {
|
|
146
|
+
const submissionId = String(params.submissionId ?? "").trim() || undefined;
|
|
147
|
+
const backtestTaskId = String(params.backtestTaskId ?? "").trim() || undefined;
|
|
148
|
+
|
|
149
|
+
if (!submissionId && !backtestTaskId) {
|
|
150
|
+
return json({
|
|
151
|
+
success: false,
|
|
152
|
+
error: "Either submissionId or backtestTaskId is required",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!config.apiKey) {
|
|
157
|
+
return json({
|
|
158
|
+
success: false,
|
|
159
|
+
error: NO_API_KEY,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const searchParams: Record<string, string> = {};
|
|
164
|
+
if (submissionId) searchParams.submissionId = submissionId;
|
|
165
|
+
if (backtestTaskId) searchParams.backtestTaskId = backtestTaskId;
|
|
166
|
+
|
|
167
|
+
const { status, data } = await hubApiRequest(config, "GET", "/skill/publish/verify", {
|
|
168
|
+
searchParams,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (status >= 200 && status < 300) {
|
|
172
|
+
const resp = data as Record<string, unknown>;
|
|
173
|
+
const lines: string[] = [];
|
|
174
|
+
lines.push("发布验证结果:");
|
|
175
|
+
lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
|
|
176
|
+
lines.push(`- Version: ${resp.version ?? "(未知)"}`);
|
|
177
|
+
lines.push(`- Backtest Status: ${resp.backtestStatus ?? "(未知)"}`);
|
|
178
|
+
|
|
179
|
+
if (resp.backtestStatus === "completed" && resp.backtestReport) {
|
|
180
|
+
const perf = (resp.backtestReport as Record<string, unknown>).performance as
|
|
181
|
+
| Record<string, unknown>
|
|
182
|
+
| undefined;
|
|
183
|
+
if (perf) {
|
|
184
|
+
lines.push("");
|
|
185
|
+
lines.push("回测报告摘要:");
|
|
186
|
+
if (typeof perf.totalReturn === "number")
|
|
187
|
+
lines.push(`- 总收益率: ${(perf.totalReturn * 100).toFixed(2)}%`);
|
|
188
|
+
if (typeof perf.sharpe === "number")
|
|
189
|
+
lines.push(`- 夏普比率: ${perf.sharpe.toFixed(3)}`);
|
|
190
|
+
if (typeof perf.maxDrawdown === "number")
|
|
191
|
+
lines.push(`- 最大回撤: ${(perf.maxDrawdown * 100).toFixed(2)}%`);
|
|
192
|
+
if (typeof perf.winRate === "number")
|
|
193
|
+
lines.push(`- 胜率: ${perf.winRate.toFixed(1)}%`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
199
|
+
details: { success: true, ...resp },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return json({
|
|
204
|
+
success: false,
|
|
205
|
+
status,
|
|
206
|
+
error:
|
|
207
|
+
(data as { code?: string; message?: string })?.message ??
|
|
208
|
+
(data as { detail?: string })?.detail ??
|
|
209
|
+
data,
|
|
210
|
+
});
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return json({
|
|
213
|
+
success: false,
|
|
214
|
+
error: err instanceof Error ? err.message : String(err),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{ names: ["skill_publish_verify"] },
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// ── skill_validate ──
|
|
223
|
+
api.registerTool(
|
|
224
|
+
{
|
|
225
|
+
name: "skill_validate",
|
|
226
|
+
label: "Validate strategy package (FEP v2.0)",
|
|
227
|
+
description:
|
|
228
|
+
"Validate a strategy package directory per FEP v2.0 before zipping and publishing.",
|
|
229
|
+
parameters: Type.Object({
|
|
230
|
+
dirPath: Type.String({
|
|
231
|
+
description: "Path to strategy package directory (must contain fep.yaml)",
|
|
232
|
+
}),
|
|
233
|
+
}),
|
|
234
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
235
|
+
try {
|
|
236
|
+
const dirPath = String(params.dirPath ?? "").trim();
|
|
237
|
+
if (!dirPath)
|
|
238
|
+
return json({ success: false, valid: false, errors: ["dirPath is required"] });
|
|
239
|
+
const resolved = api.resolvePath(dirPath);
|
|
240
|
+
const result = await validateStrategyPackage(resolved);
|
|
241
|
+
return json({
|
|
242
|
+
success: result.valid,
|
|
243
|
+
valid: result.valid,
|
|
244
|
+
errors: result.errors,
|
|
245
|
+
warnings: result.warnings,
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return json({
|
|
249
|
+
success: false,
|
|
250
|
+
valid: false,
|
|
251
|
+
errors: [err instanceof Error ? err.message : String(err)],
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{ names: ["skill_validate"] },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// ── skill_leaderboard ──
|
|
260
|
+
api.registerTool(
|
|
261
|
+
{
|
|
262
|
+
name: "skill_leaderboard",
|
|
263
|
+
label: "Get Hub leaderboard",
|
|
264
|
+
description: "Query strategy leaderboard from hub.openfinclaw.ai. No API key required.",
|
|
265
|
+
parameters: Type.Object({
|
|
266
|
+
boardType: Type.Optional(
|
|
267
|
+
Type.Unsafe<BoardType>({
|
|
268
|
+
type: "string",
|
|
269
|
+
enum: ["composite", "returns", "risk", "popular", "rising"],
|
|
270
|
+
description: "Leaderboard type: composite (default), returns, risk, popular, rising",
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
limit: Type.Optional(
|
|
274
|
+
Type.Number({ description: "Number of results (max 100, default 20)" }),
|
|
275
|
+
),
|
|
276
|
+
offset: Type.Optional(Type.Number({ description: "Offset for pagination" })),
|
|
277
|
+
}),
|
|
278
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
279
|
+
try {
|
|
280
|
+
const boardType = (params.boardType as BoardType) || "composite";
|
|
281
|
+
const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 100);
|
|
282
|
+
const offset = Math.max(Number(params.offset) || 0, 0);
|
|
283
|
+
|
|
284
|
+
const url = new URL(`${config.hubApiUrl}/api/v1/skill/leaderboard/${boardType}`);
|
|
285
|
+
url.searchParams.set("limit", String(limit));
|
|
286
|
+
url.searchParams.set("offset", String(offset));
|
|
287
|
+
|
|
288
|
+
const response = await fetch(url.toString(), {
|
|
289
|
+
method: "GET",
|
|
290
|
+
headers: { Accept: "application/json" },
|
|
291
|
+
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const rawText = await response.text();
|
|
295
|
+
let data: unknown;
|
|
296
|
+
if (rawText && rawText.trim().startsWith("{")) {
|
|
297
|
+
try {
|
|
298
|
+
data = JSON.parse(rawText);
|
|
299
|
+
} catch {
|
|
300
|
+
data = { raw: rawText };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (response.status < 200 || response.status >= 300) {
|
|
305
|
+
const errorData = data as { error?: { message?: string }; message?: string };
|
|
306
|
+
return json({
|
|
307
|
+
success: false,
|
|
308
|
+
error: errorData.error?.message ?? errorData.message ?? `HTTP ${response.status}`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const leaderboard = data as LeaderboardResponse;
|
|
313
|
+
const boardNames: Record<string, string> = {
|
|
314
|
+
composite: "综合榜",
|
|
315
|
+
returns: "收益榜",
|
|
316
|
+
risk: "风控榜",
|
|
317
|
+
popular: "人气榜",
|
|
318
|
+
rising: "新星榜",
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const lines: string[] = [];
|
|
322
|
+
lines.push(
|
|
323
|
+
`${boardNames[boardType] || boardType} Top ${leaderboard.strategies.length} (共 ${leaderboard.total} 个策略):`,
|
|
324
|
+
);
|
|
325
|
+
lines.push("");
|
|
326
|
+
|
|
327
|
+
for (const s of leaderboard.strategies) {
|
|
328
|
+
const perf = s.performance || {};
|
|
329
|
+
const returnStr =
|
|
330
|
+
typeof perf.returnSincePublish === "number"
|
|
331
|
+
? `收益: ${(perf.returnSincePublish * 100).toFixed(1)}%`
|
|
332
|
+
: "收益: --";
|
|
333
|
+
const sharpeStr =
|
|
334
|
+
typeof perf.sharpeRatio === "number"
|
|
335
|
+
? `夏普: ${perf.sharpeRatio.toFixed(2)}`
|
|
336
|
+
: "夏普: --";
|
|
337
|
+
const author = s.author?.displayName || "未知";
|
|
338
|
+
const hubUrl = `https://hub.openfinclaw.ai/strategy/${s.id}`;
|
|
339
|
+
lines.push(
|
|
340
|
+
`#${String(s.rank).padStart(2)} [${s.name}](${hubUrl}) ${returnStr} ${sharpeStr} 作者: ${author}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
lines.push("");
|
|
345
|
+
lines.push("使用 skill_get_info <id> 查看策略详情");
|
|
346
|
+
lines.push("使用 skill_fork <id> 下载策略到本地");
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
350
|
+
details: { success: true, ...leaderboard },
|
|
351
|
+
};
|
|
352
|
+
} catch (err) {
|
|
353
|
+
return json({
|
|
354
|
+
success: false,
|
|
355
|
+
error: err instanceof Error ? err.message : String(err),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{ names: ["skill_leaderboard"] },
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// ── skill_fork ──
|
|
364
|
+
api.registerTool(
|
|
365
|
+
{
|
|
366
|
+
name: "skill_fork",
|
|
367
|
+
label: "Fork strategy from Hub",
|
|
368
|
+
description:
|
|
369
|
+
"Fork a public strategy from hub.openfinclaw.ai to local directory. Requires API key.",
|
|
370
|
+
parameters: Type.Object({
|
|
371
|
+
strategyId: Type.String({
|
|
372
|
+
description: "Strategy ID from Hub (UUID or Hub URL)",
|
|
373
|
+
}),
|
|
374
|
+
name: Type.Optional(Type.String({ description: "Name for the forked strategy" })),
|
|
375
|
+
targetDir: Type.Optional(Type.String({ description: "Custom target directory" })),
|
|
376
|
+
}),
|
|
377
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
378
|
+
try {
|
|
379
|
+
const strategyId = String(params.strategyId ?? "").trim();
|
|
380
|
+
if (!strategyId) return json({ success: false, error: "strategyId is required" });
|
|
381
|
+
|
|
382
|
+
if (!config.apiKey) {
|
|
383
|
+
return json({
|
|
384
|
+
success: false,
|
|
385
|
+
error: "API key is required for fork operation.",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const result = await forkStrategy(config, strategyId, {
|
|
390
|
+
name: params.name ? String(params.name) : undefined,
|
|
391
|
+
targetDir: params.targetDir ? String(params.targetDir) : undefined,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (result.success) {
|
|
395
|
+
const lines: string[] = [];
|
|
396
|
+
lines.push("策略 Fork 成功!");
|
|
397
|
+
lines.push(`- 原策略: ${result.sourceName} (${result.sourceId})`);
|
|
398
|
+
lines.push(`- 本地路径: ${result.localPath}`);
|
|
399
|
+
lines.push("");
|
|
400
|
+
lines.push("下一步:");
|
|
401
|
+
lines.push(`- 编辑策略: code ${result.localPath}/scripts/strategy.py`);
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
405
|
+
details: result,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return json({ success: false, error: result.error ?? "Failed to fork strategy" });
|
|
410
|
+
} catch (err) {
|
|
411
|
+
return json({
|
|
412
|
+
success: false,
|
|
413
|
+
error: err instanceof Error ? err.message : String(err),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
{ names: ["skill_fork"] },
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// ── skill_list_local ──
|
|
422
|
+
api.registerTool(
|
|
423
|
+
{
|
|
424
|
+
name: "skill_list_local",
|
|
425
|
+
label: "List local strategies",
|
|
426
|
+
description: "List all strategies downloaded or created locally, organized by date.",
|
|
427
|
+
parameters: Type.Object({}),
|
|
428
|
+
async execute() {
|
|
429
|
+
try {
|
|
430
|
+
const strategies = await listLocalStrategies();
|
|
431
|
+
|
|
432
|
+
if (strategies.length === 0) {
|
|
433
|
+
return {
|
|
434
|
+
content: [
|
|
435
|
+
{
|
|
436
|
+
type: "text" as const,
|
|
437
|
+
text: "本地暂无策略。\n\n使用 skill_fork 从 Hub 下载策略。",
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
details: { success: true, strategies: [] },
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const lines: string[] = [];
|
|
445
|
+
lines.push(`本地策略列表 (共 ${strategies.length} 个):`);
|
|
446
|
+
|
|
447
|
+
let currentDate = "";
|
|
448
|
+
for (const s of strategies) {
|
|
449
|
+
if (s.dateDir !== currentDate) {
|
|
450
|
+
currentDate = s.dateDir;
|
|
451
|
+
lines.push(`${s.dateDir}/`);
|
|
452
|
+
}
|
|
453
|
+
const typeLabel = s.type === "forked" ? "(forked)" : "(created)";
|
|
454
|
+
lines.push(` ${s.name} ${typeLabel}`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
459
|
+
details: { success: true, strategies },
|
|
460
|
+
};
|
|
461
|
+
} catch (err) {
|
|
462
|
+
return json({
|
|
463
|
+
success: false,
|
|
464
|
+
error: err instanceof Error ? err.message : String(err),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{ names: ["skill_list_local"] },
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
// ── skill_get_info ──
|
|
473
|
+
api.registerTool(
|
|
474
|
+
{
|
|
475
|
+
name: "skill_get_info",
|
|
476
|
+
label: "Get strategy info from Hub",
|
|
477
|
+
description:
|
|
478
|
+
"Fetch detailed information about a strategy from hub.openfinclaw.ai. No API key required.",
|
|
479
|
+
parameters: Type.Object({
|
|
480
|
+
strategyId: Type.String({ description: "Strategy ID from Hub (UUID or Hub URL)" }),
|
|
481
|
+
}),
|
|
482
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
483
|
+
try {
|
|
484
|
+
const strategyId = String(params.strategyId ?? "").trim();
|
|
485
|
+
if (!strategyId) return json({ success: false, error: "strategyId is required" });
|
|
486
|
+
|
|
487
|
+
const result = await fetchStrategyInfo(config, strategyId);
|
|
488
|
+
|
|
489
|
+
if (result.success && result.data) {
|
|
490
|
+
const info = result.data;
|
|
491
|
+
const lines: string[] = [];
|
|
492
|
+
lines.push("策略信息:");
|
|
493
|
+
lines.push(`- ID: ${info.id}`);
|
|
494
|
+
lines.push(`- 名称: ${info.name}`);
|
|
495
|
+
if (info.author?.displayName) lines.push(`- 作者: ${info.author.displayName}`);
|
|
496
|
+
if (info.backtestResult) {
|
|
497
|
+
lines.push("");
|
|
498
|
+
lines.push("绩效指标:");
|
|
499
|
+
if (typeof info.backtestResult.totalReturn === "number")
|
|
500
|
+
lines.push(`- 总收益率: ${(info.backtestResult.totalReturn * 100).toFixed(2)}%`);
|
|
501
|
+
if (typeof info.backtestResult.sharpe === "number")
|
|
502
|
+
lines.push(`- 夏普比率: ${info.backtestResult.sharpe.toFixed(3)}`);
|
|
503
|
+
}
|
|
504
|
+
lines.push("");
|
|
505
|
+
lines.push(`Hub URL: https://hub.openfinclaw.ai/strategy/${info.id}`);
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
509
|
+
details: { success: true, ...info },
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return json({ success: false, error: result.error ?? "Failed to fetch strategy info" });
|
|
514
|
+
} catch (err) {
|
|
515
|
+
return json({
|
|
516
|
+
success: false,
|
|
517
|
+
error: err instanceof Error ? err.message : String(err),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{ names: ["skill_get_info"] },
|
|
523
|
+
);
|
|
524
|
+
}
|