@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.
@@ -146,10 +146,10 @@ Agent:
146
146
 
147
147
  ## 注意事项
148
148
 
149
- 1. **API Key**: 某些操作需要配置 API Key
149
+ 1. **API Key**: Fork 操作需要配置 API Key
150
150
 
151
151
  ```bash
152
- openfinclaw config set plugins.entries.openfinclaw.config.skillApiKey YOUR_KEY
152
+ openclaw config set plugins.entries.openfinclaw.config.apiKey YOUR_KEY
153
153
  ```
154
154
 
155
155
  2. **同名冲突**: 如果两个策略名称相同,会自动添加短 ID 后缀区分
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: strategy-pack
3
- description: "Create and validate Findoo Backtest (FEP v2.0) strategy packages. Use when the user wants to create a strategy pack, generate fep.yaml and strategy.py, or prepare a folder for remote backtest. Always validate with backtest_remote_validate before zipping and submitting."
4
- metadata: { "openclaw": { "requires": { "extensions": ["fin-backtest-remote"] } } }
3
+ description: "Create and validate FEP v2.0 strategy packages. Use when the user wants to create a strategy pack, generate fep.yaml and strategy.py, or prepare a folder for publishing. Always validate with skill_validate before zipping and publishing."
4
+ metadata: { "openclaw": { "requires": { "extensions": ["openfinclaw"] } } }
5
5
  ---
6
6
 
7
7
  # 策略包生成与校验 (FEP v2.0)
8
8
 
9
- 当用户要**创建策略包**、**生成回测策略包**、**写 fep 策略**、**打包后提交回测**时,按以下结构生成目录和文件,并在**上传前必须用 `backtest_remote_validate` 校验**,通过后再打包为 ZIP 并提交。
9
+ 当用户要**创建策略包**、**生成回测策略包**、**写 fep 策略**、**打包后提交发布**时,按以下结构生成目录和文件,并在**上传前必须用 `skill_validate` 校验**,通过后再打包为 ZIP 并发布。
10
10
 
11
11
  ## 何时触发
12
12
 
@@ -261,7 +261,7 @@ def select(universe):
261
261
  - `identity`: id, name, type, version, style, visibility, summary, description, license, tags, author.name, changelog (全部必填)
262
262
  - `backtest`: symbol, defaultPeriod (startDate/endDate), initialCapital (全部必填)
263
263
  3. **strategy.py:** 定义 `compute(data)` 或 `select(universe)`;返回 dict 包含正确字段;无禁止的导入/调用。
264
- 4. 调用 `backtest_remote_validate` 传入策略包目录路径 `dirPath`。若返回 `valid: false`,根据 `errors` 修正后再次校验。
264
+ 4. 调用 `skill_validate` 传入策略包目录路径 `dirPath`。若返回 `valid: false`,根据 `errors` 修正后再次校验。
265
265
  5. Auto-fix and re-validate up to 3 iterations;若仍失败,向用户清晰解释问题。
266
266
 
267
267
  **不要**在校验未通过时打包上传。
@@ -270,16 +270,16 @@ def select(universe):
270
270
 
271
271
  校验通过后,在策略包目录下执行 `zip -r ../<id>-<version>.zip fep.yaml scripts/`(例如 `fin-dca-basic-test-1.0.0.zip`),得到 ZIP 路径。
272
272
 
273
- ### Step 4: 提交
273
+ ### Step 4: 发布
274
274
 
275
- 调用 `backtest_remote_submit`,传入 ZIP 的 `filePath`(及可选 engine, budget_cap_usd)。
275
+ 调用 `skill_publish`,传入 ZIP 的 `filePath`(及可选 visibility)。
276
276
 
277
277
  ## 相关 Tools
278
278
 
279
- | Tool | 用途 |
280
- | --------------------------------------------------- | ------------------------------------------------------- |
281
- | `backtest_remote_validate` | 校验策略包目录格式是否符合 fep v2.0,通过后才可打包上传 |
282
- | `backtest_remote_submit` | 提交已打包的 ZIP 到远程回测服务 |
283
- | `backtest_remote_status` / `backtest_remote_report` | 查询任务状态与报告 |
279
+ | Tool | 用途 |
280
+ | ---------------------- | ------------------------------------- |
281
+ | `skill_validate` | 校验策略包目录格式是否符合 FEP v2.0 |
282
+ | `skill_publish` | 提交已打包的 ZIP 到 Hub,自动触发回测 |
283
+ | `skill_publish_verify` | 查询发布状态与回测报告 |
284
284
 
