@openfinclaw/openfinclaw-strategy 2026.3.14 → 2026.3.26

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 CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Command } from "commander";
1
2
  /**
2
3
  * OpenFinClaw — Unified financial tools plugin.
3
4
  * Features:
@@ -5,33 +6,11 @@
5
6
  * - Market data tools: price, K-line, crypto data, compare, search
6
7
  * Supports FEP v2.0 protocol for strategy packages.
7
8
  */
8
- import { readFile } from "node:fs/promises";
9
- import { Type } from "@sinclair/typebox";
10
- import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
11
- import { resolvePluginConfig } from "./src/config.js";
12
- import { DataHubClient, guessMarket } from "./src/datahub/client.js";
9
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
13
10
  import { registerStrategyCli } from "./src/cli.js";
14
- import { forkStrategy, fetchStrategyInfo } from "./src/fork.js";
15
- import { listLocalStrategies } from "./src/strategy-storage.js";
16
- import type { BoardType, LeaderboardResponse, MarketType } from "./src/types.js";
17
- import { validateStrategyPackage } from "./src/validate.js";
18
-
19
- /** JSON tool result helper. */
20
- function json(payload: unknown) {
21
- return {
22
- content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
23
- details: payload,
24
- };
25
- }
26
-
27
- /** Helper to pick params. */
28
- function pick(params: Record<string, unknown>, ...keys: string[]): Record<string, string> {
29
- const out: Record<string, string> = {};
30
- for (const k of keys) {
31
- if (params[k] != null) out[k] = String(params[k]);
32
- }
33
- return out;
34
- }
11
+ import { resolvePluginConfig } from "./src/config.js";
12
+ import { registerDatahubTools } from "./src/datahub/tools.js";
13
+ import { registerStrategyTools } from "./src/strategy/tools.js";
35
14
 
