import { randomUUID } from "node:crypto"; /** * Strategy tools registration. * Tools: skill_publish, skill_publish_verify, skill_validate, skill_leaderboard, skill_fork, skill_list_local, skill_get_info */ import { readFile } from "node:fs/promises"; import type { DatabaseSync } from "node:sqlite"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { insertAgentEvent, insertBacktestResult, updateBacktestResult, updateStrategyLevel, upsertStrategy, } from "../db/repositories.js"; import { withLogging } from "../middleware/with-logging.js"; import type { UnifiedPluginConfig, BoardType, LeaderboardResponse } from "../types.js"; import { hubApiRequest } from "./client.js"; import { forkStrategy, fetchStrategyInfo } from "./fork.js"; import { listLocalStrategies } from "./storage.js"; import { validateStrategyPackage } from "./validate.js"; /** JSON tool result helper. */ function json(payload: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], details: payload, }; } const NO_API_KEY = "API key not configured. Set apiKey in plugin config or OPENFINCLAW_API_KEY env var."; /** * Register strategy tools. * @param getDb - Lazy database getter (called at execution time, not registration). */ export function registerStrategyTools( api: OpenClawPluginApi, config: UnifiedPluginConfig, getDb: () => DatabaseSync, ): void { // ── skill_publish ── api.registerTool( { name: "skill_publish", label: "Publish skill to server", description: "Publish a strategy ZIP to the skill server. The server will automatically run backtest. Returns submissionId and backtestTaskId for polling.", parameters: Type.Object({ filePath: Type.String({ description: "Path to the strategy ZIP file (must contain fep.yaml)", }), visibility: Type.Optional( Type.Unsafe<"public" | "private" | "unlisted">({ type: "string", enum: ["public", "private", "unlisted"], description: "Override visibility from fep.yaml", }), ), }), execute: withLogging(getDb, "skill_publish", "strategy", async (_toolCallId, params) => { try { const filePath = String(params.filePath ?? "").trim(); if (!filePath) return json({ success: false, error: "filePath is required" }); if (!config.apiKey) { return json({ success: false, error: NO_API_KEY, }); } const resolvedPath = api.resolvePath(filePath); const buf = await readFile(resolvedPath); const base64Content = buf.toString("base64"); const body: Record = { content: base64Content }; if ( params.visibility === "public" || params.visibility === "private" || params.visibility === "unlisted" ) { body.visibility = params.visibility; } const { status, data } = await hubApiRequest(config, "POST", "/skill/publish", { body, }); if (status >= 200 && status < 300) { const resp = data as { slug?: string; entryId?: string; version?: string; status?: string; message?: string; submissionId?: string; backtestTaskId?: string | null; backtestStatus?: string | null; creditsEarned?: { action?: string; amount?: number; message?: string }; }; // Persist strategy + pending backtest result const now = new Date().toISOString(); const strategyId = resp.entryId ?? resp.slug ?? randomUUID(); const hubVersion = Number(resp.version) || 1; upsertStrategy(getDb(), { id: strategyId, name: resp.slug ?? filePath, status: "published", level: resp.backtestTaskId ? "L1" : "L0", version: hubVersion, created_at: now, updated_at: now, promoted_at: now, }); if (resp.backtestTaskId) { const backtestId = randomUUID(); insertBacktestResult(getDb(), { id: backtestId, strategy_id: strategyId, remote_task_id: resp.backtestTaskId, status: "pending", submitted_at: now, created_at: now, }); upsertStrategy(getDb(), { id: strategyId, name: resp.slug ?? filePath, status: "published", level: "L1", version: hubVersion, created_at: now, updated_at: now, promoted_at: now, last_backtest_id: backtestId, }); } insertAgentEvent(getDb(), { id: randomUUID(), type: "publish", title: `策略发布: ${resp.slug ?? "(未知)"}`, detail: `Entry ID: ${resp.entryId ?? "—"} | Backtest: ${resp.backtestTaskId ?? "—"}`, timestamp: now, status: "info", category: "strategy", severity: "low", strategy_id: strategyId, narration: `策略 ${resp.slug ?? filePath} 已成功发布到 Hub`, }); const lines: string[] = []; lines.push("Skill 发布成功!"); lines.push(""); lines.push(`- Slug: ${resp.slug ?? "(未知)"}`); lines.push(`- Entry ID: ${resp.entryId ?? "(未知)"}`); lines.push(`- Version: ${resp.version ?? "(未知)"}`); if (resp.backtestTaskId) lines.push(`- Backtest Task ID: ${resp.backtestTaskId}`); if (resp.creditsEarned?.amount) lines.push(`- 获得 ${resp.creditsEarned.amount} FC`); lines.push(""); lines.push("使用 skill_publish_verify 工具查询回测状态和获取完整报告。"); return { content: [{ type: "text" as const, text: lines.join("\n") }], details: { success: true, ...resp }, }; } return json({ success: false, status, error: (data as { code?: string; message?: string })?.message ?? (data as { detail?: string })?.detail ?? data, }); } catch (err) { return json({ success: false, error: err instanceof Error ? err.message : String(err), }); } }), }, { names: ["skill_publish"] }, ); // ── skill_publish_verify ── api.registerTool( { name: "skill_publish_verify", label: "Verify skill publish result", description: "Check publish and backtest status by submissionId or backtestTaskId. Returns full backtest report when completed.", parameters: Type.Object({ submissionId: Type.Optional( Type.String({ description: "Submission ID from skill_publish response" }), ), backtestTaskId: Type.Optional( Type.String({ description: "Backtest task ID from skill_publish response" }), ), }), execute: withLogging( getDb, "skill_publish_verify", "strategy", async (_toolCallId, params) => { try { const submissionId = String(params.submissionId ?? "").trim() || undefined; const backtestTaskId = String(params.backtestTaskId ?? "").trim() || undefined; if (!submissionId && !backtestTaskId) { return json({ success: false, error: "Either submissionId or backtestTaskId is required", }); } if (!config.apiKey) { return json({ success: false, error: NO_API_KEY, }); } const searchParams: Record = {}; if (submissionId) searchParams.submissionId = submissionId; if (backtestTaskId) searchParams.backtestTaskId = backtestTaskId; const { status, data } = await hubApiRequest(config, "GET", "/skill/publish/verify", { searchParams, }); if (status >= 200 && status < 300) { const resp = data as Record; // Update backtest result metrics and strategy level const verifyStrategyId = resp.entryId as string | undefined; if (resp.backtestStatus === "completed" && backtestTaskId) { const report = resp.backtestReport as Record | undefined; const perf = report?.performance as Record | undefined; updateBacktestResult(getDb(), backtestTaskId, { status: "completed", total_return: typeof perf?.totalReturn === "number" ? perf.totalReturn : undefined, sharpe: typeof perf?.sharpe === "number" ? perf.sharpe : undefined, sortino: typeof perf?.sortino === "number" ? perf.sortino : undefined, max_drawdown: typeof perf?.maxDrawdown === "number" ? perf.maxDrawdown : undefined, win_rate: typeof perf?.winRate === "number" ? perf.winRate : undefined, profit_factor: typeof perf?.profitFactor === "number" ? perf.profitFactor : undefined, total_trades: typeof perf?.totalTrades === "number" ? perf.totalTrades : undefined, final_equity: typeof perf?.finalEquity === "number" ? perf.finalEquity : undefined, equity_curve: Array.isArray(report?.equity_curve) ? JSON.stringify(report.equity_curve) : undefined, trade_journal: Array.isArray(report?.trade_journal) ? JSON.stringify(report.trade_journal) : undefined, monthly_returns: report?.monthly_returns && typeof report.monthly_returns === "object" ? JSON.stringify(report.monthly_returns) : undefined, tearsheet_html: typeof report?.tearsheet_html === "string" ? report.tearsheet_html : undefined, completed_at: new Date().toISOString(), }); // Backtest completed → strategy stays at L1 if (verifyStrategyId) updateStrategyLevel(getDb(), verifyStrategyId, "L1"); } else if (resp.backtestStatus === "failed" && backtestTaskId) { updateBacktestResult(getDb(), backtestTaskId, { status: "failed", completed_at: new Date().toISOString(), }); // Backtest failed → revert to L0 if (verifyStrategyId) updateStrategyLevel(getDb(), verifyStrategyId, "L0"); } const lines: string[] = []; lines.push("发布验证结果:"); lines.push(`- Slug: ${resp.slug ?? "(未知)"}`); lines.push(`- Version: ${resp.version ?? "(未知)"}`); lines.push(`- Backtest Status: ${resp.backtestStatus ?? "(未知)"}`); if (resp.backtestStatus === "completed" && resp.backtestReport) { const perf = (resp.backtestReport as Record).performance as | Record | undefined; if (perf) { lines.push(""); lines.push("回测报告摘要:"); if (typeof perf.totalReturn === "number") lines.push(`- 总收益率: ${(perf.totalReturn * 100).toFixed(2)}%`); if (typeof perf.sharpe === "number") lines.push(`- 夏普比率: ${perf.sharpe.toFixed(3)}`); if (typeof perf.maxDrawdown === "number") lines.push(`- 最大回撤: ${(perf.maxDrawdown * 100).toFixed(2)}%`); if (typeof perf.winRate === "number") lines.push(`- 胜率: ${(perf.winRate * 100).toFixed(1)}%`); } } return { content: [{ type: "text" as const, text: lines.join("\n") }], details: { success: true, ...resp }, }; } return json({ success: false, status, error: (data as { code?: string; message?: string })?.message ?? (data as { detail?: string })?.detail ?? data, }); } catch (err) { return json({ success: false, error: err instanceof Error ? err.message : String(err), }); } }, ), }, { names: ["skill_publish_verify"] }, ); // ── skill_validate ── api.registerTool( { name: "skill_validate", label: "Validate strategy package (FEP v2.0)", description: "Validate a strategy package directory per FEP v2.0 before zipping and publishing.", parameters: Type.Object({ dirPath: Type.String({ description: "Path to strategy package directory (must contain fep.yaml)", }), }), execute: withLogging(getDb, "skill_validate", "strategy", async (_toolCallId, params) => { if (!config.apiKey) return json({ success: false, error: NO_API_KEY }); try { const dirPath = String(params.dirPath ?? "").trim(); if (!dirPath) return json({ success: false, valid: false, errors: ["dirPath is required"] }); const resolved = api.resolvePath(dirPath); const result = await validateStrategyPackage(resolved); return json({ success: result.valid, valid: result.valid, errors: result.errors, warnings: result.warnings, }); } catch (err) { return json({ success: false, valid: false, errors: [err instanceof Error ? err.message : String(err)], }); } }), }, { names: ["skill_validate"] }, ); // ── skill_leaderboard ── api.registerTool( { name: "skill_leaderboard", label: "Get Hub leaderboard", description: "Query strategy leaderboard from hub.openfinclaw.ai.", parameters: Type.Object({ boardType: Type.Optional( Type.Unsafe({ type: "string", enum: ["composite", "returns", "risk", "popular", "rising"], description: "Leaderboard type: composite (default), returns, risk, popular, rising", }), ), limit: Type.Optional( Type.Number({ description: "Number of results (max 100, default 20)" }), ), offset: Type.Optional(Type.Number({ description: "Offset for pagination" })), }), execute: withLogging(getDb, "skill_leaderboard", "strategy", async (_toolCallId, params) => { if (!config.apiKey) return json({ success: false, error: NO_API_KEY }); try { const boardType = (params.boardType as BoardType) || "composite"; const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 100); const offset = Math.max(Number(params.offset) || 0, 0); const url = new URL(`${config.hubApiUrl}/api/v1/skill/leaderboard/${boardType}`); url.searchParams.set("limit", String(limit)); url.searchParams.set("offset", String(offset)); const response = await fetch(url.toString(), { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${config.apiKey}` }, signal: AbortSignal.timeout(config.requestTimeoutMs), }); const rawText = await response.text(); let data: unknown; if (rawText && rawText.trim().startsWith("{")) { try { data = JSON.parse(rawText); } catch { data = { raw: rawText }; } } if (response.status < 200 || response.status >= 300) { const errorData = data as { error?: { message?: string }; message?: string }; return json({ success: false, error: errorData.error?.message ?? errorData.message ?? `HTTP ${response.status}`, }); } const leaderboard = data as LeaderboardResponse; const boardNames: Record = { composite: "综合榜", returns: "收益榜", risk: "风控榜", popular: "人气榜", rising: "新星榜", }; const lines: string[] = []; lines.push( `${boardNames[boardType] || boardType} Top ${leaderboard.strategies.length} (共 ${leaderboard.total} 个策略):`, ); lines.push(""); for (const s of leaderboard.strategies) { const perf = s.performance || {}; const returnStr = typeof perf.returnSincePublish === "number" ? `收益: ${(perf.returnSincePublish * 100).toFixed(1)}%` : "收益: --"; const sharpeStr = typeof perf.sharpeRatio === "number" ? `夏普: ${perf.sharpeRatio.toFixed(2)}` : "夏普: --"; const author = s.author?.displayName || "未知"; const hubUrl = `https://hub.openfinclaw.ai/strategy/${s.id}`; lines.push( `#${String(s.rank).padStart(2)} [${s.name}](${hubUrl}) ${returnStr} ${sharpeStr} 作者: ${author}`, ); } lines.push(""); lines.push("使用 skill_get_info 查看策略详情"); lines.push("使用 skill_fork 下载策略到本地"); return { content: [{ type: "text" as const, text: lines.join("\n") }], details: { success: true, ...leaderboard }, }; } catch (err) { return json({ success: false, error: err instanceof Error ? err.message : String(err), }); } }), }, { names: ["skill_leaderboard"] }, ); // ── skill_fork ── api.registerTool( { name: "skill_fork", label: "Fork strategy from Hub", description: "Fork a public strategy from hub.openfinclaw.ai to local directory. Requires API key.", parameters: Type.Object({ strategyId: Type.String({ description: "Strategy ID from Hub (UUID or Hub URL)", }), name: Type.Optional(Type.String({ description: "Name for the forked strategy" })), targetDir: Type.Optional(Type.String({ description: "Custom target directory" })), }), execute: withLogging( getDb, "skill_fork", "strategy", async (_toolCallId, params) => { try { const strategyId = String(params.strategyId ?? "").trim(); if (!strategyId) return json({ success: false, error: "strategyId is required" }); if (!config.apiKey) { return json({ success: false, error: "API key is required for fork operation.", }); } const result = await forkStrategy(config, strategyId, { name: params.name ? String(params.name) : undefined, targetDir: params.targetDir ? String(params.targetDir) : undefined, }); if (result.success) { // Persist forked strategy to DB const now = new Date().toISOString(); const localId = result.forkEntryId ?? strategyId; upsertStrategy(getDb(), { id: localId, name: result.sourceName ?? (params.name ? String(params.name) : strategyId), template_id: result.sourceId, status: "forked", level: "L0", version: Number(result.sourceVersion) || 1, created_at: now, updated_at: now, }); insertAgentEvent(getDb(), { id: randomUUID(), type: "fork", title: `Fork 策略: ${result.sourceName ?? strategyId}`, detail: `本地路径: ${result.localPath}`, timestamp: now, status: "info", category: "strategy", severity: "low", strategy_id: localId, narration: `已将策略 ${result.sourceName ?? strategyId} Fork 到本地`, }); const lines: string[] = []; lines.push("策略 Fork 成功!"); lines.push(`- 原策略: ${result.sourceName} (${result.sourceId})`); lines.push(`- 本地路径: ${result.localPath}`); lines.push(""); lines.push("下一步:"); lines.push(`- 编辑策略: code ${result.localPath}/scripts/strategy.py`); return { content: [{ type: "text" as const, text: lines.join("\n") }], details: result, }; } return json({ success: false, error: result.error ?? "Failed to fork strategy" }); } catch (err) { return json({ success: false, error: err instanceof Error ? err.message : String(err), }); } }, { strategyIdParam: "strategyId" }, ), }, { names: ["skill_fork"] }, ); // ── skill_list_local ── api.registerTool( { name: "skill_list_local", label: "List local strategies", description: "List all strategies downloaded or created locally, organized by date.", parameters: Type.Object({}), execute: withLogging(getDb, "skill_list_local", "strategy", async () => { if (!config.apiKey) return json({ success: false, error: NO_API_KEY }); try { const strategies = await listLocalStrategies(); if (strategies.length === 0) { return { content: [ { type: "text" as const, text: "本地暂无策略。\n\n使用 skill_fork 从 Hub 下载策略。", }, ], details: { success: true, strategies: [] }, }; } const lines: string[] = []; lines.push(`本地策略列表 (共 ${strategies.length} 个):`); let currentDate = ""; for (const s of strategies) { if (s.dateDir !== currentDate) { currentDate = s.dateDir; lines.push(`${s.dateDir}/`); } const typeLabel = s.type === "forked" ? "(forked)" : "(created)"; lines.push(` ${s.name} ${typeLabel}`); } return { content: [{ type: "text" as const, text: lines.join("\n") }], details: { success: true, strategies }, }; } catch (err) { return json({ success: false, error: err instanceof Error ? err.message : String(err), }); } }), }, { names: ["skill_list_local"] }, ); // ── skill_get_info ── api.registerTool( { name: "skill_get_info", label: "Get strategy info from Hub", description: "Fetch detailed information about a strategy from hub.openfinclaw.ai.", parameters: Type.Object({ strategyId: Type.String({ description: "Strategy ID from Hub (UUID or Hub URL)" }), }), execute: withLogging( getDb, "skill_get_info", "strategy", async (_toolCallId, params) => { if (!config.apiKey) return json({ success: false, error: NO_API_KEY }); try { const strategyId = String(params.strategyId ?? "").trim(); if (!strategyId) return json({ success: false, error: "strategyId is required" }); const result = await fetchStrategyInfo(config, strategyId); if (result.success && result.data) { const info = result.data; const lines: string[] = []; lines.push("策略信息:"); lines.push(`- ID: ${info.id}`); lines.push(`- 名称: ${info.name}`); if (info.author?.displayName) lines.push(`- 作者: ${info.author.displayName}`); if (info.backtestResult) { lines.push(""); lines.push("绩效指标:"); if (typeof info.backtestResult.totalReturn === "number") lines.push(`- 总收益率: ${info.backtestResult.totalReturn.toFixed(2)}%`); if (typeof info.backtestResult.sharpe === "number") lines.push(`- 夏普比率: ${info.backtestResult.sharpe.toFixed(3)}`); } lines.push(""); lines.push(`Hub URL: https://hub.openfinclaw.ai/strategy/${info.id}`); return { content: [{ type: "text" as const, text: lines.join("\n") }], details: { success: true, ...info }, }; } return json({ success: false, error: result.error ?? "Failed to fetch strategy info", }); } catch (err) { return json({ success: false, error: err instanceof Error ? err.message : String(err), }); } }, { strategyIdParam: "strategyId" }, ), }, { names: ["skill_get_info"] }, ); }