@openfinclaw/fin-market-data 0.0.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/LICENSE +21 -0
- package/index.test.ts +402 -0
- package/index.ts +333 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +40 -0
- package/src/llm-finance-pipeline.live.test.ts +303 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Peter Steinberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/index.test.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import finMarketDataPlugin from "./index.js";
|
|
4
|
+
|
|
5
|
+
// --- Mock exchange instance ---
|
|
6
|
+
function createMockExchange() {
|
|
7
|
+
return {
|
|
8
|
+
fetchTicker: vi.fn(),
|
|
9
|
+
fetchOHLCV: vi.fn(),
|
|
10
|
+
fetchOrderBook: vi.fn(),
|
|
11
|
+
fetchTickers: vi.fn(),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --- Mock registry ---
|
|
16
|
+
function createMockRegistry(
|
|
17
|
+
exchanges: Array<{ id: string; exchange: string; testnet: boolean }> = [],
|
|
18
|
+
) {
|
|
19
|
+
const instances = new Map<string, ReturnType<typeof createMockExchange>>();
|
|
20
|
+
for (const ex of exchanges) {
|
|
21
|
+
instances.set(ex.id, createMockExchange());
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
listExchanges: vi.fn(() => exchanges),
|
|
25
|
+
getInstance: vi.fn(async (id: string) => {
|
|
26
|
+
const inst = instances.get(id);
|
|
27
|
+
if (!inst) throw new Error(`Exchange "${id}" not configured.`);
|
|
28
|
+
return inst;
|
|
29
|
+
}),
|
|
30
|
+
_instances: instances,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- Fake plugin API ---
|
|
35
|
+
function createFakeApi(registry: ReturnType<typeof createMockRegistry> | null): {
|
|
36
|
+
api: OpenClawPluginApi;
|
|
37
|
+
tools: Map<
|
|
38
|
+
string,
|
|
39
|
+
{ execute: (id: string, params: Record<string, unknown>) => Promise<unknown> }
|
|
40
|
+
>;
|
|
41
|
+
} {
|
|
42
|
+
const tools = new Map<
|
|
43
|
+
string,
|
|
44
|
+
{ execute: (id: string, params: Record<string, unknown>) => Promise<unknown> }
|
|
45
|
+
>();
|
|
46
|
+
const services = new Map<string, unknown>();
|
|
47
|
+
if (registry) {
|
|
48
|
+
services.set("fin-exchange-registry", registry);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const api = {
|
|
52
|
+
id: "fin-market-data",
|
|
53
|
+
name: "Market Data",
|
|
54
|
+
source: "test",
|
|
55
|
+
config: {},
|
|
56
|
+
pluginConfig: {},
|
|
57
|
+
runtime: {
|
|
58
|
+
version: "test",
|
|
59
|
+
services,
|
|
60
|
+
},
|
|
61
|
+
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
62
|
+
registerTool(tool: {
|
|
63
|
+
name: string;
|
|
64
|
+
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
65
|
+
}) {
|
|
66
|
+
tools.set(tool.name, tool);
|
|
67
|
+
},
|
|
68
|
+
registerHook() {},
|
|
69
|
+
registerHttpHandler() {},
|
|
70
|
+
registerHttpRoute() {},
|
|
71
|
+
registerChannel() {},
|
|
72
|
+
registerGatewayMethod() {},
|
|
73
|
+
registerCli() {},
|
|
74
|
+
registerService() {},
|
|
75
|
+
registerProvider() {},
|
|
76
|
+
registerCommand() {},
|
|
77
|
+
resolvePath: (p: string) => p,
|
|
78
|
+
on() {},
|
|
79
|
+
} as unknown as OpenClawPluginApi;
|
|
80
|
+
|
|
81
|
+
return { api, tools };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseResult(result: unknown): unknown {
|
|
85
|
+
const res = result as { content: Array<{ text: string }> };
|
|
86
|
+
return JSON.parse(res.content[0]!.text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("fin-market-data plugin", () => {
|
|
90
|
+
let registry: ReturnType<typeof createMockRegistry>;
|
|
91
|
+
let tools: Map<
|
|
92
|
+
string,
|
|
93
|
+
{ execute: (id: string, params: Record<string, unknown>) => Promise<unknown> }
|
|
94
|
+
>;
|
|
95
|
+
let mockExchange: ReturnType<typeof createMockExchange>;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
vi.clearAllMocks();
|
|
99
|
+
registry = createMockRegistry([{ id: "test-binance", exchange: "binance", testnet: false }]);
|
|
100
|
+
mockExchange = registry._instances.get("test-binance")!;
|
|
101
|
+
const { api, tools: t } = createFakeApi(registry);
|
|
102
|
+
tools = t;
|
|
103
|
+
finMarketDataPlugin.register(api);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("fin_market_price", () => {
|
|
107
|
+
it("returns current price and OHLCV candles", async () => {
|
|
108
|
+
mockExchange.fetchTicker.mockResolvedValue({
|
|
109
|
+
last: 67500.5,
|
|
110
|
+
change: 1200,
|
|
111
|
+
percentage: 1.81,
|
|
112
|
+
high: 68000,
|
|
113
|
+
low: 66200,
|
|
114
|
+
quoteVolume: 1_500_000_000,
|
|
115
|
+
});
|
|
116
|
+
mockExchange.fetchOHLCV.mockResolvedValue([
|
|
117
|
+
[1708819200000, 67000, 67800, 66900, 67500, 1500],
|
|
118
|
+
[1708822800000, 67500, 68000, 67200, 67800, 1200],
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const tool = tools.get("fin_market_price")!;
|
|
122
|
+
const result = parseResult(
|
|
123
|
+
await tool.execute("call-1", {
|
|
124
|
+
symbol: "BTC/USDT",
|
|
125
|
+
timeframe: "1h",
|
|
126
|
+
limit: 2,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(result).toMatchObject({
|
|
131
|
+
symbol: "BTC/USDT",
|
|
132
|
+
exchange: "test-binance",
|
|
133
|
+
price: 67500.5,
|
|
134
|
+
changePct24h: 1.81,
|
|
135
|
+
timeframe: "1h",
|
|
136
|
+
});
|
|
137
|
+
const r = result as { candles: unknown[] };
|
|
138
|
+
expect(r.candles).toHaveLength(2);
|
|
139
|
+
expect(r.candles[0]).toMatchObject({
|
|
140
|
+
open: 67000,
|
|
141
|
+
high: 67800,
|
|
142
|
+
low: 66900,
|
|
143
|
+
close: 67500,
|
|
144
|
+
volume: 1500,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(mockExchange.fetchTicker).toHaveBeenCalledWith("BTC/USDT");
|
|
148
|
+
expect(mockExchange.fetchOHLCV).toHaveBeenCalledWith("BTC/USDT", "1h", undefined, 2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("uses default exchange when not specified", async () => {
|
|
152
|
+
mockExchange.fetchTicker.mockResolvedValue({ last: 67500 });
|
|
153
|
+
mockExchange.fetchOHLCV.mockResolvedValue([]);
|
|
154
|
+
|
|
155
|
+
const tool = tools.get("fin_market_price")!;
|
|
156
|
+
await tool.execute("call-2", { symbol: "BTC/USDT" });
|
|
157
|
+
|
|
158
|
+
expect(registry.getInstance).toHaveBeenCalledWith("test-binance");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("uses explicit exchange when specified", async () => {
|
|
162
|
+
mockExchange.fetchTicker.mockResolvedValue({ last: 67500 });
|
|
163
|
+
mockExchange.fetchOHLCV.mockResolvedValue([]);
|
|
164
|
+
|
|
165
|
+
const tool = tools.get("fin_market_price")!;
|
|
166
|
+
await tool.execute("call-3", { symbol: "BTC/USDT", exchange: "test-binance" });
|
|
167
|
+
|
|
168
|
+
expect(registry.getInstance).toHaveBeenCalledWith("test-binance");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns error for CCXT failures", async () => {
|
|
172
|
+
mockExchange.fetchTicker.mockRejectedValue(new Error("Network error"));
|
|
173
|
+
|
|
174
|
+
const tool = tools.get("fin_market_price")!;
|
|
175
|
+
const result = parseResult(await tool.execute("call-4", { symbol: "BTC/USDT" }));
|
|
176
|
+
|
|
177
|
+
expect(result).toMatchObject({ error: "Network error" });
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("fin_ticker_info", () => {
|
|
182
|
+
it("returns detailed ticker info", async () => {
|
|
183
|
+
mockExchange.fetchTicker.mockResolvedValue({
|
|
184
|
+
last: 3400.25,
|
|
185
|
+
bid: 3400.0,
|
|
186
|
+
ask: 3400.5,
|
|
187
|
+
high: 3500,
|
|
188
|
+
low: 3300,
|
|
189
|
+
open: 3350,
|
|
190
|
+
close: 3400.25,
|
|
191
|
+
baseVolume: 250_000,
|
|
192
|
+
quoteVolume: 850_000_000,
|
|
193
|
+
change: 50.25,
|
|
194
|
+
percentage: 1.5,
|
|
195
|
+
vwap: 3380.5,
|
|
196
|
+
datetime: "2026-02-24T12:00:00Z",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const tool = tools.get("fin_ticker_info")!;
|
|
200
|
+
const result = parseResult(await tool.execute("call-5", { symbol: "ETH/USDT" }));
|
|
201
|
+
|
|
202
|
+
expect(result).toMatchObject({
|
|
203
|
+
symbol: "ETH/USDT",
|
|
204
|
+
exchange: "test-binance",
|
|
205
|
+
last: 3400.25,
|
|
206
|
+
bid: 3400.0,
|
|
207
|
+
ask: 3400.5,
|
|
208
|
+
high24h: 3500,
|
|
209
|
+
low24h: 3300,
|
|
210
|
+
volume24h: 250_000,
|
|
211
|
+
quoteVolume24h: 850_000_000,
|
|
212
|
+
change24h: 50.25,
|
|
213
|
+
changePct24h: 1.5,
|
|
214
|
+
vwap: 3380.5,
|
|
215
|
+
timestamp: "2026-02-24T12:00:00Z",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("returns error on auth failure", async () => {
|
|
220
|
+
mockExchange.fetchTicker.mockRejectedValue(new Error("AuthenticationError: Invalid API key"));
|
|
221
|
+
|
|
222
|
+
const tool = tools.get("fin_ticker_info")!;
|
|
223
|
+
const result = parseResult(await tool.execute("call-6", { symbol: "ETH/USDT" }));
|
|
224
|
+
|
|
225
|
+
expect(result).toMatchObject({ error: "AuthenticationError: Invalid API key" });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("fin_orderbook", () => {
|
|
230
|
+
it("returns bids, asks, and spread info", async () => {
|
|
231
|
+
mockExchange.fetchOrderBook.mockResolvedValue({
|
|
232
|
+
bids: [
|
|
233
|
+
[67500, 1.5],
|
|
234
|
+
[67490, 2.0],
|
|
235
|
+
[67480, 3.0],
|
|
236
|
+
],
|
|
237
|
+
asks: [
|
|
238
|
+
[67510, 1.2],
|
|
239
|
+
[67520, 2.5],
|
|
240
|
+
[67530, 1.8],
|
|
241
|
+
],
|
|
242
|
+
timestamp: 1708819200000,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const tool = tools.get("fin_orderbook")!;
|
|
246
|
+
const result = parseResult(
|
|
247
|
+
await tool.execute("call-7", {
|
|
248
|
+
symbol: "BTC/USDT",
|
|
249
|
+
limit: 3,
|
|
250
|
+
}),
|
|
251
|
+
) as Record<string, unknown>;
|
|
252
|
+
|
|
253
|
+
expect(result.symbol).toBe("BTC/USDT");
|
|
254
|
+
expect(result.exchange).toBe("test-binance");
|
|
255
|
+
expect(result.spread).toBe(10);
|
|
256
|
+
expect(result.spreadPct).toBeCloseTo(0.0148, 3);
|
|
257
|
+
expect((result.bids as unknown[]).length).toBe(3);
|
|
258
|
+
expect((result.asks as unknown[]).length).toBe(3);
|
|
259
|
+
expect(result.bidDepthUsd).toBeGreaterThan(0);
|
|
260
|
+
expect(result.askDepthUsd).toBeGreaterThan(0);
|
|
261
|
+
|
|
262
|
+
expect(mockExchange.fetchOrderBook).toHaveBeenCalledWith("BTC/USDT", 3);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("handles empty orderbook", async () => {
|
|
266
|
+
mockExchange.fetchOrderBook.mockResolvedValue({
|
|
267
|
+
bids: [],
|
|
268
|
+
asks: [],
|
|
269
|
+
timestamp: null,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const tool = tools.get("fin_orderbook")!;
|
|
273
|
+
const result = parseResult(
|
|
274
|
+
await tool.execute("call-8", {
|
|
275
|
+
symbol: "RARE/USDT",
|
|
276
|
+
}),
|
|
277
|
+
) as Record<string, unknown>;
|
|
278
|
+
|
|
279
|
+
expect(result.spread).toBeNull();
|
|
280
|
+
expect(result.spreadPct).toBeNull();
|
|
281
|
+
expect((result.bids as unknown[]).length).toBe(0);
|
|
282
|
+
expect((result.asks as unknown[]).length).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("fin_market_overview", () => {
|
|
287
|
+
it("returns sorted tickers with summary stats", async () => {
|
|
288
|
+
mockExchange.fetchTickers.mockResolvedValue({
|
|
289
|
+
"BTC/USDT": {
|
|
290
|
+
symbol: "BTC/USDT",
|
|
291
|
+
last: 67500,
|
|
292
|
+
change: 1200,
|
|
293
|
+
percentage: 1.8,
|
|
294
|
+
quoteVolume: 1_500_000_000,
|
|
295
|
+
high: 68000,
|
|
296
|
+
low: 66200,
|
|
297
|
+
},
|
|
298
|
+
"ETH/USDT": {
|
|
299
|
+
symbol: "ETH/USDT",
|
|
300
|
+
last: 3400,
|
|
301
|
+
change: -50,
|
|
302
|
+
percentage: -1.5,
|
|
303
|
+
quoteVolume: 800_000_000,
|
|
304
|
+
high: 3500,
|
|
305
|
+
low: 3300,
|
|
306
|
+
},
|
|
307
|
+
"SOL/USDT": {
|
|
308
|
+
symbol: "SOL/USDT",
|
|
309
|
+
last: 145,
|
|
310
|
+
change: 5,
|
|
311
|
+
percentage: 3.5,
|
|
312
|
+
quoteVolume: 200_000_000,
|
|
313
|
+
high: 150,
|
|
314
|
+
low: 140,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const tool = tools.get("fin_market_overview")!;
|
|
319
|
+
|
|
320
|
+
// Default sort by volume
|
|
321
|
+
const result = parseResult(await tool.execute("call-9", {})) as Record<string, unknown>;
|
|
322
|
+
expect(result.totalTickers).toBe(3);
|
|
323
|
+
expect((result.summary as Record<string, unknown>).gainers).toBe(2);
|
|
324
|
+
expect((result.summary as Record<string, unknown>).losers).toBe(1);
|
|
325
|
+
|
|
326
|
+
const tickers = result.tickers as Array<{ symbol: string; volume24h: number }>;
|
|
327
|
+
expect(tickers[0]!.symbol).toBe("BTC/USDT");
|
|
328
|
+
expect(tickers[0]!.volume24h).toBe(1_500_000_000);
|
|
329
|
+
|
|
330
|
+
// Sort by change
|
|
331
|
+
const changeResult = parseResult(
|
|
332
|
+
await tool.execute("call-10", { sort_by: "change" }),
|
|
333
|
+
) as Record<string, unknown>;
|
|
334
|
+
const changeTickers = changeResult.tickers as Array<{ symbol: string }>;
|
|
335
|
+
expect(changeTickers[0]!.symbol).toBe("SOL/USDT");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("respects limit parameter", async () => {
|
|
339
|
+
mockExchange.fetchTickers.mockResolvedValue({
|
|
340
|
+
"BTC/USDT": {
|
|
341
|
+
symbol: "BTC/USDT",
|
|
342
|
+
last: 67500,
|
|
343
|
+
percentage: 1.8,
|
|
344
|
+
quoteVolume: 1_500_000_000,
|
|
345
|
+
},
|
|
346
|
+
"ETH/USDT": { symbol: "ETH/USDT", last: 3400, percentage: -1.5, quoteVolume: 800_000_000 },
|
|
347
|
+
"SOL/USDT": { symbol: "SOL/USDT", last: 145, percentage: 3.5, quoteVolume: 200_000_000 },
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const tool = tools.get("fin_market_overview")!;
|
|
351
|
+
const result = parseResult(await tool.execute("call-11", { limit: 1 })) as Record<
|
|
352
|
+
string,
|
|
353
|
+
unknown
|
|
354
|
+
>;
|
|
355
|
+
expect((result.tickers as unknown[]).length).toBe(1);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("error scenarios", () => {
|
|
360
|
+
it("returns friendly error when no exchanges configured", async () => {
|
|
361
|
+
const emptyRegistry = createMockRegistry([]);
|
|
362
|
+
const { api, tools: emptyTools } = createFakeApi(emptyRegistry);
|
|
363
|
+
finMarketDataPlugin.register(api);
|
|
364
|
+
|
|
365
|
+
const tool = emptyTools.get("fin_market_price")!;
|
|
366
|
+
const result = parseResult(
|
|
367
|
+
await tool.execute("call-err-1", {
|
|
368
|
+
symbol: "BTC/USDT",
|
|
369
|
+
}),
|
|
370
|
+
) as Record<string, unknown>;
|
|
371
|
+
|
|
372
|
+
expect(result.error).toContain("No exchanges configured");
|
|
373
|
+
expect(result.error).toContain("openfinclaw exchange add");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("returns error when fin-core not loaded", async () => {
|
|
377
|
+
const { api, tools: noRegistryTools } = createFakeApi(null);
|
|
378
|
+
finMarketDataPlugin.register(api);
|
|
379
|
+
|
|
380
|
+
const tool = noRegistryTools.get("fin_market_price")!;
|
|
381
|
+
const result = parseResult(
|
|
382
|
+
await tool.execute("call-err-2", {
|
|
383
|
+
symbol: "BTC/USDT",
|
|
384
|
+
}),
|
|
385
|
+
) as Record<string, unknown>;
|
|
386
|
+
|
|
387
|
+
expect(result.error).toContain("exchange registry unavailable");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("returns error for unknown exchange id", async () => {
|
|
391
|
+
const tool = tools.get("fin_market_price")!;
|
|
392
|
+
const result = parseResult(
|
|
393
|
+
await tool.execute("call-err-3", {
|
|
394
|
+
symbol: "BTC/USDT",
|
|
395
|
+
exchange: "nonexistent",
|
|
396
|
+
}),
|
|
397
|
+
) as Record<string, unknown>;
|
|
398
|
+
|
|
399
|
+
expect(result.error).toContain("nonexistent");
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
});
|
package/index.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
type ExchangeRegistry = {
|
|
5
|
+
getInstance: (id: string) => Promise<unknown>;
|
|
6
|
+
listExchanges: () => Array<{ id: string; exchange: string; testnet: boolean }>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type CcxtExchange = {
|
|
10
|
+
fetchTicker: (symbol: string) => Promise<Record<string, unknown>>;
|
|
11
|
+
fetchOHLCV: (
|
|
12
|
+
symbol: string,
|
|
13
|
+
timeframe: string,
|
|
14
|
+
since?: number,
|
|
15
|
+
limit?: number,
|
|
16
|
+
) => Promise<Array<[number, number, number, number, number, number]>>;
|
|
17
|
+
fetchOrderBook: (symbol: string, limit?: number) => Promise<Record<string, unknown>>;
|
|
18
|
+
fetchTickers: (symbols?: string[]) => Promise<Record<string, Record<string, unknown>>>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const json = (payload: unknown) => ({
|
|
22
|
+
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
|
|
23
|
+
details: payload,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Resolve the ExchangeRegistry service from fin-core. */
|
|
27
|
+
function getRegistry(api: OpenClawPluginApi): ExchangeRegistry {
|
|
28
|
+
const runtime = api.runtime as unknown as { services?: Map<string, unknown> };
|
|
29
|
+
const registry = runtime.services?.get?.("fin-exchange-registry") as ExchangeRegistry | undefined;
|
|
30
|
+
if (!registry) {
|
|
31
|
+
throw new Error("fin-core plugin not loaded — exchange registry unavailable");
|
|
32
|
+
}
|
|
33
|
+
return registry;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve a CCXT exchange instance, falling back to the first configured exchange. */
|
|
37
|
+
async function resolveExchange(
|
|
38
|
+
api: OpenClawPluginApi,
|
|
39
|
+
exchangeId: string | undefined,
|
|
40
|
+
): Promise<{ exchange: CcxtExchange; exchangeId: string }> {
|
|
41
|
+
const registry = getRegistry(api);
|
|
42
|
+
|
|
43
|
+
let id = exchangeId;
|
|
44
|
+
if (!id || id === "default") {
|
|
45
|
+
const exchanges = registry.listExchanges();
|
|
46
|
+
if (exchanges.length === 0) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"No exchanges configured. Run: openfinclaw exchange add <name> --exchange binance --api-key <key> --secret <secret>",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
id = exchanges[0]!.id;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const instance = await registry.getInstance(id);
|
|
55
|
+
return { exchange: instance as CcxtExchange, exchangeId: id };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const finMarketDataPlugin = {
|
|
59
|
+
id: "fin-market-data",
|
|
60
|
+
name: "Market Data",
|
|
61
|
+
description: "Real-time and historical market data tools: prices, orderbooks, tickers",
|
|
62
|
+
kind: "financial" as const,
|
|
63
|
+
|
|
64
|
+
register(api: OpenClawPluginApi) {
|
|
65
|
+
// --- fin_market_price ---
|
|
66
|
+
api.registerTool(
|
|
67
|
+
{
|
|
68
|
+
name: "fin_market_price",
|
|
69
|
+
label: "Market Price",
|
|
70
|
+
description: "Fetch current price or historical OHLCV candles for a trading pair",
|
|
71
|
+
parameters: Type.Object({
|
|
72
|
+
symbol: Type.String({ description: "Trading pair symbol (e.g. BTC/USDT, ETH/USDT)" }),
|
|
73
|
+
exchange: Type.Optional(
|
|
74
|
+
Type.String({
|
|
75
|
+
description: "Exchange ID to query (e.g. binance, okx). Uses default if omitted.",
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
timeframe: Type.Optional(
|
|
79
|
+
Type.Unsafe<"1m" | "5m" | "1h" | "4h" | "1d">({
|
|
80
|
+
type: "string",
|
|
81
|
+
enum: ["1m", "5m", "1h", "4h", "1d"],
|
|
82
|
+
description: "Candle timeframe for historical OHLCV data",
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
limit: Type.Optional(
|
|
86
|
+
Type.Number({ description: "Number of candles to return (default 100)" }),
|
|
87
|
+
),
|
|
88
|
+
}),
|
|
89
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
90
|
+
try {
|
|
91
|
+
const symbol = params.symbol as string;
|
|
92
|
+
const timeframe = (params.timeframe as string | undefined) ?? "1h";
|
|
93
|
+
const limit = (params.limit as number | undefined) ?? 100;
|
|
94
|
+
|
|
95
|
+
const { exchange, exchangeId } = await resolveExchange(
|
|
96
|
+
api,
|
|
97
|
+
params.exchange as string | undefined,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Fetch current ticker for latest price
|
|
101
|
+
const ticker = await exchange.fetchTicker(symbol);
|
|
102
|
+
|
|
103
|
+
// Fetch OHLCV candles
|
|
104
|
+
const candles = await exchange.fetchOHLCV(symbol, timeframe, undefined, limit);
|
|
105
|
+
const formattedCandles = candles.map(([ts, open, high, low, close, volume]) => ({
|
|
106
|
+
timestamp: new Date(ts).toISOString(),
|
|
107
|
+
open,
|
|
108
|
+
high,
|
|
109
|
+
low,
|
|
110
|
+
close,
|
|
111
|
+
volume,
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
return json({
|
|
115
|
+
symbol,
|
|
116
|
+
exchange: exchangeId,
|
|
117
|
+
price: ticker.last,
|
|
118
|
+
change24h: ticker.change,
|
|
119
|
+
changePct24h: ticker.percentage,
|
|
120
|
+
high24h: ticker.high,
|
|
121
|
+
low24h: ticker.low,
|
|
122
|
+
volume24h: ticker.quoteVolume,
|
|
123
|
+
timeframe,
|
|
124
|
+
candles: formattedCandles,
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{ names: ["fin_market_price"] },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// --- fin_market_overview ---
|
|
135
|
+
api.registerTool(
|
|
136
|
+
{
|
|
137
|
+
name: "fin_market_overview",
|
|
138
|
+
label: "Market Overview",
|
|
139
|
+
description:
|
|
140
|
+
"Get market overview with top movers, volume leaders, and market summary from an exchange",
|
|
141
|
+
parameters: Type.Object({
|
|
142
|
+
exchange: Type.Optional(
|
|
143
|
+
Type.String({ description: "Exchange ID to query. Uses default if omitted." }),
|
|
144
|
+
),
|
|
145
|
+
sort_by: Type.Optional(
|
|
146
|
+
Type.Unsafe<"change" | "volume" | "price">({
|
|
147
|
+
type: "string",
|
|
148
|
+
enum: ["change", "volume", "price"],
|
|
149
|
+
description: "Sort tickers by: change (24h %), volume, or price (default: volume)",
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
limit: Type.Optional(
|
|
153
|
+
Type.Number({ description: "Number of top tickers to return (default 20)" }),
|
|
154
|
+
),
|
|
155
|
+
}),
|
|
156
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
157
|
+
try {
|
|
158
|
+
const sortBy = (params.sort_by as string | undefined) ?? "volume";
|
|
159
|
+
const limit = (params.limit as number | undefined) ?? 20;
|
|
160
|
+
|
|
161
|
+
const { exchange, exchangeId } = await resolveExchange(
|
|
162
|
+
api,
|
|
163
|
+
params.exchange as string | undefined,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const tickers = await exchange.fetchTickers();
|
|
167
|
+
const tickerList = Object.values(tickers)
|
|
168
|
+
.filter((t) => t.last != null && t.quoteVolume != null)
|
|
169
|
+
.map((t) => ({
|
|
170
|
+
symbol: t.symbol as string,
|
|
171
|
+
price: t.last as number,
|
|
172
|
+
change24h: (t.change as number) ?? 0,
|
|
173
|
+
changePct24h: (t.percentage as number) ?? 0,
|
|
174
|
+
volume24h: (t.quoteVolume as number) ?? 0,
|
|
175
|
+
high24h: t.high as number | null,
|
|
176
|
+
low24h: t.low as number | null,
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
// Sort
|
|
180
|
+
if (sortBy === "change") {
|
|
181
|
+
tickerList.sort((a, b) => Math.abs(b.changePct24h) - Math.abs(a.changePct24h));
|
|
182
|
+
} else if (sortBy === "price") {
|
|
183
|
+
tickerList.sort((a, b) => b.price - a.price);
|
|
184
|
+
} else {
|
|
185
|
+
tickerList.sort((a, b) => b.volume24h - a.volume24h);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const topTickers = tickerList.slice(0, limit);
|
|
189
|
+
|
|
190
|
+
// Compute summary stats
|
|
191
|
+
const gainers = tickerList.filter((t) => t.changePct24h > 0).length;
|
|
192
|
+
const losers = tickerList.filter((t) => t.changePct24h < 0).length;
|
|
193
|
+
const totalVolume = tickerList.reduce((sum, t) => sum + t.volume24h, 0);
|
|
194
|
+
|
|
195
|
+
return json({
|
|
196
|
+
exchange: exchangeId,
|
|
197
|
+
totalTickers: tickerList.length,
|
|
198
|
+
summary: {
|
|
199
|
+
gainers,
|
|
200
|
+
losers,
|
|
201
|
+
unchanged: tickerList.length - gainers - losers,
|
|
202
|
+
totalVolume24h: totalVolume,
|
|
203
|
+
},
|
|
204
|
+
sortedBy: sortBy,
|
|
205
|
+
tickers: topTickers,
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{ names: ["fin_market_overview"] },
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// --- fin_orderbook ---
|
|
216
|
+
api.registerTool(
|
|
217
|
+
{
|
|
218
|
+
name: "fin_orderbook",
|
|
219
|
+
label: "Order Book",
|
|
220
|
+
description: "Fetch order book depth showing bids and asks for a trading pair",
|
|
221
|
+
parameters: Type.Object({
|
|
222
|
+
symbol: Type.String({ description: "Trading pair symbol (e.g. BTC/USDT)" }),
|
|
223
|
+
exchange: Type.Optional(
|
|
224
|
+
Type.String({ description: "Exchange ID to query. Uses default if omitted." }),
|
|
225
|
+
),
|
|
226
|
+
limit: Type.Optional(
|
|
227
|
+
Type.Number({
|
|
228
|
+
description: "Depth limit — number of price levels per side (default 25)",
|
|
229
|
+
}),
|
|
230
|
+
),
|
|
231
|
+
}),
|
|
232
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
233
|
+
try {
|
|
234
|
+
const symbol = params.symbol as string;
|
|
235
|
+
const limit = (params.limit as number | undefined) ?? 25;
|
|
236
|
+
|
|
237
|
+
const { exchange, exchangeId } = await resolveExchange(
|
|
238
|
+
api,
|
|
239
|
+
params.exchange as string | undefined,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const book = await exchange.fetchOrderBook(symbol, limit);
|
|
243
|
+
|
|
244
|
+
const bids = (book.bids as Array<[number, number]>).map(([price, amount]) => ({
|
|
245
|
+
price,
|
|
246
|
+
amount,
|
|
247
|
+
}));
|
|
248
|
+
const asks = (book.asks as Array<[number, number]>).map(([price, amount]) => ({
|
|
249
|
+
price,
|
|
250
|
+
amount,
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
const bidTotal = bids.reduce((s, b) => s + b.price * b.amount, 0);
|
|
254
|
+
const askTotal = asks.reduce((s, a) => s + a.price * a.amount, 0);
|
|
255
|
+
const spread =
|
|
256
|
+
asks.length > 0 && bids.length > 0 ? asks[0]!.price - bids[0]!.price : null;
|
|
257
|
+
const spreadPct =
|
|
258
|
+
spread != null && bids[0]!.price > 0 ? (spread / bids[0]!.price) * 100 : null;
|
|
259
|
+
|
|
260
|
+
return json({
|
|
261
|
+
symbol,
|
|
262
|
+
exchange: exchangeId,
|
|
263
|
+
timestamp: book.timestamp
|
|
264
|
+
? new Date(book.timestamp as number).toISOString()
|
|
265
|
+
: new Date().toISOString(),
|
|
266
|
+
spread,
|
|
267
|
+
spreadPct: spreadPct != null ? Number(spreadPct.toFixed(4)) : null,
|
|
268
|
+
bidDepthUsd: Number(bidTotal.toFixed(2)),
|
|
269
|
+
askDepthUsd: Number(askTotal.toFixed(2)),
|
|
270
|
+
bids,
|
|
271
|
+
asks,
|
|
272
|
+
});
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{ names: ["fin_orderbook"] },
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// --- fin_ticker_info ---
|
|
282
|
+
api.registerTool(
|
|
283
|
+
{
|
|
284
|
+
name: "fin_ticker_info",
|
|
285
|
+
label: "Ticker Info",
|
|
286
|
+
description: "Get detailed ticker information including 24h volume, price change, high/low",
|
|
287
|
+
parameters: Type.Object({
|
|
288
|
+
symbol: Type.String({
|
|
289
|
+
description: "Trading pair symbol (e.g. BTC/USDT, ETH/BTC)",
|
|
290
|
+
}),
|
|
291
|
+
exchange: Type.Optional(
|
|
292
|
+
Type.String({ description: "Exchange ID to query. Uses default if omitted." }),
|
|
293
|
+
),
|
|
294
|
+
}),
|
|
295
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
296
|
+
try {
|
|
297
|
+
const symbol = params.symbol as string;
|
|
298
|
+
|
|
299
|
+
const { exchange, exchangeId } = await resolveExchange(
|
|
300
|
+
api,
|
|
301
|
+
params.exchange as string | undefined,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const ticker = await exchange.fetchTicker(symbol);
|
|
305
|
+
|
|
306
|
+
return json({
|
|
307
|
+
symbol,
|
|
308
|
+
exchange: exchangeId,
|
|
309
|
+
last: ticker.last,
|
|
310
|
+
bid: ticker.bid,
|
|
311
|
+
ask: ticker.ask,
|
|
312
|
+
high24h: ticker.high,
|
|
313
|
+
low24h: ticker.low,
|
|
314
|
+
open24h: ticker.open,
|
|
315
|
+
close24h: ticker.close,
|
|
316
|
+
volume24h: ticker.baseVolume,
|
|
317
|
+
quoteVolume24h: ticker.quoteVolume,
|
|
318
|
+
change24h: ticker.change,
|
|
319
|
+
changePct24h: ticker.percentage,
|
|
320
|
+
vwap: ticker.vwap,
|
|
321
|
+
timestamp: ticker.datetime ?? new Date().toISOString(),
|
|
322
|
+
});
|
|
323
|
+
} catch (err) {
|
|
324
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
{ names: ["fin_ticker_info"] },
|
|
329
|
+
);
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export default finMarketDataPlugin;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "fin-market-data",
|
|
3
|
+
"name": "Market Data",
|
|
4
|
+
"description": "Real-time and historical market data tools: prices, orderbooks, tickers",
|
|
5
|
+
"kind": "financial",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {}
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openfinclaw/fin-market-data",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "OpenFinClaw market data tools — prices, orderbooks, tickers, market overview",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"financial",
|
|
7
|
+
"market-data",
|
|
8
|
+
"openclaw",
|
|
9
|
+
"openfinclaw",
|
|
10
|
+
"trading"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/cryptoSUN2049/openFinclaw#readme",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/cryptoSUN2049/openFinclaw.git",
|
|
17
|
+
"directory": "extensions/fin-market-data"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"*.ts",
|
|
21
|
+
"src",
|
|
22
|
+
"openclaw.plugin.json"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"openfinclaw": "2026.3.3"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"openfinclaw": ">=2026.2.0"
|
|
34
|
+
},
|
|
35
|
+
"openclaw": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./index.ts"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real E2E: LLM tool-calling -> fin-market-data -> Binance testnet -> final answer.
|
|
3
|
+
*
|
|
4
|
+
* Requires:
|
|
5
|
+
* - LIVE=1 (or OPENCLAW_LIVE_TEST=1)
|
|
6
|
+
* - BINANCE_TESTNET_API_KEY
|
|
7
|
+
* - BINANCE_TESTNET_SECRET
|
|
8
|
+
* - OPENAI_API_KEY
|
|
9
|
+
*
|
|
10
|
+
* Optional:
|
|
11
|
+
* - OPENAI_BASE_URL (OpenAI-compatible endpoint)
|
|
12
|
+
* - OPENCLAW_FIN_LIVE_MODEL (default: gpt-5.2)
|
|
13
|
+
* - OPENCLAW_FIN_LIVE_MODEL_API (openai-completions | openai-responses)
|
|
14
|
+
*/
|
|
15
|
+
import { type Api, completeSimple, getModel, type Model } from "@mariozechner/pi-ai";
|
|
16
|
+
import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
|
|
17
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
18
|
+
import { isTruthyEnvValue } from "../../../src/infra/env.js";
|
|
19
|
+
import { ExchangeRegistry } from "../../fin-core/src/exchange-registry.js";
|
|
20
|
+
import finMarketDataPlugin from "../index.js";
|
|
21
|
+
|
|
22
|
+
type ToolSchema = {
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
parameters?: unknown;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type RegisteredTool = ToolSchema & {
|
|
29
|
+
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
|
|
33
|
+
const API_KEY = process.env.BINANCE_TESTNET_API_KEY ?? "";
|
|
34
|
+
const SECRET = process.env.BINANCE_TESTNET_SECRET ?? "";
|
|
35
|
+
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
|
|
36
|
+
const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL?.trim() || "";
|
|
37
|
+
const MODEL_API =
|
|
38
|
+
(process.env.OPENCLAW_FIN_LIVE_MODEL_API?.trim() as
|
|
39
|
+
| "openai-completions"
|
|
40
|
+
| "openai-responses"
|
|
41
|
+
| "") || (OPENAI_BASE_URL ? "openai-completions" : "openai-responses");
|
|
42
|
+
const MODEL_ID =
|
|
43
|
+
process.env.OPENCLAW_FIN_LIVE_MODEL?.trim() ||
|
|
44
|
+
(MODEL_API === "openai-completions" ? "gpt-4o-mini" : "gpt-5.2");
|
|
45
|
+
|
|
46
|
+
const describeLive = LIVE && API_KEY && SECRET && OPENAI_API_KEY ? describe : describe.skip;
|
|
47
|
+
|
|
48
|
+
function parseToolResult(result: unknown): Record<string, unknown> {
|
|
49
|
+
const content = (result as { content?: Array<{ text?: string }> }).content;
|
|
50
|
+
const raw = content?.[0]?.text;
|
|
51
|
+
if (!raw) {
|
|
52
|
+
throw new Error("tool result missing JSON text content");
|
|
53
|
+
}
|
|
54
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseToolArguments(value: unknown): Record<string, unknown> {
|
|
59
|
+
if (!value) return {};
|
|
60
|
+
if (typeof value === "string") {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(value) as Record<string, unknown>;
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (typeof value === "object") {
|
|
68
|
+
return value as Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertNoModelError(message: {
|
|
74
|
+
stopReason?: string;
|
|
75
|
+
errorMessage?: string;
|
|
76
|
+
content?: unknown[];
|
|
77
|
+
}): void {
|
|
78
|
+
if (message.stopReason === "error") {
|
|
79
|
+
throw new Error(`model request failed: ${message.errorMessage ?? "unknown error"}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createFakeApi(registry: ExchangeRegistry): {
|
|
84
|
+
api: OpenClawPluginApi;
|
|
85
|
+
tools: Map<string, RegisteredTool>;
|
|
86
|
+
} {
|
|
87
|
+
const tools = new Map<string, RegisteredTool>();
|
|
88
|
+
const services = new Map<string, unknown>();
|
|
89
|
+
services.set("fin-exchange-registry", registry);
|
|
90
|
+
|
|
91
|
+
const api = {
|
|
92
|
+
id: "fin-market-data",
|
|
93
|
+
name: "Market Data",
|
|
94
|
+
source: "live-test",
|
|
95
|
+
config: {},
|
|
96
|
+
pluginConfig: {},
|
|
97
|
+
runtime: {
|
|
98
|
+
version: "live-test",
|
|
99
|
+
services,
|
|
100
|
+
},
|
|
101
|
+
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
102
|
+
registerTool(tool: {
|
|
103
|
+
name: string;
|
|
104
|
+
description?: string;
|
|
105
|
+
parameters?: unknown;
|
|
106
|
+
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
107
|
+
}) {
|
|
108
|
+
tools.set(tool.name, {
|
|
109
|
+
name: tool.name,
|
|
110
|
+
description: tool.description,
|
|
111
|
+
parameters: tool.parameters,
|
|
112
|
+
execute: tool.execute,
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
registerHook() {},
|
|
116
|
+
registerHttpHandler() {},
|
|
117
|
+
registerHttpRoute() {},
|
|
118
|
+
registerChannel() {},
|
|
119
|
+
registerGatewayMethod() {},
|
|
120
|
+
registerCli() {},
|
|
121
|
+
registerService() {},
|
|
122
|
+
registerProvider() {},
|
|
123
|
+
registerCommand() {},
|
|
124
|
+
resolvePath: (p: string) => p,
|
|
125
|
+
on() {},
|
|
126
|
+
} as unknown as OpenClawPluginApi;
|
|
127
|
+
|
|
128
|
+
return { api, tools };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describeLive("finance llm full pipeline (live)", () => {
|
|
132
|
+
let registry: ExchangeRegistry;
|
|
133
|
+
let tool: RegisteredTool;
|
|
134
|
+
let model: Model<Api>;
|
|
135
|
+
|
|
136
|
+
beforeAll(async () => {
|
|
137
|
+
registry = new ExchangeRegistry();
|
|
138
|
+
registry.addExchange("binance-testnet", {
|
|
139
|
+
exchange: "binance",
|
|
140
|
+
apiKey: API_KEY,
|
|
141
|
+
secret: SECRET,
|
|
142
|
+
testnet: true,
|
|
143
|
+
defaultType: "spot",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const { api, tools } = createFakeApi(registry);
|
|
147
|
+
finMarketDataPlugin.register(api);
|
|
148
|
+
|
|
149
|
+
const marketPriceTool = tools.get("fin_market_price");
|
|
150
|
+
if (!marketPriceTool) {
|
|
151
|
+
throw new Error("fin_market_price tool not registered");
|
|
152
|
+
}
|
|
153
|
+
tool = marketPriceTool;
|
|
154
|
+
|
|
155
|
+
if (MODEL_API === "openai-completions") {
|
|
156
|
+
model = {
|
|
157
|
+
id: MODEL_ID,
|
|
158
|
+
name: `OpenAI Compatible ${MODEL_ID}`,
|
|
159
|
+
api: "openai-completions",
|
|
160
|
+
provider: "openai",
|
|
161
|
+
baseUrl: OPENAI_BASE_URL || "https://api.openai.com/v1",
|
|
162
|
+
reasoning: false,
|
|
163
|
+
input: ["text"],
|
|
164
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
165
|
+
contextWindow: 128_000,
|
|
166
|
+
maxTokens: 8_192,
|
|
167
|
+
} as unknown as Model<Api>;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const baseModel = getModel(
|
|
172
|
+
"openai" as "openai",
|
|
173
|
+
MODEL_ID as Parameters<typeof getModel>[1],
|
|
174
|
+
) as unknown as Model<Api>;
|
|
175
|
+
model = OPENAI_BASE_URL
|
|
176
|
+
? ({ ...baseModel, baseUrl: OPENAI_BASE_URL } as Model<Api>)
|
|
177
|
+
: baseModel;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
afterAll(async () => {
|
|
181
|
+
await registry.closeAll();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("LLM calls fin_market_price then produces grounded summary", async () => {
|
|
185
|
+
let first = await completeSimple(
|
|
186
|
+
model,
|
|
187
|
+
{
|
|
188
|
+
messages: [
|
|
189
|
+
{
|
|
190
|
+
role: "user",
|
|
191
|
+
content:
|
|
192
|
+
"Use tool fin_market_price to fetch BTC/USDT from binance-testnet with timeframe 1h limit 5. Return only the tool call.",
|
|
193
|
+
timestamp: Date.now(),
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
tools: [
|
|
197
|
+
{
|
|
198
|
+
name: tool.name,
|
|
199
|
+
description: tool.description ?? "",
|
|
200
|
+
parameters: tool.parameters,
|
|
201
|
+
} as NonNullable<Parameters<typeof completeSimple>[1]["tools"]>[number],
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
{ apiKey: OPENAI_API_KEY, maxTokens: 256, reasoning: "minimal" },
|
|
205
|
+
);
|
|
206
|
+
assertNoModelError(first);
|
|
207
|
+
|
|
208
|
+
let toolCall = first.content.find((block) => block.type === "toolCall");
|
|
209
|
+
for (let retry = 0; retry < 2 && !toolCall; retry += 1) {
|
|
210
|
+
first = await completeSimple(
|
|
211
|
+
model,
|
|
212
|
+
{
|
|
213
|
+
messages: [
|
|
214
|
+
{
|
|
215
|
+
role: "user",
|
|
216
|
+
content:
|
|
217
|
+
'MANDATORY: Call fin_market_price with {"symbol":"BTC/USDT","exchange":"binance-testnet","timeframe":"1h","limit":5}. Reply with tool call only.',
|
|
218
|
+
timestamp: Date.now(),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
tools: [
|
|
222
|
+
{
|
|
223
|
+
name: tool.name,
|
|
224
|
+
description: tool.description ?? "",
|
|
225
|
+
parameters: tool.parameters,
|
|
226
|
+
} as NonNullable<Parameters<typeof completeSimple>[1]["tools"]>[number],
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
{ apiKey: OPENAI_API_KEY, maxTokens: 256, reasoning: "minimal" },
|
|
230
|
+
);
|
|
231
|
+
assertNoModelError(first);
|
|
232
|
+
toolCall = first.content.find((block) => block.type === "toolCall");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
expect(toolCall).toBeTruthy();
|
|
236
|
+
if (!toolCall || toolCall.type !== "toolCall") {
|
|
237
|
+
throw new Error("expected model to issue a tool call");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const toolArgs = parseToolArguments(
|
|
241
|
+
(toolCall as { arguments?: unknown; input?: unknown }).arguments ??
|
|
242
|
+
(toolCall as { arguments?: unknown; input?: unknown }).input,
|
|
243
|
+
);
|
|
244
|
+
const mergedArgs: Record<string, unknown> = {
|
|
245
|
+
symbol: "BTC/USDT",
|
|
246
|
+
exchange: "binance-testnet",
|
|
247
|
+
timeframe: "1h",
|
|
248
|
+
limit: 5,
|
|
249
|
+
...toolArgs,
|
|
250
|
+
};
|
|
251
|
+
const rawToolResult = await tool.execute("fin-live-call-1", mergedArgs);
|
|
252
|
+
const parsedToolResult = parseToolResult(rawToolResult);
|
|
253
|
+
|
|
254
|
+
expect(parsedToolResult.error).toBeUndefined();
|
|
255
|
+
expect(parsedToolResult.symbol).toBe("BTC/USDT");
|
|
256
|
+
expect(Number(parsedToolResult.price ?? 0)).toBeGreaterThan(0);
|
|
257
|
+
expect(Array.isArray(parsedToolResult.candles)).toBe(true);
|
|
258
|
+
expect((parsedToolResult.candles as unknown[]).length).toBeGreaterThan(0);
|
|
259
|
+
|
|
260
|
+
const second = await completeSimple(
|
|
261
|
+
model,
|
|
262
|
+
{
|
|
263
|
+
messages: [
|
|
264
|
+
{
|
|
265
|
+
role: "user",
|
|
266
|
+
content:
|
|
267
|
+
"Use tool fin_market_price to fetch BTC/USDT from binance-testnet with timeframe 1h limit 5. Return only the tool call.",
|
|
268
|
+
timestamp: Date.now(),
|
|
269
|
+
},
|
|
270
|
+
first,
|
|
271
|
+
{
|
|
272
|
+
role: "toolResult",
|
|
273
|
+
toolCallId: toolCall.id,
|
|
274
|
+
toolName: tool.name,
|
|
275
|
+
content: [{ type: "text", text: JSON.stringify(parsedToolResult) }],
|
|
276
|
+
isError: false,
|
|
277
|
+
timestamp: Date.now(),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
role: "user",
|
|
281
|
+
content:
|
|
282
|
+
"Now give one concise sentence summarizing current BTC/USDT price and 24h change from the tool result.",
|
|
283
|
+
timestamp: Date.now(),
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
{ apiKey: OPENAI_API_KEY, maxTokens: 256, reasoning: "minimal" },
|
|
288
|
+
);
|
|
289
|
+
assertNoModelError(second);
|
|
290
|
+
|
|
291
|
+
const finalText = second.content
|
|
292
|
+
.filter((block) => block.type === "text")
|
|
293
|
+
.map((block) => block.text.trim())
|
|
294
|
+
.join(" ")
|
|
295
|
+
.trim();
|
|
296
|
+
|
|
297
|
+
expect(finalText.length).toBeGreaterThan(0);
|
|
298
|
+
expect(/btc|usdt/i.test(finalText)).toBe(true);
|
|
299
|
+
console.log(
|
|
300
|
+
` LLM summary: ${finalText}\n Tool price: ${parsedToolResult.price}, change%: ${parsedToolResult.changePct24h}`,
|
|
301
|
+
);
|
|
302
|
+
}, 45_000);
|
|
303
|
+
});
|