36
15
  const openfinclawPlugin = {
37
16
  id: "openfinclaw",
@@ -43,815 +22,15 @@ const openfinclawPlugin = {
43
22
  register(api: OpenClawPluginApi) {
44
23
  const config = resolvePluginConfig(api);
45
24
 
46
- // ── DataHub client (for market data tools) ──
47
- const datahubClient = config.apiKey
48
- ? new DataHubClient(config.datahubGatewayUrl, config.apiKey, config.requestTimeoutMs)
49
- : null;
50
-
51
- const NO_KEY =
52
- "API key not configured. Set apiKey in plugin config or OPENFINCLAW_API_KEY env var.";
53
-
54
- // Register fin-data-provider service for other plugins
55
- if (datahubClient) {
56
- api.registerService({
57
- id: "fin-data-provider",
58
- start: () => {},
59
- instance: {
60
- async getOHLCV(params: {
61
- symbol: string;
62
- market?: string;
63
- timeframe?: string;
64
- limit?: number;
65
- }) {
66
- const market = (params.market as MarketType) ?? guessMarket(params.symbol);
67
- return datahubClient!.getOHLCV({
68
- symbol: params.symbol,
69
- market,
70
- limit: params.limit ?? 300,
71
- });
72
- },
73
- async getTicker(symbol: string, market?: string) {
74
- const m = (market as MarketType) ?? guessMarket(symbol);
75
- return datahubClient!.getTicker(symbol, m);
76
- },
77
- },
78
- } as Parameters<typeof api.registerService>[0]);
79
- }
80
-
81
- // ═══════════════════════════════════════════════════════════════
82
- // MARKET DATA TOOLS (from findoo-datahub-slim-plugin)
83
- // ═══════════════════════════════════════════════════════════════
84
-
85
- // ── fin_price — Price Lookup ──
86
- api.registerTool(
87
- {
88
- name: "fin_price",
89
- label: "Price Lookup",
90
- description:
91
- "Get the current/latest price for any asset — stocks (A/HK/US), crypto, index. " +
92
- "Returns latest close, volume, and date. The simplest way to answer 'XX 现在什么价格'.",
93
- parameters: Type.Object({
94
- symbol: Type.String({
95
- description:
96
- "Asset symbol. Crypto: BTC/USDT, ETH/USDT; A-share: 600519.SH; HK: 00700.HK; US: AAPL; Index: 000300.SH",
97
- }),
98
- market: Type.Optional(
99
- Type.Unsafe<"crypto" | "equity">({
100
- type: "string",
101
- enum: ["crypto", "equity"],
102
- description:
103
- "Market type. Auto-detected if omitted: symbols with .SH/.SZ/.HK or pure letters → equity; contains '/' → crypto.",
104
- }),
105
- ),
106
- }),
107
- async execute(_id: string, params: Record<string, unknown>) {
108
- try {
109
- if (!datahubClient) return json({ error: NO_KEY });
110
- const symbol = String(params.symbol);
111
- const market = (params.market as MarketType) ?? guessMarket(symbol);
112
- const ticker = await datahubClient.getTicker(symbol, market);
113
- return json({
114
- symbol: ticker.symbol,
115
- market: ticker.market,
116
- price: ticker.last,
117
- volume24h: ticker.volume24h,
118
- timestamp: new Date(ticker.timestamp).toISOString(),
119
- });
120
- } catch (err) {
121
- return json({ error: err instanceof Error ? err.message : String(err) });
122
- }
123
- },
124
- },
125
- { names: ["fin_price"] },
126
- );
127
-
128
- // ── fin_kline — K-Line / OHLCV ──
129
- api.registerTool(
130
- {
131
- name: "fin_kline",
132
- label: "K-Line / OHLCV",
133
- description:
134
- "Fetch historical OHLCV (candlestick) data for any asset. " +
135
- "Use for price history, charting, and trend analysis.",
136
- parameters: Type.Object({
137
- symbol: Type.String({
138
- description: "Asset symbol (BTC/USDT, 600519.SH, AAPL, etc.)",
139
- }),
140
- market: Type.Optional(
141
- Type.Unsafe<"crypto" | "equity">({
142
- type: "string",
143
- enum: ["crypto", "equity"],
144
- description: "Market type (auto-detected if omitted)",
145
- }),
146
- ),
147
- limit: Type.Optional(
148
- Type.Number({ description: "Number of bars to return (default: 30)" }),
149
- ),
150
- }),
151
- async execute(_id: string, params: Record<string, unknown>) {
152
- try {
153
- if (!datahubClient) return json({ error: NO_KEY });
154
- const symbol = String(params.symbol);
155
- const market = (params.market as MarketType) ?? guessMarket(symbol);
156
- const limit = (params.limit as number) ?? 30;
157
- const ohlcv = await datahubClient.getOHLCV({ symbol, market, limit });
158
- return json({
159
- symbol,
160
- market,
161
- count: ohlcv.length,
162
- bars: ohlcv.map((b) => ({
163
- date: new Date(b.timestamp).toISOString().slice(0, 10),
164
- open: b.open,
165
- high: b.high,
166
- low: b.low,
167
- close: b.close,
168
- volume: b.volume,
169
- })),
170
- });
171
- } catch (err) {
172
- return json({ error: err instanceof Error ? err.message : String(err) });
173
- }
174
- },
175
- },
176
- { names: ["fin_kline"] },
177
- );
178
-
179
- // ── fin_crypto — Crypto & DeFi ──
180
- api.registerTool(
181
- {
182
- name: "fin_crypto",
183
- label: "Crypto & DeFi",
184
- description:
185
- "Crypto market data (ticker/orderbook/trades/funding_rate) via CEX, " +
186
- "DeFi (protocols/TVL/yields/stablecoins/fees/dex_volumes) via DefiLlama, " +
187
- "market metrics (coin/market/info/categories/trending/global_stats) via CoinGecko.",
188
- parameters: Type.Object({
189
- endpoint: Type.Unsafe<string>({
190
- type: "string",
191
- enum: [
192
- "market/ticker",
193
- "market/tickers",
194
- "market/orderbook",
195
- "market/trades",
196
- "market/funding_rate",
197
- "coin/market",
198
- "coin/historical",
199
- "coin/info",
200
- "coin/categories",
201
- "coin/trending",
202
- "coin/global_stats",
203
- "defi/protocols",
204
- "defi/tvl_historical",
205
- "defi/protocol_tvl",
206
- "defi/chains",
207
- "defi/yields",
208
- "defi/stablecoins",
209
- "defi/fees",
210
- "defi/dex_volumes",
211
- "defi/bridges",
212
- "defi/coin_prices",
213
- "price/historical",
214
- "search",
215
- ],
216
- description: "DataHub crypto endpoint path",
217
- }),
218
- symbol: Type.Optional(
219
- Type.String({ description: "Coin ID, trading pair, or protocol slug" }),
220
- ),
221
- start_date: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
222
- end_date: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
223
- limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })),
224
- }),
225
- async execute(_id: string, params: Record<string, unknown>) {
226
- try {
227
- if (!datahubClient) return json({ error: NO_KEY });
228
- const endpoint = String(params.endpoint ?? "coin/market");
229
- const qp = pick(params, "symbol", "start_date", "end_date", "limit");
230
- if (!qp.limit) qp.limit = "20";
231
- if (qp.symbol) {
232
- const coinIdEndpoints = ["coin/historical", "coin/info"];
233
- if (coinIdEndpoints.includes(endpoint)) {
234
- qp.coin_id = qp.symbol;
235
- delete qp.symbol;
236
- } else if (endpoint === "defi/protocol_tvl") {
237
- qp.protocol = qp.symbol;
238
- delete qp.symbol;
239
- } else if (endpoint === "defi/coin_prices") {
240
- qp.coins = qp.symbol;
241
- delete qp.symbol;
242
- }
243
- }
244
- const results = await datahubClient.crypto(endpoint, qp);
245
- return json({
246
- success: true,
247
- endpoint: `crypto/${endpoint}`,
248
- count: results.length,
249
- results,
250
- });
251
- } catch (err) {
252
- return json({ error: err instanceof Error ? err.message : String(err) });
253
- }
254
- },
255
- },
256
- { names: ["fin_crypto"] },
257
- );
258
-
259
- // ── fin_compare — Price Compare ──
260
- api.registerTool(
261
- {
262
- name: "fin_compare",
263
- label: "Price Compare",
264
- description:
265
- "Compare prices of 2-5 assets side by side. Returns latest price and recent change for each. " +
266
- "Use for cross-asset comparison questions like 'BTC vs ETH vs 黄金'.",
267
- parameters: Type.Object({
268
- symbols: Type.String({
269
- description: "Comma-separated symbols (2-5). Example: BTC/USDT,ETH/USDT,600519.SH",
270
- }),
271
- }),
272
- async execute(_id: string, params: Record<string, unknown>) {
273
- try {
274
- if (!datahubClient) return json({ error: NO_KEY });
275
- const raw = String(params.symbols);
276
- const symbols = raw
277
- .split(",")
278
- .map((s) => s.trim())
279
- .filter(Boolean)
280
- .slice(0, 5);
281
- if (symbols.length < 2)
282
- return json({ error: "Need at least 2 symbols, comma-separated" });
283
-
284
- const results = await Promise.allSettled(
285
- symbols.map(async (sym) => {
286
- const market = guessMarket(sym);
287
- const ticker = await datahubClient!.getTicker(sym, market);
288
- const bars = await datahubClient!.getOHLCV({ symbol: sym, market, limit: 7 });
289
- const weekAgo = bars.length > 0 ? bars[0]!.close : ticker.last;
290
- const weekChange = weekAgo > 0 ? ((ticker.last - weekAgo) / weekAgo) * 100 : 0;
291
- return {
292
- symbol: sym,
293
- market,
294
- price: ticker.last,
295
- weekChange: Math.round(weekChange * 100) / 100,
296
- };
297
- }),
298
- );
299
-
300
- return json({
301
- comparison: results.map((r, i) =>
302
- r.status === "fulfilled"
303
- ? r.value
304
- : { symbol: symbols[i], error: (r.reason as Error).message },
305
- ),
306
- });
307
- } catch (err) {
308
- return json({ error: err instanceof Error ? err.message : String(err) });
309
- }
310
- },
311
- },
312
- { names: ["fin_compare"] },
313
- );
314
-
315
- // ── fin_slim_search — Symbol Search ──
316
- api.registerTool(
317
- {
318
- name: "fin_slim_search",
319
- label: "Symbol Search",
320
- description:
321
- "Search for stock/crypto symbols by name or keyword. " +
322
- "Use when user mentions a company/coin name but not the exact symbol.",
323
- parameters: Type.Object({
324
- query: Type.String({ description: "Search keyword (e.g. '茅台', 'bitcoin', 'Tesla')" }),
325
- market: Type.Optional(
326
- Type.Unsafe<"crypto" | "equity">({
327
- type: "string",
328
- enum: ["crypto", "equity"],
329
- description: "Limit search to market type",
330
- }),
331
- ),
332
- }),
333
- async execute(_id: string, params: Record<string, unknown>) {
334
- try {
335
- if (!datahubClient) return json({ error: NO_KEY });
336
- const q = String(params.query);
337
- const market = params.market as string | undefined;
338
-
339
- const results: unknown[] = [];
340
-
341
- if (!market || market === "crypto") {
342
- try {
343
- const crypto = await datahubClient!.crypto("search", { query: q, limit: "5" });
344
- results.push(...crypto.map((r) => ({ ...(r as object), market: "crypto" })));
345
- } catch {
346
- /* ignore */
347
- }
348
- }
349
-
350
- if (!market || market === "equity") {
351
- try {
352
- const equity = await datahubClient!.equity("search", { query: q, limit: "5" });
353
- results.push(...equity.map((r) => ({ ...(r as object), market: "equity" })));
354
- } catch {
355
- /* ignore */
356
- }
357
- }
358
-
359
- return json({ query: q, count: results.length, results });
360
- } catch (err) {
361
- return json({ error: err instanceof Error ? err.message : String(err) });
362
- }
363
- },
364
- },
365
- { names: ["fin_slim_search"] },
366
- );
367
-
368
- // ═══════════════════════════════════════════════════════════════
369
- // STRATEGY TOOLS (existing)
370
- // ═══════════════════════════════════════════════════════════════
371
-
372
- // ── skill_publish ──
373
- api.registerTool(
374
- {
375
- name: "skill_publish",
376
- label: "Publish skill to server",
377
- description:
378
- "Publish a strategy ZIP to the skill server. The server will automatically run backtest. Returns submissionId and backtestTaskId for polling.",
379
- parameters: Type.Object({
380
- filePath: Type.String({
381
- description: "Path to the strategy ZIP file (must contain fep.yaml)",
382
- }),
383
- visibility: Type.Optional(
384
- Type.Unsafe<"public" | "private" | "unlisted">({
385
- type: "string",
386
- enum: ["public", "private", "unlisted"],
387
- description: "Override visibility from fep.yaml",
388
- }),
389
- ),
390
- }),
391
- async execute(_toolCallId: string, params: Record<string, unknown>) {
392
- try {
393
- const filePath = String(params.filePath ?? "").trim();
394
- if (!filePath) return json({ success: false, error: "filePath is required" });
395
-
396
- if (!config.apiKey) {
397
- return json({
398
- success: false,
399
- error: "API key not configured. Set apiKey in plugin config or OPENFINCLAW_API_KEY env.",
400
- });
401
- }
402
-
403
- const resolvedPath = api.resolvePath(filePath);
404
- const buf = await readFile(resolvedPath);
405
- const base64Content = buf.toString("base64");
406
-
407
- const body: Record<string, unknown> = { content: base64Content };
408
- if (
409
- params.visibility === "public" ||
410
- params.visibility === "private" ||
411
- params.visibility === "unlisted"
412
- ) {
413
- body.visibility = params.visibility;
414
- }
415
-
416
- const { status, data } = await skillApiRequest(config, "POST", "/skill/publish", {
417
- body,
418
- });
419
-
420
- if (status >= 200 && status < 300) {
421
- const resp = data as {
422
- slug?: string;
423
- entryId?: string;
424
- version?: string;
425
- status?: string;
426
- message?: string;
427
- submissionId?: string;
428
- backtestTaskId?: string | null;
429
- backtestStatus?: string | null;
430
- creditsEarned?: { action?: string; amount?: number; message?: string };
431
- };
432
-
433
- const lines: string[] = [];
434
- lines.push("Skill 发布成功!");
435
- lines.push("");
436
- lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
437
- lines.push(`- Entry ID: ${resp.entryId ?? "(未知)"}`);
438
- lines.push(`- Version: ${resp.version ?? "(未知)"}`);
439
- if (resp.backtestTaskId) lines.push(`- Backtest Task ID: ${resp.backtestTaskId}`);
440
- if (resp.creditsEarned?.amount) lines.push(`- 获得 ${resp.creditsEarned.amount} FC`);
441
- lines.push("");
442
- lines.push("使用 skill_publish_verify 工具查询回测状态和获取完整报告。");
443
-
444
- return {
445
- content: [{ type: "text" as const, text: lines.join("\n") }],
446
- details: { success: true, ...resp },
447
- };
448
- }
449
-
450
- return json({
451
- success: false,
452
- status,
453
- error:
454
- (data as { code?: string; message?: string })?.message ??
455
- (data as { detail?: string })?.detail ??
456
- data,
457
- });
458
- } catch (err) {
459
- return json({
460
- success: false,
461
- error: err instanceof Error ? err.message : String(err),
462
- });
463
- }
464
- },
465
- },
466
- { names: ["skill_publish"] },
467
- );
468
-
469
- // ── skill_publish_verify ──
470
- api.registerTool(
471
- {
472
- name: "skill_publish_verify",
473
- label: "Verify skill publish result",
474
- description:
475
- "Check publish and backtest status by submissionId or backtestTaskId. Returns full backtest report when completed.",
476
- parameters: Type.Object({
477
- submissionId: Type.Optional(
478
- Type.String({ description: "Submission ID from skill_publish response" }),
479
- ),
480
- backtestTaskId: Type.Optional(
481
- Type.String({ description: "Backtest task ID from skill_publish response" }),
482
- ),
483
- }),
484
- async execute(_toolCallId: string, params: Record<string, unknown>) {
485
- try {
486
- const submissionId = String(params.submissionId ?? "").trim() || undefined;
487
- const backtestTaskId = String(params.backtestTaskId ?? "").trim() || undefined;
488
-
489
- if (!submissionId && !backtestTaskId) {
490
- return json({ success: false, error: "Either submissionId or backtestTaskId is required" });
491
- }
492
-
493
- if (!config.apiKey) {
494
- return json({
495
- success: false,
496
- error: "API key not configured. Set apiKey in plugin config or OPENFINCLAW_API_KEY env.",
497
- });
498
- }
499
-
500
- const searchParams: Record<string, string> = {};
501
- if (submissionId) searchParams.submissionId = submissionId;
502
- if (backtestTaskId) searchParams.backtestTaskId = backtestTaskId;
503
-
504
- const { status, data } = await skillApiRequest(config, "GET", "/skill/publish/verify", {
505
- searchParams,
506
- });
507
-
508
- if (status >= 200 && status < 300) {
509
- const resp = data as Record<string, unknown>;
510
- const lines: string[] = [];
511
- lines.push("发布验证结果:");
512
- lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
513
- lines.push(`- Version: ${resp.version ?? "(未知)"}`);
514
- lines.push(`- Backtest Status: ${resp.backtestStatus ?? "(未知)"}`);
25
+ // Register DataHub market data tools (fin_price, fin_kline, fin_crypto, fin_compare, fin_slim_search)
26
+ registerDatahubTools(api, config);
515
27
 
516
- if (resp.backtestStatus === "completed" && resp.backtestReport) {
517
- const perf = (resp.backtestReport as Record<string, unknown>).performance as Record<string, unknown> | undefined;
518
- if (perf) {
519
- lines.push("");
520
- lines.push("回测报告摘要:");
521
- if (typeof perf.totalReturn === "number")
522
- lines.push(`- 总收益率: ${(perf.totalReturn * 100).toFixed(2)}%`);
523
- if (typeof perf.sharpe === "number") lines.push(`- 夏普比率: ${perf.sharpe.toFixed(3)}`);
524
- if (typeof perf.maxDrawdown === "number")
525
- lines.push(`- 最大回撤: ${(perf.maxDrawdown * 100).toFixed(2)}%`);
526
- if (typeof perf.winRate === "number") lines.push(`- 胜率: ${perf.winRate.toFixed(1)}%`);
527
- }
528
- }
28
+ // Register strategy tools (skill_publish, skill_validate, skill_fork, skill_leaderboard, etc.)
29
+ registerStrategyTools(api, config);
529
30
 
530
- return {
531
- content: [{ type: "text" as const, text: lines.join("\n") }],
532
- details: { success: true, ...resp },
533
- };
534
- }
535
-
536
- return json({
537
- success: false,
538
- status,
539
- error:
540
- (data as { code?: string; message?: string })?.message ??
541
- (data as { detail?: string })?.detail ??
542
- data,
543
- });
544
- } catch (err) {
545
- return json({
546
- success: false,
547
- error: err instanceof Error ? err.message : String(err),
548
- });
549
- }
550
- },
551
- },
552
- { names: ["skill_publish_verify"] },
553
- );
554
-
555
- // ── skill_validate ──
556
- api.registerTool(
557
- {
558
- name: "skill_validate",
559
- label: "Validate strategy package (FEP v2.0)",
560
- description:
561
- "Validate a strategy package directory per FEP v2.0 before zipping and publishing.",
562
- parameters: Type.Object({
563
- dirPath: Type.String({
564
- description: "Path to strategy package directory (must contain fep.yaml)",
565
- }),
566
- }),
567
- async execute(_toolCallId: string, params: Record<string, unknown>) {
568
- try {
569
- const dirPath = String(params.dirPath ?? "").trim();
570
- if (!dirPath) return json({ success: false, valid: false, errors: ["dirPath is required"] });
571
- const resolved = api.resolvePath(dirPath);
572
- const result = await validateStrategyPackage(resolved);
573
- return json({
574
- success: result.valid,
575
- valid: result.valid,
576
- errors: result.errors,
577
- warnings: result.warnings,
578
- });
579
- } catch (err) {
580
- return json({
581
- success: false,
582
- valid: false,
583
- errors: [err instanceof Error ? err.message : String(err)],
584
- });
585
- }
586
- },
587
- },
588
- { names: ["skill_validate"] },
589
- );
590
-
591
- // ── skill_leaderboard ──
592
- api.registerTool(
593
- {
594
- name: "skill_leaderboard",
595
- label: "Get Hub leaderboard",
596
- description:
597
- "Query strategy leaderboard from hub.openfinclaw.ai. No API key required.",
598
- parameters: Type.Object({
599
- boardType: Type.Optional(
600
- Type.Unsafe<BoardType>({
601
- type: "string",
602
- enum: ["composite", "returns", "risk", "popular", "rising"],
603
- description: "Leaderboard type: composite (default), returns, risk, popular, rising",
604
- }),
605
- ),
606
- limit: Type.Optional(Type.Number({ description: "Number of results (max 100, default 20)" })),
607
- offset: Type.Optional(Type.Number({ description: "Offset for pagination" })),
608
- }),
609
- async execute(_toolCallId: string, params: Record<string, unknown>) {
610
- try {
611
- const boardType = (params.boardType as BoardType) || "composite";
612
- const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 100);
613
- const offset = Math.max(Number(params.offset) || 0, 0);
614
-
615
- const url = new URL(`${config.hubApiUrl}/api/v1/skill/leaderboard/${boardType}`);
616
- url.searchParams.set("limit", String(limit));
617
- url.searchParams.set("offset", String(offset));
618
-
619
- const response = await fetch(url.toString(), {
620
- method: "GET",
621
- headers: { Accept: "application/json" },
622
- signal: AbortSignal.timeout(config.requestTimeoutMs),
623
- });
624
-
625
- const rawText = await response.text();
626
- let data: unknown;
627
- if (rawText && rawText.trim().startsWith("{")) {
628
- try {
629
- data = JSON.parse(rawText);
630
- } catch {
631
- data = { raw: rawText };
632
- }
633
- }
634
-
635
- if (response.status < 200 || response.status >= 300) {
636
- const errorData = data as { error?: { message?: string }; message?: string };
637
- return json({
638
- success: false,
639
- error: errorData.error?.message ?? errorData.message ?? `HTTP ${response.status}`,
640
- });
641
- }
642
-
643
- const leaderboard = data as LeaderboardResponse;
644
- const boardNames: Record<string, string> = {
645
- composite: "综合榜",
646
- returns: "收益榜",
647
- risk: "风控榜",
648
- popular: "人气榜",
649
- rising: "新星榜",
650
- };
651
-
652
- const lines: string[] = [];
653
- lines.push(
654
- `${boardNames[boardType] || boardType} Top ${leaderboard.strategies.length} (共 ${leaderboard.total} 个策略):`,
655
- );
656
- lines.push("");
657
-
658
- for (const s of leaderboard.strategies) {
659
- const perf = s.performance || {};
660
- const returnStr =
661
- typeof perf.returnSincePublish === "number"
662
- ? `收益: ${(perf.returnSincePublish * 100).toFixed(1)}%`
663
- : "收益: --";
664
- const sharpeStr =
665
- typeof perf.sharpeRatio === "number" ? `夏普: ${perf.sharpeRatio.toFixed(2)}` : "夏普: --";
666
- const author = s.author?.displayName || "未知";
667
- const hubUrl = `https://hub.openfinclaw.ai/strategy/${s.id}`;
668
- lines.push(`#${String(s.rank).padStart(2)} [${s.name}](${hubUrl}) ${returnStr} ${sharpeStr} 作者: ${author}`);
669
- }
670
-
671
- lines.push("");
672
- lines.push("使用 skill_get_info <id> 查看策略详情");
673
- lines.push("使用 skill_fork <id> 下载策略到本地");
674
-
675
- return {
676
- content: [{ type: "text" as const, text: lines.join("\n") }],
677
- details: { success: true, ...leaderboard },
678
- };
679
- } catch (err) {
680
- return json({
681
- success: false,
682
- error: err instanceof Error ? err.message : String(err),
683
- });
684
- }
685
- },
686
- },
687
- { names: ["skill_leaderboard"] },
688
- );
689
-
690
- // ── skill_fork ──
691
- api.registerTool(
692
- {
693
- name: "skill_fork",
694
- label: "Fork strategy from Hub",
695
- description:
696
- "Fork a public strategy from hub.openfinclaw.ai to local directory. Requires API key.",
697
- parameters: Type.Object({
698
- strategyId: Type.String({
699
- description: "Strategy ID from Hub (UUID or Hub URL)",
700
- }),
701
- name: Type.Optional(Type.String({ description: "Name for the forked strategy" })),
702
- targetDir: Type.Optional(Type.String({ description: "Custom target directory" })),
703
- }),
704
- async execute(_toolCallId: string, params: Record<string, unknown>) {
705
- try {
706
- const strategyId = String(params.strategyId ?? "").trim();
707
- if (!strategyId) return json({ success: false, error: "strategyId is required" });
708
-
709
- if (!config.apiKey) {
710
- return json({
711
- success: false,
712
- error: "API key is required for fork operation.",
713
- });
714
- }
715
-
716
- const result = await forkStrategy(config, strategyId, {
717
- name: params.name ? String(params.name) : undefined,
718
- targetDir: params.targetDir ? String(params.targetDir) : undefined,
719
- });
720
-
721
- if (result.success) {
722
- const lines: string[] = [];
723
- lines.push("策略 Fork 成功!");
724
- lines.push(`- 原策略: ${result.sourceName} (${result.sourceId})`);
725
- lines.push(`- 本地路径: ${result.localPath}`);
726
- lines.push("");
727
- lines.push("下一步:");
728
- lines.push(`- 编辑策略: code ${result.localPath}/scripts/strategy.py`);
729
-
730
- return {
731
- content: [{ type: "text" as const, text: lines.join("\n") }],
732
- details: result,
733
- };
734
- }
735
-
736
- return json({ success: false, error: result.error ?? "Failed to fork strategy" });
737
- } catch (err) {
738
- return json({
739
- success: false,
740
- error: err instanceof Error ? err.message : String(err),
741
- });
742
- }
743
- },
744
- },
745
- { names: ["skill_fork"] },
746
- );
747
-
748
- // ── skill_list_local ──
749
- api.registerTool(
750
- {
751
- name: "skill_list_local",
752
- label: "List local strategies",
753
- description: "List all strategies downloaded or created locally, organized by date.",
754
- parameters: Type.Object({}),
755
- async execute() {
756
- try {
757
- const strategies = await listLocalStrategies();
758
-
759
- if (strategies.length === 0) {
760
- return {
761
- content: [
762
- {
763
- type: "text" as const,
764
- text: "本地暂无策略。\n\n使用 skill_fork 从 Hub 下载策略。",
765
- },
766
- ],
767
- details: { success: true, strategies: [] },
768
- };
769
- }
770
-
771
- const lines: string[] = [];
772
- lines.push(`本地策略列表 (共 ${strategies.length} 个):`);
773
-
774
- let currentDate = "";
775
- for (const s of strategies) {
776
- if (s.dateDir !== currentDate) {
777
- currentDate = s.dateDir;
778
- lines.push(`${s.dateDir}/`);
779
- }
780
- const typeLabel = s.type === "forked" ? "(forked)" : "(created)";
781
- lines.push(` ${s.name} ${typeLabel}`);
782
- }
783
-
784
- return {
785
- content: [{ type: "text" as const, text: lines.join("\n") }],
786
- details: { success: true, strategies },
787
- };
788
- } catch (err) {
789
- return json({
790
- success: false,
791
- error: err instanceof Error ? err.message : String(err),
792
- });
793
- }
794
- },
795
- },
796
- { names: ["skill_list_local"] },
797
- );
798
-
799
- // ── skill_get_info ──
800
- api.registerTool(
801
- {
802
- name: "skill_get_info",
803
- label: "Get strategy info from Hub",
804
- description:
805
- "Fetch detailed information about a strategy from hub.openfinclaw.ai. No API key required.",
806
- parameters: Type.Object({
807
- strategyId: Type.String({ description: "Strategy ID from Hub (UUID or Hub URL)" }),
808
- }),
809
- async execute(_toolCallId: string, params: Record<string, unknown>) {
810
- try {
811
- const strategyId = String(params.strategyId ?? "").trim();
812
- if (!strategyId) return json({ success: false, error: "strategyId is required" });
813
-
814
- const result = await fetchStrategyInfo(config, strategyId);
815
-
816
- if (result.success && result.data) {
817
- const info = result.data;
818
- const lines: string[] = [];
819
- lines.push("策略信息:");
820
- lines.push(`- ID: ${info.id}`);
821
- lines.push(`- 名称: ${info.name}`);
822
- if (info.author?.displayName) lines.push(`- 作者: ${info.author.displayName}`);
823
- if (info.backtestResult) {
824
- lines.push("");
825
- lines.push("绩效指标:");
826
- if (typeof info.backtestResult.totalReturn === "number")
827
- lines.push(`- 总收益率: ${(info.backtestResult.totalReturn * 100).toFixed(2)}%`);
828
- if (typeof info.backtestResult.sharpe === "number")
829
- lines.push(`- 夏普比率: ${info.backtestResult.sharpe.toFixed(3)}`);
830
- }
831
- lines.push("");
832
- lines.push(`Hub URL: https://hub.openfinclaw.ai/strategy/${info.id}`);
833
-
834
- return {
835
- content: [{ type: "text" as const, text: lines.join("\n") }],
836
- details: { success: true, ...info },
837
- };
838
- }
839
-
840
- return json({ success: false, error: result.error ?? "Failed to fetch strategy info" });
841
- } catch (err) {
842
- return json({
843
- success: false,
844
- error: err instanceof Error ? err.message : String(err),
845
- });
846
- }
847
- },
848
- },
849
- { names: ["skill_get_info"] },
850
- );
851
-
852
- // ── CLI commands ──
31
+ // Register CLI commands
853
32
  api.registerCli(
854
- ({ program }) =>
33
+ ({ program }: { program: Command }) =>
855
34
  registerStrategyCli({
856
35
  program,
857
36
  config,
@@ -862,43 +41,4 @@ const openfinclawPlugin = {
862
41
  },
863
42
  };
864
43
 
865
- /**
866
- * HTTP request helper for Hub API.
867
- */
868
- async function skillApiRequest(
869
- config: { hubApiUrl: string; apiKey: string | undefined; requestTimeoutMs: number },
870
- method: "GET" | "POST",
871
- pathSegments: string,
872
- options?: { body?: Record<string, unknown>; searchParams?: Record<string, string> },
873
- ): Promise<{ status: number; data: unknown }> {
874
- const url = new URL(`${config.hubApiUrl}/api/v1${pathSegments}`);
875
- if (options?.searchParams) {
876
- for (const [k, v] of Object.entries(options.searchParams)) {
877
- url.searchParams.set(k, v);
878
- }
879
- }
880
-
881
- const headers: Record<string, string> = { "Content-Type": "application/json" };
882
- if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
883
-
884
- const response = await fetch(url.toString(), {
885
- method,
886
- headers,
887
- body: options?.body ? JSON.stringify(options.body) : undefined,
888
- signal: AbortSignal.timeout(config.requestTimeoutMs),
889
- });
890
-
891
- const rawText = await response.text();
892
- let data: unknown = rawText;
893
- if (rawText && rawText.trim().startsWith("{")) {
894
- try {
895
- data = JSON.parse(rawText);
896
- } catch {
897
- data = { raw: rawText };
898
- }
899
- }
900
-
901
- return { status: response.status, data };
902
- }
903
-
904
- export default openfinclawPlugin;
44
+ export default openfinclawPlugin;