/** * DataHub market data tools registration. * Tools: fin_price, fin_kline, fin_crypto, fin_compare, fin_slim_search */ import type { DatabaseSync } from "node:sqlite"; import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { withLogging } from "../middleware/with-logging.js"; import type { UnifiedPluginConfig, MarketType } from "../types.js"; import { DataHubClient, guessMarket } from "./client.js"; /** JSON tool result helper. */ function json(payload: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], details: payload, }; } /** Helper to pick params. */ function pick(params: Record, ...keys: string[]): Record { const out: Record = {}; for (const k of keys) { if (params[k] != null) out[k] = String(params[k]); } return out; } const NO_KEY = "API key not configured. Set apiKey in plugin config or OPENFINCLAW_API_KEY env var."; /** * Register DataHub market data tools. * @param getDb - Lazy database getter (called at execution time, not registration). */ export function registerDatahubTools( api: OpenClawPluginApi, config: UnifiedPluginConfig, getDb: () => DatabaseSync, ): void { const datahubClient = config.apiKey ? new DataHubClient(config.datahubGatewayUrl, config.apiKey, config.requestTimeoutMs) : null; // Register fin-data-provider service for other plugins if (datahubClient) { api.registerService({ id: "fin-data-provider", start: () => {}, instance: { async getOHLCV(params: { symbol: string; market?: string; timeframe?: string; limit?: number; }) { const market = (params.market as MarketType) ?? guessMarket(params.symbol); return datahubClient.getOHLCV({ symbol: params.symbol, market, limit: params.limit ?? 300, }); }, async getTicker(symbol: string, market?: string) { const m = (market as MarketType) ?? guessMarket(symbol); return datahubClient.getTicker(symbol, m); }, }, } as Parameters[0]); } // ── fin_price — Price Lookup ── api.registerTool( { name: "fin_price", label: "Price Lookup", description: "Get the current/latest price for any asset — stocks (A/HK/US), crypto, index. " + "Returns latest close, volume, and date. The simplest way to answer 'XX 现在什么价格'.", parameters: Type.Object({ symbol: Type.String({ description: "Asset symbol. Crypto: BTC/USDT, ETH/USDT; A-share: 600519.SH; HK: 00700.HK; US: AAPL; Index: 000300.SH", }), market: Type.Optional( Type.Unsafe<"crypto" | "equity">({ type: "string", enum: ["crypto", "equity"], description: "Market type. Auto-detected if omitted: symbols with .SH/.SZ/.HK or pure letters → equity; contains '/' → crypto.", }), ), }), execute: withLogging(getDb, "fin_price", "market-data", async (_id, params) => { try { if (!datahubClient) return json({ error: NO_KEY }); const symbol = String(params.symbol); const market = (params.market as MarketType) ?? guessMarket(symbol); const ticker = await datahubClient.getTicker(symbol, market); return json({ symbol: ticker.symbol, market: ticker.market, price: ticker.last, volume24h: ticker.volume24h, timestamp: new Date(ticker.timestamp).toISOString(), }); } catch (err) { return json({ error: err instanceof Error ? err.message : String(err) }); } }), }, { names: ["fin_price"] }, ); // ── fin_kline — K-Line / OHLCV ── api.registerTool( { name: "fin_kline", label: "K-Line / OHLCV", description: "Fetch historical OHLCV (candlestick) data for any asset. " + "Use for price history, charting, and trend analysis.", parameters: Type.Object({ symbol: Type.String({ description: "Asset symbol (BTC/USDT, 600519.SH, AAPL, etc.)", }), market: Type.Optional( Type.Unsafe<"crypto" | "equity">({ type: "string", enum: ["crypto", "equity"], description: "Market type (auto-detected if omitted)", }), ), limit: Type.Optional( Type.Number({ description: "Number of bars to return (default: 30)" }), ), }), execute: withLogging(getDb, "fin_kline", "market-data", async (_id, params) => { try { if (!datahubClient) return json({ error: NO_KEY }); const symbol = String(params.symbol); const market = (params.market as MarketType) ?? guessMarket(symbol); const limit = (params.limit as number) ?? 30; const ohlcv = await datahubClient.getOHLCV({ symbol, market, limit }); return json({ symbol, market, count: ohlcv.length, bars: ohlcv.map((b) => ({ date: new Date(b.timestamp).toISOString().slice(0, 10), open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume, })), }); } catch (err) { return json({ error: err instanceof Error ? err.message : String(err) }); } }), }, { names: ["fin_kline"] }, ); // ── fin_crypto — Crypto & DeFi ── api.registerTool( { name: "fin_crypto", label: "Crypto & DeFi", description: "Crypto market data (ticker/orderbook/trades/funding_rate) via CEX, " + "DeFi (protocols/TVL/yields/stablecoins/fees/dex_volumes) via DefiLlama, " + "market metrics (coin/market/info/categories/trending/global_stats) via CoinGecko.", parameters: Type.Object({ endpoint: Type.Unsafe({ type: "string", enum: [ "market/ticker", "market/tickers", "market/orderbook", "market/trades", "market/funding_rate", "coin/market", "coin/historical", "coin/info", "coin/categories", "coin/trending", "coin/global_stats", "defi/protocols", "defi/tvl_historical", "defi/protocol_tvl", "defi/chains", "defi/yields", "defi/stablecoins", "defi/fees", "defi/dex_volumes", "defi/bridges", "defi/coin_prices", "price/historical", "search", ], description: "DataHub crypto endpoint path", }), symbol: Type.Optional( Type.String({ description: "Coin ID, trading pair, or protocol slug" }), ), start_date: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })), end_date: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })), limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })), }), execute: withLogging(getDb, "fin_crypto", "market-data", async (_id, params) => { try { if (!datahubClient) return json({ error: NO_KEY }); const endpoint = String(params.endpoint ?? "coin/market"); const qp = pick(params, "symbol", "start_date", "end_date", "limit"); if (!qp.limit) qp.limit = "20"; if (qp.symbol) { const coinIdEndpoints = ["coin/historical", "coin/info"]; if (coinIdEndpoints.includes(endpoint)) { qp.coin_id = qp.symbol; delete qp.symbol; } else if (endpoint === "defi/protocol_tvl") { qp.protocol = qp.symbol; delete qp.symbol; } else if (endpoint === "defi/coin_prices") { qp.coins = qp.symbol; delete qp.symbol; } } const results = await datahubClient.crypto(endpoint, qp); return json({ success: true, endpoint: `crypto/${endpoint}`, count: results.length, results, }); } catch (err) { return json({ error: err instanceof Error ? err.message : String(err) }); } }), }, { names: ["fin_crypto"] }, ); // ── fin_compare — Price Compare ── api.registerTool( { name: "fin_compare", label: "Price Compare", description: "Compare prices of 2-5 assets side by side. Returns latest price and recent change for each. " + "Use for cross-asset comparison questions like 'BTC vs ETH vs 黄金'.", parameters: Type.Object({ symbols: Type.String({ description: "Comma-separated symbols (2-5). Example: BTC/USDT,ETH/USDT,600519.SH", }), }), execute: withLogging(getDb, "fin_compare", "market-data", async (_id, params) => { try { if (!datahubClient) return json({ error: NO_KEY }); const raw = String(params.symbols); const symbols = raw .split(",") .map((s) => s.trim()) .filter(Boolean) .slice(0, 5); if (symbols.length < 2) return json({ error: "Need at least 2 symbols, comma-separated" }); const results = await Promise.allSettled( symbols.map(async (sym) => { const market = guessMarket(sym); const ticker = await datahubClient!.getTicker(sym, market); const bars = await datahubClient!.getOHLCV({ symbol: sym, market, limit: 7 }); const weekAgo = bars.length > 0 ? bars[0]!.close : ticker.last; const weekChange = weekAgo > 0 ? ((ticker.last - weekAgo) / weekAgo) * 100 : 0; return { symbol: sym, market, price: ticker.last, weekChange: parseFloat(weekChange.toFixed(2)), }; }), ); return json({ comparison: results.map((r, i) => r.status === "fulfilled" ? r.value : { symbol: symbols[i], error: (r.reason as Error).message }, ), }); } catch (err) { return json({ error: err instanceof Error ? err.message : String(err) }); } }), }, { names: ["fin_compare"] }, ); // ── fin_slim_search — Symbol Search ── api.registerTool( { name: "fin_slim_search", label: "Symbol Search", description: "Search for stock/crypto symbols by name or keyword. " + "Use when user mentions a company/coin name but not the exact symbol.", parameters: Type.Object({ query: Type.String({ description: "Search keyword (e.g. '茅台', 'bitcoin', 'Tesla')" }), market: Type.Optional( Type.Unsafe<"crypto" | "equity">({ type: "string", enum: ["crypto", "equity"], description: "Limit search to market type", }), ), }), execute: withLogging(getDb, "fin_slim_search", "market-data", async (_id, params) => { try { if (!datahubClient) return json({ error: NO_KEY }); const q = String(params.query); const market = params.market as string | undefined; const results: unknown[] = []; if (!market || market === "crypto") { try { const crypto = await datahubClient!.crypto("search", { query: q, limit: "5" }); results.push(...crypto.map((r) => ({ ...(r as object), market: "crypto" }))); } catch { /* ignore */ } } if (!market || market === "equity") { try { const equity = await datahubClient!.equity("search", { query: q, limit: "5" }); results.push(...equity.map((r) => ({ ...(r as object), market: "equity" }))); } catch { /* ignore */ } } return json({ query: q, count: results.length, results }); } catch (err) { return json({ error: err instanceof Error ? err.message : String(err) }); } }), }, { names: ["fin_slim_search"] }, ); }