@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
|
@@ -146,10 +146,10 @@ Agent:
|
|
|
146
146
|
|
|
147
147
|
## 注意事项
|
|
148
148
|
|
|
149
|
-
1. **API Key**:
|
|
149
|
+
1. **API Key**: Fork 操作需要配置 API Key
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
|
-
|
|
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
|
|
4
|
-
metadata: { "openclaw": { "requires": { "extensions": ["
|
|
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
|
|
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. 调用 `
|
|
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
|
-
调用 `
|
|
275
|
+
调用 `skill_publish`,传入 ZIP 的 `filePath`(及可选 visibility)。
|
|
276
276
|
|
|
277
277
|
## 相关 Tools
|
|
278
278
|
|
|
279
|
-
| Tool
|
|
280
|
-
|
|
|
281
|
-
| `
|
|
282
|
-
| `
|
|
283
|
-
| `
|
|
279
|
+
| Tool | 用途 |
|
|
280
|
+
| ---------------------- | ------------------------------------- |
|
|
281
|
+
| `skill_validate` | 校验策略包目录格式是否符合 FEP v2.0 |
|
|
282
|
+
| `skill_publish` | 提交已打包的 ZIP 到 Hub,自动触发回测 |
|
|
283
|
+
| `skill_publish_verify` | 查询发布状态与回测报告 |
|
|
284
284
|
|
|
285
|
-
总结:**先按本 skill 生成/补全策略包 → 用
|
|
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
|
|
7
|
-
import type {
|
|
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:
|
|
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.
|
|
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
|
+
}
|