@openfinclaw/findoo-datahub-plugin 2026.3.2
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/DESIGN.md +234 -0
- package/index.ts +608 -0
- package/openclaw.plugin.json +34 -0
- package/package.json +32 -0
- package/skills/crypto-defi/skill.md +69 -0
- package/skills/data-query/skill.md +56 -0
- package/skills/derivatives/skill.md +53 -0
- package/skills/equity/skill.md +64 -0
- package/skills/macro/skill.md +60 -0
- package/skills/market-radar/skill.md +47 -0
- package/src/adapters/crypto-adapter.ts +103 -0
- package/src/adapters/equity-adapter.ts +11 -0
- package/src/adapters/yahoo-adapter.ts +110 -0
- package/src/config.ts +51 -0
- package/src/datahub-client.test.ts +544 -0
- package/src/datahub-client.ts +207 -0
- package/src/integration.live.test.ts +589 -0
- package/src/ohlcv-cache.ts +118 -0
- package/src/regime-detector.ts +73 -0
- package/src/types.ts +31 -0
- package/src/unified-provider.ts +123 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* findoo-datahub-plugin Integration Live Tests
|
|
3
|
+
*
|
|
4
|
+
* Four sections:
|
|
5
|
+
* A — Tool execute() tests (10 tools against live DataHub)
|
|
6
|
+
* B — Cross-extension: DataHub → Strategy Engine (backtest with real OHLCV)
|
|
7
|
+
* C — Cross-extension: DataHub → Monitoring (price alerts with real ticker)
|
|
8
|
+
* D — Service contract verification (fin-data-provider / fin-regime-detector)
|
|
9
|
+
*
|
|
10
|
+
* Uses baked-in public DataHub credentials by default.
|
|
11
|
+
* Set DATAHUB_SKIP_LIVE=1 to skip all tests.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
19
|
+
import finMonitoringPlugin from "../../fin-monitoring/index.js";
|
|
20
|
+
import finStrategyPlugin from "../../fin-strategy-engine/index.js";
|
|
21
|
+
import findooDatahubPlugin from "../index.js";
|
|
22
|
+
|
|
23
|
+
const SKIP = process.env.DATAHUB_SKIP_LIVE === "1";
|
|
24
|
+
|
|
25
|
+
/* ---------- helpers ---------- */
|
|
26
|
+
|
|
27
|
+
type ToolMap = Map<
|
|
28
|
+
string,
|
|
29
|
+
{ execute: (id: string, params: Record<string, unknown>) => Promise<unknown> }
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
function createFakeApi(stateDir: string, pluginId: string, sharedServices?: Map<string, unknown>) {
|
|
33
|
+
const tools: ToolMap = new Map();
|
|
34
|
+
const services = sharedServices ?? new Map<string, unknown>();
|
|
35
|
+
const api = {
|
|
36
|
+
id: pluginId,
|
|
37
|
+
name: pluginId,
|
|
38
|
+
source: "test",
|
|
39
|
+
config: {},
|
|
40
|
+
pluginConfig: {},
|
|
41
|
+
runtime: { version: "test", services },
|
|
42
|
+
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
43
|
+
registerTool(tool: {
|
|
44
|
+
name: string;
|
|
45
|
+
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
46
|
+
}) {
|
|
47
|
+
tools.set(tool.name, tool);
|
|
48
|
+
},
|
|
49
|
+
registerHook() {},
|
|
50
|
+
registerHttpHandler() {},
|
|
51
|
+
registerHttpRoute() {},
|
|
52
|
+
registerChannel() {},
|
|
53
|
+
registerGatewayMethod() {},
|
|
54
|
+
registerCli() {},
|
|
55
|
+
registerService(svc: { id: string; instance: unknown }) {
|
|
56
|
+
services.set(svc.id, svc.instance);
|
|
57
|
+
},
|
|
58
|
+
registerProvider() {},
|
|
59
|
+
registerCommand() {},
|
|
60
|
+
resolvePath: (p: string) => {
|
|
61
|
+
const full = join(stateDir, p);
|
|
62
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
63
|
+
return full;
|
|
64
|
+
},
|
|
65
|
+
on() {},
|
|
66
|
+
} as unknown as OpenClawPluginApi;
|
|
67
|
+
return { api, tools, services };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseResult(result: unknown): Record<string, unknown> {
|
|
71
|
+
const res = result as { content: Array<{ text: string }> };
|
|
72
|
+
return JSON.parse(res.content[0]!.text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ---------- main ---------- */
|
|
76
|
+
|
|
77
|
+
describe.skipIf(SKIP)(
|
|
78
|
+
"findoo-datahub-plugin integration (live DataHub)",
|
|
79
|
+
{ timeout: 120_000 },
|
|
80
|
+
() => {
|
|
81
|
+
let tempDir: string;
|
|
82
|
+
let tools: ToolMap;
|
|
83
|
+
let services: Map<string, unknown>;
|
|
84
|
+
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
tempDir = mkdtempSync(join(tmpdir(), "findoo-live-"));
|
|
87
|
+
const fake = createFakeApi(tempDir, "findoo-datahub-plugin");
|
|
88
|
+
tools = fake.tools;
|
|
89
|
+
services = fake.services;
|
|
90
|
+
await findooDatahubPlugin.register(fake.api);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterAll(() => {
|
|
94
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/* ============================================================
|
|
98
|
+
* Section A: Tool execute() — each tool against live DataHub
|
|
99
|
+
* ============================================================ */
|
|
100
|
+
|
|
101
|
+
describe("Section A: Tool execute()", () => {
|
|
102
|
+
it("A1: fin_stock — A-share historical (600519.SH)", async () => {
|
|
103
|
+
const tool = tools.get("fin_stock")!;
|
|
104
|
+
const res = parseResult(
|
|
105
|
+
await tool.execute("a1", {
|
|
106
|
+
symbol: "600519.SH",
|
|
107
|
+
endpoint: "price/historical",
|
|
108
|
+
provider: "tushare",
|
|
109
|
+
limit: 5,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
expect(res.error).toBeUndefined();
|
|
113
|
+
expect(res.success).toBe(true);
|
|
114
|
+
expect(res.count as number).toBeGreaterThan(0);
|
|
115
|
+
const rows = res.results as Array<Record<string, unknown>>;
|
|
116
|
+
expect(rows[0]).toHaveProperty("close");
|
|
117
|
+
}, 30_000);
|
|
118
|
+
|
|
119
|
+
it("A2: fin_index — CSI 300 historical (000300.SH)", async () => {
|
|
120
|
+
const tool = tools.get("fin_index")!;
|
|
121
|
+
const res = parseResult(
|
|
122
|
+
await tool.execute("a2", {
|
|
123
|
+
symbol: "000300.SH",
|
|
124
|
+
endpoint: "price/historical",
|
|
125
|
+
provider: "tushare",
|
|
126
|
+
limit: 5,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
expect(res.error).toBeUndefined();
|
|
130
|
+
expect(res.success).toBe(true);
|
|
131
|
+
expect(res.count as number).toBeGreaterThan(0);
|
|
132
|
+
}, 30_000);
|
|
133
|
+
|
|
134
|
+
it("A3: fin_macro — CPI data", async () => {
|
|
135
|
+
const tool = tools.get("fin_macro")!;
|
|
136
|
+
const res = parseResult(await tool.execute("a3", { endpoint: "cpi", limit: 5 }));
|
|
137
|
+
expect(res.error).toBeUndefined();
|
|
138
|
+
expect(res.success).toBe(true);
|
|
139
|
+
expect(res.count as number).toBeGreaterThan(0);
|
|
140
|
+
}, 30_000);
|
|
141
|
+
|
|
142
|
+
it("A4: fin_derivatives — futures historical", async () => {
|
|
143
|
+
const tool = tools.get("fin_derivatives")!;
|
|
144
|
+
const res = parseResult(
|
|
145
|
+
await tool.execute("a4", {
|
|
146
|
+
symbol: "RB2501.SHF",
|
|
147
|
+
endpoint: "futures/historical",
|
|
148
|
+
provider: "tushare",
|
|
149
|
+
limit: 5,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
expect(res.error).toBeUndefined();
|
|
153
|
+
expect(res.success).toBe(true);
|
|
154
|
+
expect(res.count as number).toBeGreaterThan(0);
|
|
155
|
+
}, 30_000);
|
|
156
|
+
|
|
157
|
+
it("A5: fin_crypto — CoinGecko top coins", async () => {
|
|
158
|
+
const tool = tools.get("fin_crypto")!;
|
|
159
|
+
const res = parseResult(await tool.execute("a5", { endpoint: "coin/market", limit: 5 }));
|
|
160
|
+
expect(res.error).toBeUndefined();
|
|
161
|
+
expect(res.success).toBe(true);
|
|
162
|
+
expect(res.count as number).toBeGreaterThan(0);
|
|
163
|
+
}, 30_000);
|
|
164
|
+
|
|
165
|
+
it("A6: fin_market — discovery/gainers", async () => {
|
|
166
|
+
const tool = tools.get("fin_market")!;
|
|
167
|
+
try {
|
|
168
|
+
const res = parseResult(await tool.execute("a6", { endpoint: "discovery/gainers" }));
|
|
169
|
+
// yfinance may return data or fail with rate limit
|
|
170
|
+
if (!res.error) {
|
|
171
|
+
expect(res.success).toBe(true);
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// yfinance rate limit — acceptable
|
|
175
|
+
}
|
|
176
|
+
}, 30_000);
|
|
177
|
+
|
|
178
|
+
it("A7: fin_query — raw passthrough (economy/cpi)", async () => {
|
|
179
|
+
const tool = tools.get("fin_query")!;
|
|
180
|
+
const res = parseResult(
|
|
181
|
+
await tool.execute("a7", { path: "economy/cpi", params: { limit: "3" } }),
|
|
182
|
+
);
|
|
183
|
+
expect(res.error).toBeUndefined();
|
|
184
|
+
expect(res.success).toBe(true);
|
|
185
|
+
expect(res.count as number).toBeGreaterThan(0);
|
|
186
|
+
}, 30_000);
|
|
187
|
+
|
|
188
|
+
it("A8: fin_data_ohlcv — equity OHLCV with caching (600519.SH)", async () => {
|
|
189
|
+
const tool = tools.get("fin_data_ohlcv")!;
|
|
190
|
+
const res = parseResult(
|
|
191
|
+
await tool.execute("a8", {
|
|
192
|
+
symbol: "600519.SH",
|
|
193
|
+
market: "equity",
|
|
194
|
+
timeframe: "1d",
|
|
195
|
+
limit: 10,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
expect(res.error).toBeUndefined();
|
|
199
|
+
expect(res.symbol).toBe("600519.SH");
|
|
200
|
+
expect(res.market).toBe("equity");
|
|
201
|
+
expect(res.count as number).toBeGreaterThan(0);
|
|
202
|
+
const candles = res.candles as Array<Record<string, unknown>>;
|
|
203
|
+
expect(candles[0]).toHaveProperty("open");
|
|
204
|
+
expect(candles[0]).toHaveProperty("close");
|
|
205
|
+
expect(candles[0]).toHaveProperty("volume");
|
|
206
|
+
}, 30_000);
|
|
207
|
+
|
|
208
|
+
it("A9: fin_data_regime — market regime detection (600519.SH)", async () => {
|
|
209
|
+
const tool = tools.get("fin_data_regime")!;
|
|
210
|
+
const res = parseResult(
|
|
211
|
+
await tool.execute("a9", {
|
|
212
|
+
symbol: "600519.SH",
|
|
213
|
+
market: "equity",
|
|
214
|
+
timeframe: "1d",
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
expect(res.error).toBeUndefined();
|
|
218
|
+
expect(res.symbol).toBe("600519.SH");
|
|
219
|
+
const validRegimes = ["bull", "bear", "sideways", "volatile", "crisis"];
|
|
220
|
+
expect(validRegimes).toContain(res.regime);
|
|
221
|
+
}, 30_000);
|
|
222
|
+
|
|
223
|
+
it("A10: fin_data_markets — supported markets", async () => {
|
|
224
|
+
const tool = tools.get("fin_data_markets")!;
|
|
225
|
+
const res = parseResult(await tool.execute("a10", {}));
|
|
226
|
+
expect(res.datahub).toBeDefined();
|
|
227
|
+
const markets = res.markets as Array<{ market: string; available: boolean }>;
|
|
228
|
+
expect(markets.length).toBeGreaterThanOrEqual(3);
|
|
229
|
+
expect(markets.find((m) => m.market === "crypto")?.available).toBe(true);
|
|
230
|
+
expect(markets.find((m) => m.market === "equity")?.available).toBe(true);
|
|
231
|
+
expect(res.endpoints as number).toBe(172);
|
|
232
|
+
}, 30_000);
|
|
233
|
+
|
|
234
|
+
it("A11: fin_stock — fundamental/income (600519.SH)", async () => {
|
|
235
|
+
const tool = tools.get("fin_stock")!;
|
|
236
|
+
const res = parseResult(
|
|
237
|
+
await tool.execute("a11", {
|
|
238
|
+
symbol: "600519.SH",
|
|
239
|
+
endpoint: "fundamental/income",
|
|
240
|
+
provider: "tushare",
|
|
241
|
+
limit: 3,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
expect(res.error).toBeUndefined();
|
|
245
|
+
expect(res.success).toBe(true);
|
|
246
|
+
expect(res.count as number).toBeGreaterThan(0);
|
|
247
|
+
}, 30_000);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/* ============================================================
|
|
251
|
+
* Section B: Cross-extension — Strategy Engine backtest
|
|
252
|
+
* ============================================================ */
|
|
253
|
+
|
|
254
|
+
describe("Section B: Cross-extension backtest", () => {
|
|
255
|
+
let strategyTools: ToolMap;
|
|
256
|
+
|
|
257
|
+
beforeAll(() => {
|
|
258
|
+
// Register fin-strategy-engine sharing the same services Map
|
|
259
|
+
const fake2 = createFakeApi(tempDir, "fin-strategy-engine", services);
|
|
260
|
+
finStrategyPlugin.register(fake2.api);
|
|
261
|
+
strategyTools = fake2.tools;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("B1: services wired — both data + strategy services present", () => {
|
|
265
|
+
expect(services.has("fin-data-provider")).toBe(true);
|
|
266
|
+
expect(services.has("fin-regime-detector")).toBe(true);
|
|
267
|
+
expect(services.has("fin-strategy-registry")).toBe(true);
|
|
268
|
+
expect(services.has("fin-backtest-engine")).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("B2: create SMA strategy + run real backtest with DataHub OHLCV", async () => {
|
|
272
|
+
const createTool = strategyTools.get("fin_strategy_create")!;
|
|
273
|
+
const created = parseResult(
|
|
274
|
+
await createTool.execute("b2-create", {
|
|
275
|
+
name: "E2E-SMA-Live",
|
|
276
|
+
type: "sma-crossover",
|
|
277
|
+
// Use default BTC/USDT + crypto market — matches sma-crossover markets[0]
|
|
278
|
+
symbols: ["BTC/USDT"],
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
expect(created.error).toBeUndefined();
|
|
282
|
+
expect(created.created).toBe(true);
|
|
283
|
+
const strategyId = created.id as string;
|
|
284
|
+
expect(strategyId).toBeTruthy();
|
|
285
|
+
|
|
286
|
+
// Run backtest — fetches real OHLCV from DataHub via shared services
|
|
287
|
+
const backtestTool = strategyTools.get("fin_backtest_run")!;
|
|
288
|
+
const bt = parseResult(await backtestTool.execute("b2-backtest", { strategyId }));
|
|
289
|
+
expect(bt.error).toBeUndefined();
|
|
290
|
+
expect(bt.strategyId).toBe(strategyId);
|
|
291
|
+
expect(typeof bt.totalReturn).toBe("string"); // formatted "X.XX%"
|
|
292
|
+
expect(typeof bt.sharpe).toBe("string");
|
|
293
|
+
expect(typeof bt.maxDrawdown).toBe("string");
|
|
294
|
+
expect(typeof bt.totalTrades).toBe("number");
|
|
295
|
+
expect(bt.totalTrades as number).toBeGreaterThanOrEqual(0);
|
|
296
|
+
expect(typeof bt.finalEquity).toBe("string");
|
|
297
|
+
|
|
298
|
+
// Verify result is persisted
|
|
299
|
+
const resultTool = strategyTools.get("fin_backtest_result")!;
|
|
300
|
+
const stored = parseResult(await resultTool.execute("b2-result", { strategyId }));
|
|
301
|
+
expect(stored.error).toBeUndefined();
|
|
302
|
+
expect(stored.strategyId).toBe(strategyId);
|
|
303
|
+
expect(typeof stored.totalReturn).toBe("number");
|
|
304
|
+
expect(typeof stored.sharpe).toBe("number");
|
|
305
|
+
}, 60_000);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/* ============================================================
|
|
309
|
+
* Section C: Cross-extension — Monitoring alerts
|
|
310
|
+
* ============================================================ */
|
|
311
|
+
|
|
312
|
+
describe("Section C: Cross-extension alerts", () => {
|
|
313
|
+
let monitorTools: ToolMap;
|
|
314
|
+
|
|
315
|
+
beforeAll(() => {
|
|
316
|
+
const fake3 = createFakeApi(tempDir, "fin-monitoring", services);
|
|
317
|
+
finMonitoringPlugin.register(fake3.api);
|
|
318
|
+
monitorTools = fake3.tools;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("C1: price_above alert triggers for 600519.SH (threshold=1)", async () => {
|
|
322
|
+
// Set alert with absurdly low price — should always trigger
|
|
323
|
+
const setTool = monitorTools.get("fin_set_alert")!;
|
|
324
|
+
const alert = parseResult(
|
|
325
|
+
await setTool.execute("c1-set", {
|
|
326
|
+
kind: "price_above",
|
|
327
|
+
symbol: "600519.SH",
|
|
328
|
+
price: 1,
|
|
329
|
+
message: "E2E test: price above 1",
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
expect(alert.error).toBeUndefined();
|
|
333
|
+
expect(alert.id).toBeTruthy();
|
|
334
|
+
expect(alert.status).toBe("active");
|
|
335
|
+
|
|
336
|
+
// Run checks — evaluates against live DataHub ticker
|
|
337
|
+
const checkTool = monitorTools.get("fin_monitor_run_checks")!;
|
|
338
|
+
const result = parseResult(await checkTool.execute("c1-check", {}));
|
|
339
|
+
expect(result.error).toBeUndefined();
|
|
340
|
+
expect(result.checkedAlerts as number).toBeGreaterThanOrEqual(1);
|
|
341
|
+
expect(result.checkedSymbols as number).toBeGreaterThanOrEqual(1);
|
|
342
|
+
// Price of 600519.SH is always > 1 CNY
|
|
343
|
+
expect(result.triggeredCount as number).toBeGreaterThanOrEqual(1);
|
|
344
|
+
const triggered = result.triggeredAlerts as Array<{ condition: { symbol: string } }>;
|
|
345
|
+
expect(triggered.some((a) => a.condition.symbol === "600519.SH")).toBe(true);
|
|
346
|
+
}, 30_000);
|
|
347
|
+
|
|
348
|
+
it("C2: price_below alert does NOT trigger for 600519.SH (threshold=1)", async () => {
|
|
349
|
+
// Set alert with absurdly low price — should NOT trigger (price is >> 1)
|
|
350
|
+
const setTool = monitorTools.get("fin_set_alert")!;
|
|
351
|
+
const alert = parseResult(
|
|
352
|
+
await setTool.execute("c2-set", {
|
|
353
|
+
kind: "price_below",
|
|
354
|
+
symbol: "600519.SH",
|
|
355
|
+
price: 1,
|
|
356
|
+
message: "E2E test: price below 1",
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
expect(alert.error).toBeUndefined();
|
|
360
|
+
|
|
361
|
+
// List alerts to confirm it's active
|
|
362
|
+
const listTool = monitorTools.get("fin_list_alerts")!;
|
|
363
|
+
const list = parseResult(await listTool.execute("c2-list", {}));
|
|
364
|
+
expect(list.total as number).toBeGreaterThanOrEqual(2);
|
|
365
|
+
// The price_below alert should still be active (not triggered)
|
|
366
|
+
const alerts = list.alerts as Array<{
|
|
367
|
+
id: string;
|
|
368
|
+
condition: { kind: string; symbol: string; price: number };
|
|
369
|
+
triggeredAt?: string;
|
|
370
|
+
}>;
|
|
371
|
+
const belowAlert = alerts.find(
|
|
372
|
+
(a) => a.condition.kind === "price_below" && a.condition.symbol === "600519.SH",
|
|
373
|
+
);
|
|
374
|
+
expect(belowAlert).toBeDefined();
|
|
375
|
+
expect(belowAlert!.triggeredAt).toBeUndefined();
|
|
376
|
+
}, 30_000);
|
|
377
|
+
|
|
378
|
+
it("C3: remove alert cleans up", async () => {
|
|
379
|
+
const listTool = monitorTools.get("fin_list_alerts")!;
|
|
380
|
+
const listBefore = parseResult(await listTool.execute("c3-before", {}));
|
|
381
|
+
const alerts = listBefore.alerts as Array<{ id: string }>;
|
|
382
|
+
|
|
383
|
+
const removeTool = monitorTools.get("fin_remove_alert")!;
|
|
384
|
+
for (const alert of alerts) {
|
|
385
|
+
const res = parseResult(await removeTool.execute("c3-rm", { id: alert.id }));
|
|
386
|
+
expect(res.removed).toBe(true);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const listAfter = parseResult(await listTool.execute("c3-after", {}));
|
|
390
|
+
expect(listAfter.total).toBe(0);
|
|
391
|
+
}, 30_000);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
/* ============================================================
|
|
395
|
+
* Section D: Service contract verification
|
|
396
|
+
* ============================================================ */
|
|
397
|
+
|
|
398
|
+
describe("Section D: Service contracts", () => {
|
|
399
|
+
it("D1: fin-data-provider.getOHLCV returns valid OHLCV for equity", async () => {
|
|
400
|
+
const provider = services.get("fin-data-provider") as {
|
|
401
|
+
getOHLCV: (params: {
|
|
402
|
+
symbol: string;
|
|
403
|
+
market: string;
|
|
404
|
+
timeframe: string;
|
|
405
|
+
limit?: number;
|
|
406
|
+
}) => Promise<
|
|
407
|
+
Array<{
|
|
408
|
+
timestamp: number;
|
|
409
|
+
open: number;
|
|
410
|
+
high: number;
|
|
411
|
+
low: number;
|
|
412
|
+
close: number;
|
|
413
|
+
volume: number;
|
|
414
|
+
}>
|
|
415
|
+
>;
|
|
416
|
+
};
|
|
417
|
+
expect(provider).toBeDefined();
|
|
418
|
+
expect(typeof provider.getOHLCV).toBe("function");
|
|
419
|
+
|
|
420
|
+
const ohlcv = await provider.getOHLCV({
|
|
421
|
+
symbol: "600519.SH",
|
|
422
|
+
market: "equity",
|
|
423
|
+
timeframe: "1d",
|
|
424
|
+
limit: 20,
|
|
425
|
+
});
|
|
426
|
+
expect(ohlcv.length).toBeGreaterThan(0);
|
|
427
|
+
expect(ohlcv.length).toBeLessThanOrEqual(20);
|
|
428
|
+
|
|
429
|
+
const bar = ohlcv[0]!;
|
|
430
|
+
expect(typeof bar.timestamp).toBe("number");
|
|
431
|
+
expect(bar.timestamp).toBeGreaterThan(0);
|
|
432
|
+
expect(typeof bar.open).toBe("number");
|
|
433
|
+
expect(typeof bar.high).toBe("number");
|
|
434
|
+
expect(typeof bar.low).toBe("number");
|
|
435
|
+
expect(typeof bar.close).toBe("number");
|
|
436
|
+
expect(typeof bar.volume).toBe("number");
|
|
437
|
+
expect(bar.high).toBeGreaterThanOrEqual(bar.low);
|
|
438
|
+
}, 30_000);
|
|
439
|
+
|
|
440
|
+
it("D2: fin-data-provider.getTicker returns valid ticker for equity", async () => {
|
|
441
|
+
const provider = services.get("fin-data-provider") as {
|
|
442
|
+
getTicker: (
|
|
443
|
+
symbol: string,
|
|
444
|
+
market: string,
|
|
445
|
+
) => Promise<{
|
|
446
|
+
symbol: string;
|
|
447
|
+
market: string;
|
|
448
|
+
last: number;
|
|
449
|
+
timestamp: number;
|
|
450
|
+
}>;
|
|
451
|
+
};
|
|
452
|
+
expect(typeof provider.getTicker).toBe("function");
|
|
453
|
+
|
|
454
|
+
const ticker = await provider.getTicker("600519.SH", "equity");
|
|
455
|
+
expect(ticker.symbol).toBe("600519.SH");
|
|
456
|
+
expect(ticker.market).toBe("equity");
|
|
457
|
+
expect(typeof ticker.last).toBe("number");
|
|
458
|
+
expect(ticker.last).toBeGreaterThan(0);
|
|
459
|
+
expect(typeof ticker.timestamp).toBe("number");
|
|
460
|
+
}, 30_000);
|
|
461
|
+
|
|
462
|
+
it("D3: fin-data-provider.detectRegime returns valid regime", async () => {
|
|
463
|
+
const provider = services.get("fin-data-provider") as {
|
|
464
|
+
detectRegime: (params: {
|
|
465
|
+
symbol: string;
|
|
466
|
+
market: string;
|
|
467
|
+
timeframe: string;
|
|
468
|
+
}) => Promise<string>;
|
|
469
|
+
};
|
|
470
|
+
expect(typeof provider.detectRegime).toBe("function");
|
|
471
|
+
|
|
472
|
+
const regime = await provider.detectRegime({
|
|
473
|
+
symbol: "600519.SH",
|
|
474
|
+
market: "equity",
|
|
475
|
+
timeframe: "1d",
|
|
476
|
+
});
|
|
477
|
+
const validRegimes = ["bull", "bear", "sideways", "volatile", "crisis"];
|
|
478
|
+
expect(validRegimes).toContain(regime);
|
|
479
|
+
}, 30_000);
|
|
480
|
+
|
|
481
|
+
it("D4: fin-data-provider.getSupportedMarkets returns correct structure", () => {
|
|
482
|
+
const provider = services.get("fin-data-provider") as {
|
|
483
|
+
getSupportedMarkets: () => Array<{
|
|
484
|
+
market: string;
|
|
485
|
+
available: boolean;
|
|
486
|
+
}>;
|
|
487
|
+
};
|
|
488
|
+
expect(typeof provider.getSupportedMarkets).toBe("function");
|
|
489
|
+
|
|
490
|
+
const markets = provider.getSupportedMarkets();
|
|
491
|
+
expect(markets.length).toBeGreaterThanOrEqual(3);
|
|
492
|
+
|
|
493
|
+
const crypto = markets.find((m) => m.market === "crypto");
|
|
494
|
+
const equity = markets.find((m) => m.market === "equity");
|
|
495
|
+
const commodity = markets.find((m) => m.market === "commodity");
|
|
496
|
+
expect(crypto?.available).toBe(true);
|
|
497
|
+
expect(equity?.available).toBe(true);
|
|
498
|
+
expect(commodity?.available).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("D5: fin-regime-detector.detect returns valid regime for sufficient data", async () => {
|
|
502
|
+
// First, get 300+ bars via data provider
|
|
503
|
+
const provider = services.get("fin-data-provider") as {
|
|
504
|
+
getOHLCV: (params: {
|
|
505
|
+
symbol: string;
|
|
506
|
+
market: string;
|
|
507
|
+
timeframe: string;
|
|
508
|
+
limit?: number;
|
|
509
|
+
}) => Promise<
|
|
510
|
+
Array<{
|
|
511
|
+
timestamp: number;
|
|
512
|
+
open: number;
|
|
513
|
+
high: number;
|
|
514
|
+
low: number;
|
|
515
|
+
close: number;
|
|
516
|
+
volume: number;
|
|
517
|
+
}>
|
|
518
|
+
>;
|
|
519
|
+
};
|
|
520
|
+
const ohlcv = await provider.getOHLCV({
|
|
521
|
+
symbol: "600519.SH",
|
|
522
|
+
market: "equity",
|
|
523
|
+
timeframe: "1d",
|
|
524
|
+
limit: 300,
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const detector = services.get("fin-regime-detector") as {
|
|
528
|
+
detect: (
|
|
529
|
+
bars: Array<{
|
|
530
|
+
timestamp: number;
|
|
531
|
+
open: number;
|
|
532
|
+
high: number;
|
|
533
|
+
low: number;
|
|
534
|
+
close: number;
|
|
535
|
+
volume: number;
|
|
536
|
+
}>,
|
|
537
|
+
) => string;
|
|
538
|
+
};
|
|
539
|
+
expect(detector).toBeDefined();
|
|
540
|
+
expect(typeof detector.detect).toBe("function");
|
|
541
|
+
|
|
542
|
+
const regime = detector.detect(ohlcv);
|
|
543
|
+
const validRegimes = ["bull", "bear", "sideways", "volatile", "crisis"];
|
|
544
|
+
expect(validRegimes).toContain(regime);
|
|
545
|
+
|
|
546
|
+
// With >= 200 bars, should NOT always be "sideways"
|
|
547
|
+
// (It CAN be sideways legitimately, but we at least verify it returns a valid regime)
|
|
548
|
+
if (ohlcv.length >= 200) {
|
|
549
|
+
expect(typeof regime).toBe("string");
|
|
550
|
+
expect(regime.length).toBeGreaterThan(0);
|
|
551
|
+
}
|
|
552
|
+
}, 30_000);
|
|
553
|
+
|
|
554
|
+
it("D6: OHLCV caching — repeated calls return consistent data", async () => {
|
|
555
|
+
const provider = services.get("fin-data-provider") as {
|
|
556
|
+
getOHLCV: (params: {
|
|
557
|
+
symbol: string;
|
|
558
|
+
market: string;
|
|
559
|
+
timeframe: string;
|
|
560
|
+
limit?: number;
|
|
561
|
+
}) => Promise<Array<{ timestamp: number; close: number }>>;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
// Use a fresh symbol not tested above to ensure cold-cache first call
|
|
565
|
+
const first = await provider.getOHLCV({
|
|
566
|
+
symbol: "000001.SZ",
|
|
567
|
+
market: "equity",
|
|
568
|
+
timeframe: "1d",
|
|
569
|
+
limit: 30,
|
|
570
|
+
});
|
|
571
|
+
expect(first.length).toBeGreaterThan(0);
|
|
572
|
+
|
|
573
|
+
// Second call: should come from cache, returning identical data
|
|
574
|
+
const second = await provider.getOHLCV({
|
|
575
|
+
symbol: "000001.SZ",
|
|
576
|
+
market: "equity",
|
|
577
|
+
timeframe: "1d",
|
|
578
|
+
limit: 30,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
expect(first.length).toBe(second.length);
|
|
582
|
+
expect(first[0]!.timestamp).toBe(second[0]!.timestamp);
|
|
583
|
+
expect(first[0]!.close).toBe(second[0]!.close);
|
|
584
|
+
// Verify last bar matches too
|
|
585
|
+
expect(first[first.length - 1]!.close).toBe(second[second.length - 1]!.close);
|
|
586
|
+
}, 30_000);
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import type { OHLCV } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export class OHLCVCache {
|
|
7
|
+
private db: DatabaseSync;
|
|
8
|
+
private closed = false;
|
|
9
|
+
|
|
10
|
+
constructor(dbPath: string) {
|
|
11
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
12
|
+
this.db = new DatabaseSync(dbPath);
|
|
13
|
+
this.db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS ohlcv (
|
|
15
|
+
symbol TEXT NOT NULL,
|
|
16
|
+
market TEXT NOT NULL,
|
|
17
|
+
timeframe TEXT NOT NULL,
|
|
18
|
+
timestamp INTEGER NOT NULL,
|
|
19
|
+
open REAL NOT NULL,
|
|
20
|
+
high REAL NOT NULL,
|
|
21
|
+
low REAL NOT NULL,
|
|
22
|
+
close REAL NOT NULL,
|
|
23
|
+
volume REAL NOT NULL,
|
|
24
|
+
PRIMARY KEY (symbol, market, timeframe, timestamp)
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
this.db.exec(`
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_ohlcv_lookup
|
|
29
|
+
ON ohlcv (symbol, market, timeframe, timestamp)
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
upsertBatch(symbol: string, market: string, timeframe: string, rows: OHLCV[]): void {
|
|
34
|
+
const stmt = this.db.prepare(`
|
|
35
|
+
INSERT OR REPLACE INTO ohlcv (symbol, market, timeframe, timestamp, open, high, low, close, volume)
|
|
36
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
stmt.run(
|
|
41
|
+
symbol,
|
|
42
|
+
market,
|
|
43
|
+
timeframe,
|
|
44
|
+
row.timestamp,
|
|
45
|
+
row.open,
|
|
46
|
+
row.high,
|
|
47
|
+
row.low,
|
|
48
|
+
row.close,
|
|
49
|
+
row.volume,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
query(
|
|
55
|
+
symbol: string,
|
|
56
|
+
market: string,
|
|
57
|
+
timeframe: string,
|
|
58
|
+
since?: number,
|
|
59
|
+
until?: number,
|
|
60
|
+
): OHLCV[] {
|
|
61
|
+
let sql =
|
|
62
|
+
"SELECT timestamp, open, high, low, close, volume FROM ohlcv WHERE symbol = ? AND market = ? AND timeframe = ?";
|
|
63
|
+
const params: (string | number)[] = [symbol, market, timeframe];
|
|
64
|
+
|
|
65
|
+
if (since != null) {
|
|
66
|
+
sql += " AND timestamp >= ?";
|
|
67
|
+
params.push(since);
|
|
68
|
+
}
|
|
69
|
+
if (until != null) {
|
|
70
|
+
sql += " AND timestamp <= ?";
|
|
71
|
+
params.push(until);
|
|
72
|
+
}
|
|
73
|
+
sql += " ORDER BY timestamp ASC";
|
|
74
|
+
|
|
75
|
+
const stmt = this.db.prepare(sql);
|
|
76
|
+
const rows = stmt.all(...params) as Array<{
|
|
77
|
+
timestamp: number;
|
|
78
|
+
open: number;
|
|
79
|
+
high: number;
|
|
80
|
+
low: number;
|
|
81
|
+
close: number;
|
|
82
|
+
volume: number;
|
|
83
|
+
}>;
|
|
84
|
+
|
|
85
|
+
return rows.map((r) => ({
|
|
86
|
+
timestamp: r.timestamp,
|
|
87
|
+
open: r.open,
|
|
88
|
+
high: r.high,
|
|
89
|
+
low: r.low,
|
|
90
|
+
close: r.close,
|
|
91
|
+
volume: r.volume,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getRange(
|
|
96
|
+
symbol: string,
|
|
97
|
+
market: string,
|
|
98
|
+
timeframe: string,
|
|
99
|
+
): { earliest: number; latest: number } | null {
|
|
100
|
+
const stmt = this.db.prepare(
|
|
101
|
+
"SELECT MIN(timestamp) as earliest, MAX(timestamp) as latest FROM ohlcv WHERE symbol = ? AND market = ? AND timeframe = ?",
|
|
102
|
+
);
|
|
103
|
+
const row = stmt.get(symbol, market, timeframe) as
|
|
104
|
+
| { earliest: number | null; latest: number | null }
|
|
105
|
+
| undefined;
|
|
106
|
+
|
|
107
|
+
if (!row || row.earliest == null || row.latest == null) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return { earliest: row.earliest, latest: row.latest };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
close(): void {
|
|
114
|
+
if (this.closed) return;
|
|
115
|
+
this.closed = true;
|
|
116
|
+
this.db.close();
|
|
117
|
+
}
|
|
118
|
+
}
|