285
- 总结:**先按本 skill 生成/补全策略包 → 用 backtest_remote_validate 校验 → 通过后再打包并 backtest_remote_submit**。
285
+ 总结:**先按本 skill 生成/补全策略包 → 用 skill_validate 校验 → 通过后再打包并 skill_publish**。
package/src/cli.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  * CLI commands for strategy management.
3
3
  */
4
4
  import type { Command } from "commander";
5
- import { forkStrategy, fetchStrategyInfo } from "./fork.js";
6
- import { listLocalStrategies, findLocalStrategy, removeLocalStrategy } from "./strategy-storage.js";
7
- import type { SkillApiConfig, LeaderboardResponse, BoardType } from "./types.js";
5
+ import { forkStrategy, fetchStrategyInfo } from "./strategy/fork.js";
6
+ import { listLocalStrategies, findLocalStrategy, removeLocalStrategy } from "./strategy/storage.js";
7
+ import type { UnifiedPluginConfig, LeaderboardResponse, BoardType } from "./types.js";
8
8
 
9
9
  type Logger = {
10
10
  info: (message: string) => void;
@@ -14,7 +14,7 @@ type Logger = {
14
14
 
15
15
  export function registerStrategyCli(params: {
16
16
  program: Command;
17
- config: SkillApiConfig;
17
+ config: UnifiedPluginConfig;
18
18
  logger: Logger;
19
19
  }) {
20
20
  const { program, config } = params;
@@ -34,7 +34,7 @@ export function registerStrategyCli(params: {
34
34
  const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 100);
35
35
  const offset = Math.max(Number(options.offset) || 0, 0);
36
36
 
37
- const url = new URL(`${config.baseUrl}/api/v1/skill/leaderboard/${boardType}`);
37
+ const url = new URL(`${config.hubApiUrl}/api/v1/skill/leaderboard/${boardType}`);
38
38
  url.searchParams.set("limit", String(limit));
39
39
  url.searchParams.set("offset", String(offset));
40
40
 
package/src/config.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Unified configuration resolver for OpenFinClaw plugin.
3
+ * Supports single API key for both Hub and DataHub services.
4
+ */
5
+ import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
6
+ import type { UnifiedPluginConfig } from "./types.js";
7
+
8
+ const DEFAULT_HUB_API_URL = "https://hub.openfinclaw.ai";
9
+ const DEFAULT_DATAHUB_GATEWAY_URL = "http://43.134.61.136:9080";
10
+ const DEFAULT_TIMEOUT_MS = 60_000;
11
+
12
+ function readEnv(keys: string[]): string | undefined {
13
+ for (const key of keys) {
14
+ const value = process.env[key]?.trim();
15
+ if (value) return value;
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ /**
21
+ * Resolve unified plugin configuration from plugin config and environment variables.
22
+ * Priority: plugin config > env var > default
23
+ */
24
+ export function resolvePluginConfig(api: OpenClawPluginApi): UnifiedPluginConfig {
25
+ const raw = api.pluginConfig as Record<string, unknown> | undefined;
26
+
27
+ const apiKey =
28
+ (typeof raw?.apiKey === "string" ? raw.apiKey : undefined) ??
29
+ (typeof raw?.skillApiKey === "string" ? raw.skillApiKey : undefined) ??
30
+ (typeof raw?.datahubApiKey === "string" ? raw.datahubApiKey : undefined) ??
31
+ readEnv(["OPENFINCLAW_API_KEY", "SKILL_API_KEY", "DATAHUB_API_KEY"]);
32
+
33
+ const hubApiUrl =
34
+ (typeof raw?.hubApiUrl === "string" ? raw.hubApiUrl : undefined) ??
35
+ (typeof raw?.skillApiUrl === "string" ? raw.skillApiUrl : undefined) ??
36
+ readEnv(["HUB_API_URL", "SKILL_API_URL"]) ??
37
+ DEFAULT_HUB_API_URL;
38
+
39
+ const datahubGatewayUrl =
40
+ (typeof raw?.datahubGatewayUrl === "string" ? raw.datahubGatewayUrl : undefined) ??
41
+ readEnv(["DATAHUB_GATEWAY_URL", "OPENFINCLAW_DATAHUB_GATEWAY_URL"]) ??
42
+ DEFAULT_DATAHUB_GATEWAY_URL;
43
+
44
+ const timeoutRaw =
45
+ raw?.requestTimeoutMs ?? readEnv(["REQUEST_TIMEOUT_MS", "SKILL_REQUEST_TIMEOUT_MS"]);
46
+ const requestTimeoutMs =
47
+ Number(timeoutRaw) >= 5000 && Number(timeoutRaw) <= 300_000
48
+ ? Math.floor(Number(timeoutRaw))
49
+ : DEFAULT_TIMEOUT_MS;
50
+
51
+ return {
52
+ apiKey: apiKey && apiKey.length > 0 ? apiKey : undefined,
53
+ hubApiUrl: hubApiUrl.replace(/\/$/, ""),
54
+ datahubGatewayUrl: datahubGatewayUrl.replace(/\/+$/, ""),
55
+ requestTimeoutMs,
56
+ };
57
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * DataHub REST client via Gateway proxy.
3
+ * Uses Bearer token authentication (fch_<64-char-hex>).
4
+ * Gateway validates API key in Redis, then forwards to DataHub with Basic Auth.
5
+ */
6
+ import type { OHLCV, Ticker, MarketType } from "../types.js";
7
+
8
+ export class DataHubClient {
9
+ private authHeader: string;
10
+
11
+ constructor(
12
+ private gatewayUrl: string,
13
+ apiKey: string,
14
+ private timeoutMs: number,
15
+ ) {
16
+ this.authHeader = `Bearer ${apiKey}`;
17
+ }
18
+
19
+ async query(path: string, params?: Record<string, string>): Promise<unknown[]> {
20
+ const url = new URL(`${this.gatewayUrl}/api/v1/${path}`);
21
+ if (params) {
22
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
23
+ }
24
+
25
+ const resp = await fetch(url.toString(), {
26
+ headers: { Authorization: this.authHeader },
27
+ signal: AbortSignal.timeout(this.timeoutMs),
28
+ });
29
+
30
+ if (resp.status === 204) return [];
31
+ const text = await resp.text();
32
+ if (!resp.ok) throw new Error(`Gateway error (${resp.status}): ${text.slice(0, 300)}`);
33
+
34
+ let payload: { results?: unknown[]; detail?: string };
35
+ try {
36
+ payload = JSON.parse(text);
37
+ } catch {
38
+ throw new Error(`Gateway returned non-JSON (${resp.status}): ${text.slice(0, 200)}`);
39
+ }
40
+ if (payload.detail) throw new Error(`Gateway: ${payload.detail}`);
41
+ return payload.results ?? [];
42
+ }
43
+
44
+ crypto(endpoint: string, params?: Record<string, string>) {
45
+ return this.query(`crypto/${endpoint}`, params);
46
+ }
47
+
48
+ equity(endpoint: string, params?: Record<string, string>) {
49
+ return this.query(`equity/${endpoint}`, params);
50
+ }
51
+
52
+ async getOHLCV(params: {
53
+ symbol: string;
54
+ market: string;
55
+ since?: number;
56
+ limit?: number;
57
+ }): Promise<OHLCV[]> {
58
+ const qp: Record<string, string> = { symbol: params.symbol };
59
+ if (params.since) qp.start_date = new Date(params.since).toISOString().slice(0, 10);
60
+ const apiLimit = params.limit ? String(Math.min(params.limit * 2, 500)) : "100";
61
+ qp.limit = apiLimit;
62
+
63
+ const results =
64
+ params.market === "crypto"
65
+ ? await this.crypto("price/historical", { ...qp, provider: "ccxt" })
66
+ : await this.equity("price/historical", { ...qp, provider: detectProvider(params.symbol) });
67
+
68
+ return this.normalizeOHLCV(results, params.limit);
69
+ }
70
+
71
+ async getTicker(symbol: string, market: string): Promise<Ticker> {
72
+ if (market === "crypto") {
73
+ const results = await this.crypto("market/ticker", { symbol, exchange: "binance" });
74
+ const t = (results[0] ?? {}) as Record<string, unknown>;
75
+ return {
76
+ symbol,
77
+ market: "crypto",
78
+ last: Number(t.last ?? t.close ?? t.bid ?? 0),
79
+ volume24h: Number(t.baseVolume ?? t.volume ?? 0) || undefined,
80
+ timestamp: Date.now(),
81
+ };
82
+ }
83
+
84
+ const qp: Record<string, string> = {
85
+ symbol,
86
+ provider: detectProvider(symbol),
87
+ limit: "5",
88
+ };
89
+ const results = await this.equity("price/historical", qp);
90
+ const rows = (results as Array<Record<string, unknown>>).sort((a, b) => {
91
+ const da = String(a.date ?? a.trade_date ?? "");
92
+ const db = String(b.date ?? b.trade_date ?? "");
93
+ return db.localeCompare(da);
94
+ });
95
+ const last = rows[0] as Record<string, unknown> | undefined;
96
+ if (!last) throw new Error(`No ticker data for ${symbol}`);
97
+ return {
98
+ symbol,
99
+ market: "equity",
100
+ last: Number(last.close ?? 0),
101
+ volume24h: Number(last.volume ?? 0) || undefined,
102
+ timestamp: last.date ? new Date(String(last.date)).getTime() : Date.now(),
103
+ };
104
+ }
105
+
106
+ private normalizeOHLCV(results: unknown[], limit?: number): OHLCV[] {
107
+ const rows = (results as Array<Record<string, unknown>>)
108
+ .map((r) => {
109
+ const ts = r.date ?? r.trade_date ?? r.timestamp;
110
+ if (!ts) return null;
111
+ return {
112
+ timestamp: typeof ts === "number" ? ts : new Date(String(ts)).getTime(),
113
+ open: Number(r.open) || 0,
114
+ high: Number(r.high) || 0,
115
+ low: Number(r.low) || 0,
116
+ close: Number(r.close) || 0,
117
+ volume: Number(r.volume ?? r.vol) || 0,
118
+ };
119
+ })
120
+ .filter((r): r is OHLCV => r !== null)
121
+ .sort((a, b) => a.timestamp - b.timestamp);
122
+ return limit ? rows.slice(-limit) : rows;
123
+ }
124
+ }
125
+
126
+ function detectProvider(symbol: string): string {
127
+ const u = symbol.toUpperCase();
128
+ if (
129
+ u.endsWith(".SH") ||
130
+ u.endsWith(".SZ") ||
131
+ u.endsWith(".BJ") ||
132
+ u.endsWith(".HK") ||
133
+ /^\d{6}/.test(u)
134
+ )
135
+ return "tushare";
136
+ return "massive";
137
+ }
138
+
139
+ /**
140
+ * Guess market type from symbol format.
141
+ */
142
+ export function guessMarket(symbol: string): MarketType {
143
+ if (symbol.includes("/")) return "crypto";
144
+ const u = symbol.toUpperCase();
145
+ if (u.endsWith(".SH") || u.endsWith(".SZ") || u.endsWith(".BJ") || u.endsWith(".HK"))
146
+ return "equity";
147
+ if (/^\d{5,6}/.test(u)) return "equity";
148
+ if (/^[A-Z]{1,5}$/.test(u)) return "equity";
149
+ return "crypto";
150
+ }
@@ -0,0 +1,349 @@
1
+ /**
2
+ * DataHub market data tools registration.
3
+ * Tools: fin_price, fin_kline, fin_crypto, fin_compare, fin_slim_search
4
+ */
5
+ import { Type } from "@sinclair/typebox";
6
+ import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
7
+ import { DataHubClient, guessMarket } from "./client.js";
8
+ import type { UnifiedPluginConfig, MarketType } from "../types.js";
9
+
10
+ /** JSON tool result helper. */
11
+ function json(payload: unknown) {
12
+ return {
13
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
14
+ details: payload,
15
+ };
16
+ }
17
+
18
+ /** Helper to pick params. */
19
+ function pick(params: Record<string, unknown>, ...keys: string[]): Record<string, string> {
20
+ const out: Record<string, string> = {};
21
+ for (const k of keys) {
22
+ if (params[k] != null) out[k] = String(params[k]);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ const NO_KEY = "API key not configured. Set apiKey in plugin config or OPENFINCLAW_API_KEY env var.";
28
+
29
+ /**
30
+ * Register DataHub market data tools.
31
+ */
32
+ export function registerDatahubTools(
33
+ api: OpenClawPluginApi,
34
+ config: UnifiedPluginConfig,
35
+ ): void {
36
+ const datahubClient = config.apiKey
37
+ ? new DataHubClient(config.datahubGatewayUrl, config.apiKey, config.requestTimeoutMs)
38
+ : null;
39
+
40
+ // Register fin-data-provider service for other plugins
41
+ if (datahubClient) {
42
+ api.registerService({
43
+ id: "fin-data-provider",
44
+ start: () => {},
45
+ instance: {
46
+ async getOHLCV(params: {
47
+ symbol: string;
48
+ market?: string;
49
+ timeframe?: string;
50
+ limit?: number;
51
+ }) {
52
+ const market = (params.market as MarketType) ?? guessMarket(params.symbol);
53
+ return datahubClient.getOHLCV({
54
+ symbol: params.symbol,
55
+ market,
56
+ limit: params.limit ?? 300,
57
+ });
58
+ },
59
+ async getTicker(symbol: string, market?: string) {
60
+ const m = (market as MarketType) ?? guessMarket(symbol);
61
+ return datahubClient.getTicker(symbol, m);
62
+ },
63
+ },
64
+ } as Parameters<typeof api.registerService>[0]);
65
+ }
66
+
67
+ // ── fin_price — Price Lookup ──
68
+ api.registerTool(
69
+ {
70
+ name: "fin_price",
71
+ label: "Price Lookup",
72
+ description:
73
+ "Get the current/latest price for any asset — stocks (A/HK/US), crypto, index. " +
74
+ "Returns latest close, volume, and date. The simplest way to answer 'XX 现在什么价格'.",
75
+ parameters: Type.Object({
76
+ symbol: Type.String({
77
+ description:
78
+ "Asset symbol. Crypto: BTC/USDT, ETH/USDT; A-share: 600519.SH; HK: 00700.HK; US: AAPL; Index: 000300.SH",
79
+ }),
80
+ market: Type.Optional(
81
+ Type.Unsafe<"crypto" | "equity">({
82
+ type: "string",
83
+ enum: ["crypto", "equity"],
84
+ description:
85
+ "Market type. Auto-detected if omitted: symbols with .SH/.SZ/.HK or pure letters → equity; contains '/' → crypto.",
86
+ }),
87
+ ),
88
+ }),
89
+ async execute(_id: string, params: Record<string, unknown>) {
90
+ try {
91
+ if (!datahubClient) return json({ error: NO_KEY });
92
+ const symbol = String(params.symbol);
93
+ const market = (params.market as MarketType) ?? guessMarket(symbol);
94
+ const ticker = await datahubClient.getTicker(symbol, market);
95
+ return json({
96
+ symbol: ticker.symbol,
97
+ market: ticker.market,
98
+ price: ticker.last,
99
+ volume24h: ticker.volume24h,
100
+ timestamp: new Date(ticker.timestamp).toISOString(),
101
+ });
102
+ } catch (err) {
103
+ return json({ error: err instanceof Error ? err.message : String(err) });
104
+ }
105
+ },
106
+ },
107
+ { names: ["fin_price"] },
108
+ );
109
+
110
+ // ── fin_kline — K-Line / OHLCV ──
111
+ api.registerTool(
112
+ {
113
+ name: "fin_kline",
114
+ label: "K-Line / OHLCV",
115
+ description:
116
+ "Fetch historical OHLCV (candlestick) data for any asset. " +
117
+ "Use for price history, charting, and trend analysis.",
118
+ parameters: Type.Object({
119
+ symbol: Type.String({
120
+ description: "Asset symbol (BTC/USDT, 600519.SH, AAPL, etc.)",
121
+ }),
122
+ market: Type.Optional(
123
+ Type.Unsafe<"crypto" | "equity">({
124
+ type: "string",
125
+ enum: ["crypto", "equity"],
126
+ description: "Market type (auto-detected if omitted)",
127
+ }),
128
+ ),
129
+ limit: Type.Optional(
130
+ Type.Number({ description: "Number of bars to return (default: 30)" }),
131
+ ),
132
+ }),
133
+ async execute(_id: string, params: Record<string, unknown>) {
134
+ try {
135
+ if (!datahubClient) return json({ error: NO_KEY });
136
+ const symbol = String(params.symbol);
137
+ const market = (params.market as MarketType) ?? guessMarket(symbol);
138
+ const limit = (params.limit as number) ?? 30;
139
+ const ohlcv = await datahubClient.getOHLCV({ symbol, market, limit });
140
+ return json({
141
+ symbol,
142
+ market,
143
+ count: ohlcv.length,
144
+ bars: ohlcv.map((b) => ({
145
+ date: new Date(b.timestamp).toISOString().slice(0, 10),
146
+ open: b.open,
147
+ high: b.high,
148
+ low: b.low,
149
+ close: b.close,
150
+ volume: b.volume,
151
+ })),
152
+ });
153
+ } catch (err) {
154
+ return json({ error: err instanceof Error ? err.message : String(err) });
155
+ }
156
+ },
157
+ },
158
+ { names: ["fin_kline"] },
159
+ );
160
+
161
+ // ── fin_crypto — Crypto & DeFi ──
162
+ api.registerTool(
163
+ {
164
+ name: "fin_crypto",
165
+ label: "Crypto & DeFi",
166
+ description:
167
+ "Crypto market data (ticker/orderbook/trades/funding_rate) via CEX, " +
168
+ "DeFi (protocols/TVL/yields/stablecoins/fees/dex_volumes) via DefiLlama, " +
169
+ "market metrics (coin/market/info/categories/trending/global_stats) via CoinGecko.",
170
+ parameters: Type.Object({
171
+ endpoint: Type.Unsafe<string>({
172
+ type: "string",
173
+ enum: [
174
+ "market/ticker",
175
+ "market/tickers",
176
+ "market/orderbook",
177
+ "market/trades",
178
+ "market/funding_rate",
179
+ "coin/market",
180
+ "coin/historical",
181
+ "coin/info",
182
+ "coin/categories",
183
+ "coin/trending",
184
+ "coin/global_stats",
185
+ "defi/protocols",
186
+ "defi/tvl_historical",
187
+ "defi/protocol_tvl",
188
+ "defi/chains",
189
+ "defi/yields",
190
+ "defi/stablecoins",
191
+ "defi/fees",
192
+ "defi/dex_volumes",
193
+ "defi/bridges",
194
+ "defi/coin_prices",
195
+ "price/historical",
196
+ "search",
197
+ ],
198
+ description: "DataHub crypto endpoint path",
199
+ }),
200
+ symbol: Type.Optional(
201
+ Type.String({ description: "Coin ID, trading pair, or protocol slug" }),
202
+ ),
203
+ start_date: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
204
+ end_date: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
205
+ limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })),
206
+ }),
207
+ async execute(_id: string, params: Record<string, unknown>) {
208
+ try {
209
+ if (!datahubClient) return json({ error: NO_KEY });
210
+ const endpoint = String(params.endpoint ?? "coin/market");
211
+ const qp = pick(params, "symbol", "start_date", "end_date", "limit");
212
+ if (!qp.limit) qp.limit = "20";
213
+ if (qp.symbol) {
214
+ const coinIdEndpoints = ["coin/historical", "coin/info"];
215
+ if (coinIdEndpoints.includes(endpoint)) {
216
+ qp.coin_id = qp.symbol;
217
+ delete qp.symbol;
218
+ } else if (endpoint === "defi/protocol_tvl") {
219
+ qp.protocol = qp.symbol;
220
+ delete qp.symbol;
221
+ } else if (endpoint === "defi/coin_prices") {
222
+ qp.coins = qp.symbol;
223
+ delete qp.symbol;
224
+ }
225
+ }
226
+ const results = await datahubClient.crypto(endpoint, qp);
227
+ return json({
228
+ success: true,
229
+ endpoint: `crypto/${endpoint}`,
230
+ count: results.length,
231
+ results,
232
+ });
233
+ } catch (err) {
234
+ return json({ error: err instanceof Error ? err.message : String(err) });
235
+ }
236
+ },
237
+ },
238
+ { names: ["fin_crypto"] },
239
+ );
240
+
241
+ // ── fin_compare — Price Compare ──
242
+ api.registerTool(
243
+ {
244
+ name: "fin_compare",
245
+ label: "Price Compare",
246
+ description:
247
+ "Compare prices of 2-5 assets side by side. Returns latest price and recent change for each. " +
248
+ "Use for cross-asset comparison questions like 'BTC vs ETH vs 黄金'.",
249
+ parameters: Type.Object({
250
+ symbols: Type.String({
251
+ description: "Comma-separated symbols (2-5). Example: BTC/USDT,ETH/USDT,600519.SH",
252
+ }),
253
+ }),
254
+ async execute(_id: string, params: Record<string, unknown>) {
255
+ try {
256
+ if (!datahubClient) return json({ error: NO_KEY });
257
+ const raw = String(params.symbols);
258
+ const symbols = raw
259
+ .split(",")
260
+ .map((s) => s.trim())
261
+ .filter(Boolean)
262
+ .slice(0, 5);
263
+ if (symbols.length < 2)
264
+ return json({ error: "Need at least 2 symbols, comma-separated" });
265
+
266
+ const results = await Promise.allSettled(
267
+ symbols.map(async (sym) => {
268
+ const market = guessMarket(sym);
269
+ const ticker = await datahubClient!.getTicker(sym, market);
270
+ const bars = await datahubClient!.getOHLCV({ symbol: sym, market, limit: 7 });
271
+ const weekAgo = bars.length > 0 ? bars[0]!.close : ticker.last;
272
+ const weekChange = weekAgo > 0 ? ((ticker.last - weekAgo) / weekAgo) * 100 : 0;
273
+ return {
274
+ symbol: sym,
275
+ market,
276
+ price: ticker.last,
277
+ weekChange: Math.round(weekChange * 100) / 100,
278
+ };
279
+ }),
280
+ );
281
+
282
+ return json({
283
+ comparison: results.map((r, i) =>
284
+ r.status === "fulfilled"
285
+ ? r.value
286
+ : { symbol: symbols[i], error: (r.reason as Error).message },
287
+ ),
288
+ });
289
+ } catch (err) {
290
+ return json({ error: err instanceof Error ? err.message : String(err) });
291
+ }
292
+ },
293
+ },
294
+ { names: ["fin_compare"] },
295
+ );
296
+
297
+ // ── fin_slim_search — Symbol Search ──
298
+ api.registerTool(
299
+ {
300
+ name: "fin_slim_search",
301
+ label: "Symbol Search",
302
+ description:
303
+ "Search for stock/crypto symbols by name or keyword. " +
304
+ "Use when user mentions a company/coin name but not the exact symbol.",
305
+ parameters: Type.Object({
306
+ query: Type.String({ description: "Search keyword (e.g. '茅台', 'bitcoin', 'Tesla')" }),
307
+ market: Type.Optional(
308
+ Type.Unsafe<"crypto" | "equity">({
309
+ type: "string",
310
+ enum: ["crypto", "equity"],
311
+ description: "Limit search to market type",
312
+ }),
313
+ ),
314
+ }),
315
+ async execute(_id: string, params: Record<string, unknown>) {
316
+ try {
317
+ if (!datahubClient) return json({ error: NO_KEY });
318
+ const q = String(params.query);
319
+ const market = params.market as string | undefined;
320
+
321
+ const results: unknown[] = [];
322
+
323
+ if (!market || market === "crypto") {
324
+ try {
325
+ const crypto = await datahubClient!.crypto("search", { query: q, limit: "5" });
326
+ results.push(...crypto.map((r) => ({ ...(r as object), market: "crypto" })));
327
+ } catch {
328
+ /* ignore */
329
+ }
330
+ }
331
+
332
+ if (!market || market === "equity") {
333
+ try {
334
+ const equity = await datahubClient!.equity("search", { query: q, limit: "5" });
335
+ results.push(...equity.map((r) => ({ ...(r as object), market: "equity" })));
336
+ } catch {
337
+ /* ignore */
338
+ }
339
+ }
340
+
341
+ return json({ query: q, count: results.length, results });
342
+ } catch (err) {
343
+ return json({ error: err instanceof Error ? err.message : String(err) });
344
+ }
345
+ },
346
+ },
347
+ { names: ["fin_slim_search"] },
348
+ );
349
+ }