@openfinclaw/openfinclaw-strategy 2026.3.275 → 2026.3.310
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/index.test.ts +4 -2
- package/index.ts +37 -16
- package/package.json +1 -1
- package/src/config.ts +3 -1
- package/src/db/repositories.ts +79 -17
- package/src/db/schema.ts +5 -1
- package/src/scheduler/news-provider.ts +1 -1
- package/src/scheduler/periodic-report-builder.ts +71 -0
- package/src/scheduler/scan-report-builder.ts +6 -2
- package/src/scheduler/tools.ts +362 -42
- package/src/strategy/tools.ts +114 -105
- package/src/tournament/cron-setup.ts +102 -0
- package/src/tournament/db.test.ts +222 -0
- package/src/tournament/db.ts +286 -0
- package/src/tournament/orchestrator.test.ts +232 -0
- package/src/tournament/orchestrator.ts +238 -0
- package/src/tournament/prompts.ts +65 -0
- package/src/tournament/tools.test.ts +221 -0
- package/src/tournament/tools.ts +192 -0
package/src/scheduler/tools.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scheduler tools registration.
|
|
3
|
-
* Tools: strategy_daily_scan, strategy_scan_history
|
|
3
|
+
* Tools: strategy_daily_scan, strategy_price_monitor, strategy_scan_history, strategy_periodic_report
|
|
4
4
|
*/
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import type { DatabaseSync } from "node:sqlite";
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
8
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
9
|
+
import { DataHubClient, guessMarket } from "../datahub/client.js";
|
|
9
10
|
import {
|
|
11
|
+
countPriceAlertsSince,
|
|
12
|
+
countScanHistoryByTypeSince,
|
|
13
|
+
insertPriceAlert,
|
|
10
14
|
insertScanHistory,
|
|
11
15
|
queryBacktestResults,
|
|
12
16
|
queryScanHistory,
|
|
13
17
|
queryStrategies,
|
|
14
18
|
updateScanHistory,
|
|
15
19
|
} from "../db/repositories.js";
|
|
16
|
-
import { DataHubClient } from "../datahub/client.js";
|
|
17
20
|
import { withLogging } from "../middleware/with-logging.js";
|
|
18
21
|
import type { UnifiedPluginConfig } from "../types.js";
|
|
19
22
|
import type { AggregatedNewsProvider } from "./news-provider.js";
|
|
23
|
+
import { formatPeriodicReportMarkdown } from "./periodic-report-builder.js";
|
|
20
24
|
import { buildScanReport, formatScanReportMarkdown } from "./scan-report-builder.js";
|
|
21
25
|
|
|
22
26
|
/** JSON tool result helper. */
|
|
@@ -191,6 +195,213 @@ export function registerSchedulerTools(
|
|
|
191
195
|
{ names: ["strategy_daily_scan"] },
|
|
192
196
|
);
|
|
193
197
|
|
|
198
|
+
// ── strategy_price_monitor ──
|
|
199
|
+
api.registerTool(
|
|
200
|
+
{
|
|
201
|
+
name: "strategy_price_monitor",
|
|
202
|
+
label: "Strategy price monitor",
|
|
203
|
+
description:
|
|
204
|
+
"Check price moves for all symbols referenced by local strategies against a threshold. " +
|
|
205
|
+
"Persists alerts to SQLite. Intended for cron or manual runs.",
|
|
206
|
+
parameters: Type.Object({
|
|
207
|
+
threshold: Type.Optional(
|
|
208
|
+
Type.Number({ description: "Alert threshold in percent (default: plugin config)" }),
|
|
209
|
+
),
|
|
210
|
+
strategyId: Type.Optional(
|
|
211
|
+
Type.String({ description: "Monitor symbols for one strategy only (default: all)" }),
|
|
212
|
+
),
|
|
213
|
+
}),
|
|
214
|
+
execute: withLogging(
|
|
215
|
+
getDb,
|
|
216
|
+
"strategy_price_monitor",
|
|
217
|
+
"scheduler",
|
|
218
|
+
async (_toolCallId, params) => {
|
|
219
|
+
const scanId = randomUUID();
|
|
220
|
+
const now = new Date().toISOString();
|
|
221
|
+
|
|
222
|
+
insertScanHistory(getDb(), {
|
|
223
|
+
id: scanId,
|
|
224
|
+
scan_type: "price_monitor",
|
|
225
|
+
started_at: now,
|
|
226
|
+
status: "running",
|
|
227
|
+
strategies_scanned: 0,
|
|
228
|
+
news_found: 0,
|
|
229
|
+
actions_taken: 0,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const db = getDb();
|
|
234
|
+
let strategies = queryStrategies(db);
|
|
235
|
+
if (params.strategyId) {
|
|
236
|
+
strategies = strategies.filter((s) => s.id === params.strategyId);
|
|
237
|
+
}
|
|
238
|
+
strategies = strategies.filter((s) => {
|
|
239
|
+
try {
|
|
240
|
+
const syms = JSON.parse(s.symbols ?? "[]");
|
|
241
|
+
return Array.isArray(syms) && syms.length > 0;
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (!config.apiKey) {
|
|
248
|
+
updateScanHistory(getDb(), scanId, {
|
|
249
|
+
status: "failed",
|
|
250
|
+
completed_at: new Date().toISOString(),
|
|
251
|
+
summary: "API key not configured.",
|
|
252
|
+
});
|
|
253
|
+
return json({
|
|
254
|
+
success: false,
|
|
255
|
+
error: "API key not configured; price monitoring requires DataHub access.",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (strategies.length === 0) {
|
|
260
|
+
updateScanHistory(getDb(), scanId, {
|
|
261
|
+
status: "completed",
|
|
262
|
+
completed_at: new Date().toISOString(),
|
|
263
|
+
strategies_scanned: 0,
|
|
264
|
+
summary: "No strategies with symbols.",
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
content: [
|
|
268
|
+
{
|
|
269
|
+
type: "text" as const,
|
|
270
|
+
text: "暂无包含标的的策略。请先配置策略 symbols 后再运行价格监控。",
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
details: { success: true, strategiesScanned: 0, alertCount: 0 },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const threshold =
|
|
278
|
+
params.threshold != null && Number.isFinite(Number(params.threshold))
|
|
279
|
+
? Number(params.threshold)
|
|
280
|
+
: config.priceAlertThreshold;
|
|
281
|
+
|
|
282
|
+
type StratRef = { id: string; name: string };
|
|
283
|
+
const symbolToStrategies = new Map<string, StratRef[]>();
|
|
284
|
+
for (const s of strategies) {
|
|
285
|
+
try {
|
|
286
|
+
const syms = JSON.parse(s.symbols ?? "[]") as string[];
|
|
287
|
+
for (const sym of syms) {
|
|
288
|
+
if (typeof sym !== "string" || !sym) continue;
|
|
289
|
+
const list = symbolToStrategies.get(sym) ?? [];
|
|
290
|
+
list.push({ id: s.id, name: s.name });
|
|
291
|
+
symbolToStrategies.set(sym, list);
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
/* skip */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const client = new DataHubClient(
|
|
299
|
+
config.datahubGatewayUrl,
|
|
300
|
+
config.apiKey,
|
|
301
|
+
config.requestTimeoutMs,
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const alertLines: string[] = [];
|
|
305
|
+
const okLines: string[] = [];
|
|
306
|
+
let alertCount = 0;
|
|
307
|
+
|
|
308
|
+
for (const [symbol, strats] of symbolToStrategies) {
|
|
309
|
+
const market = guessMarket(symbol);
|
|
310
|
+
let pct: number | null = null;
|
|
311
|
+
let lastPrice = 0;
|
|
312
|
+
let prevPrice = 0;
|
|
313
|
+
try {
|
|
314
|
+
const ohlcv = await client.getOHLCV({ symbol, market, limit: 2 });
|
|
315
|
+
if (ohlcv.length >= 2) {
|
|
316
|
+
prevPrice = ohlcv[ohlcv.length - 2]!.close;
|
|
317
|
+
lastPrice = ohlcv[ohlcv.length - 1]!.close;
|
|
318
|
+
if (prevPrice > 0) pct = ((lastPrice - prevPrice) / prevPrice) * 100;
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
pct = null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (pct === null) {
|
|
325
|
+
okLines.push(`- ${symbol}: 无法获取 K 线(DataHub 或标的异常)`);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (Math.abs(pct) >= threshold) {
|
|
330
|
+
for (const st of strats) {
|
|
331
|
+
const alertId = randomUUID();
|
|
332
|
+
const pctStr = `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
|
|
333
|
+
insertPriceAlert(getDb(), {
|
|
334
|
+
id: alertId,
|
|
335
|
+
strategy_id: st.id,
|
|
336
|
+
symbol,
|
|
337
|
+
alert_type: "threshold_breached",
|
|
338
|
+
trigger_value: pct,
|
|
339
|
+
threshold,
|
|
340
|
+
message: `涨跌幅 ${pctStr} 超过阈值 ${threshold}%(策略: ${st.name})`,
|
|
341
|
+
created_at: new Date().toISOString(),
|
|
342
|
+
acknowledged: 0,
|
|
343
|
+
});
|
|
344
|
+
alertCount += 1;
|
|
345
|
+
alertLines.push(
|
|
346
|
+
`- **${symbol}**: ${pctStr} (阈值 ${threshold}%) — 策略: ${st.name}\n - 当前收盘: ${lastPrice.toFixed(6)},上一根收盘: ${prevPrice.toFixed(6)}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
okLines.push(`- ${symbol}: ${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const title = `## 价格监控报告 — ${now.slice(0, 16).replace("T", " ")}`;
|
|
355
|
+
const body: string[] = [title, ""];
|
|
356
|
+
if (alertLines.length > 0) {
|
|
357
|
+
body.push("### 告警");
|
|
358
|
+
body.push(...alertLines);
|
|
359
|
+
body.push("");
|
|
360
|
+
} else {
|
|
361
|
+
body.push("### 告警");
|
|
362
|
+
body.push("(本窗口无触及阈值的标的)");
|
|
363
|
+
body.push("");
|
|
364
|
+
}
|
|
365
|
+
body.push("### 正常 / 其他");
|
|
366
|
+
body.push(...okLines);
|
|
367
|
+
|
|
368
|
+
const markdown = body.join("\n");
|
|
369
|
+
|
|
370
|
+
updateScanHistory(getDb(), scanId, {
|
|
371
|
+
status: "completed",
|
|
372
|
+
completed_at: new Date().toISOString(),
|
|
373
|
+
strategies_scanned: strategies.length,
|
|
374
|
+
actions_taken: alertCount,
|
|
375
|
+
summary: `Price monitor: ${alertCount} alert(s), ${symbolToStrategies.size} symbol(s).`,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
content: [{ type: "text" as const, text: markdown }],
|
|
380
|
+
details: {
|
|
381
|
+
success: true,
|
|
382
|
+
alertCount,
|
|
383
|
+
symbolsChecked: symbolToStrategies.size,
|
|
384
|
+
strategiesScanned: strategies.length,
|
|
385
|
+
threshold,
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
} catch (err) {
|
|
389
|
+
updateScanHistory(getDb(), scanId, {
|
|
390
|
+
status: "failed",
|
|
391
|
+
completed_at: new Date().toISOString(),
|
|
392
|
+
summary: `Price monitor failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
393
|
+
});
|
|
394
|
+
return json({
|
|
395
|
+
success: false,
|
|
396
|
+
error: err instanceof Error ? err.message : String(err),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
),
|
|
401
|
+
},
|
|
402
|
+
{ names: ["strategy_price_monitor"] },
|
|
403
|
+
);
|
|
404
|
+
|
|
194
405
|
// ── strategy_scan_history ──
|
|
195
406
|
api.registerTool(
|
|
196
407
|
{
|
|
@@ -200,57 +411,166 @@ export function registerSchedulerTools(
|
|
|
200
411
|
parameters: Type.Object({
|
|
201
412
|
scanType: Type.Optional(
|
|
202
413
|
Type.String({
|
|
203
|
-
description:
|
|
204
|
-
"Filter by type: daily_scan, price_monitor, weekly_report, monthly_report",
|
|
414
|
+
description: "Filter by type: daily_scan, price_monitor, weekly_report, monthly_report",
|
|
205
415
|
}),
|
|
206
416
|
),
|
|
207
417
|
limit: Type.Optional(Type.Number({ description: "Max results (default 10)" })),
|
|
208
418
|
}),
|
|
209
|
-
execute: withLogging(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
419
|
+
execute: withLogging(
|
|
420
|
+
getDb,
|
|
421
|
+
"strategy_scan_history",
|
|
422
|
+
"scheduler",
|
|
423
|
+
async (_toolCallId, params) => {
|
|
424
|
+
try {
|
|
425
|
+
const db = getDb();
|
|
426
|
+
const entries = queryScanHistory(db, {
|
|
427
|
+
scanType: params.scanType ? String(params.scanType) : undefined,
|
|
428
|
+
limit: Number(params.limit) || 10,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (entries.length === 0) {
|
|
432
|
+
return {
|
|
433
|
+
content: [
|
|
434
|
+
{
|
|
435
|
+
type: "text" as const,
|
|
436
|
+
text: "暂无扫描记录。调用 strategy_daily_scan 执行首次扫描。",
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
details: { success: true, entries: [] },
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const lines: string[] = [];
|
|
444
|
+
lines.push(`## 扫描历史 (最近 ${entries.length} 条)`);
|
|
445
|
+
lines.push("");
|
|
446
|
+
|
|
447
|
+
for (const e of entries) {
|
|
448
|
+
const date = e.started_at.slice(0, 16).replace("T", " ");
|
|
449
|
+
const status =
|
|
450
|
+
e.status === "completed" ? "OK" : e.status === "failed" ? "FAIL" : "...";
|
|
451
|
+
lines.push(
|
|
452
|
+
`- [${status}] ${date} | ${e.scan_type} | 策略: ${e.strategies_scanned} | 新闻: ${e.news_found}`,
|
|
453
|
+
);
|
|
454
|
+
if (e.summary) lines.push(` ${e.summary}`);
|
|
455
|
+
}
|
|
216
456
|
|
|
217
|
-
if (entries.length === 0) {
|
|
218
457
|
return {
|
|
219
|
-
content: [
|
|
220
|
-
|
|
221
|
-
type: "text" as const,
|
|
222
|
-
text: "暂无扫描记录。调用 strategy_daily_scan 执行首次扫描。",
|
|
223
|
-
},
|
|
224
|
-
],
|
|
225
|
-
details: { success: true, entries: [] },
|
|
458
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
459
|
+
details: { success: true, entries },
|
|
226
460
|
};
|
|
461
|
+
} catch (err) {
|
|
462
|
+
return json({
|
|
463
|
+
success: false,
|
|
464
|
+
error: err instanceof Error ? err.message : String(err),
|
|
465
|
+
});
|
|
227
466
|
}
|
|
467
|
+
},
|
|
468
|
+
),
|
|
469
|
+
},
|
|
470
|
+
{ names: ["strategy_scan_history"] },
|
|
471
|
+
);
|
|
228
472
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
473
|
+
// ── strategy_periodic_report ──
|
|
474
|
+
api.registerTool(
|
|
475
|
+
{
|
|
476
|
+
name: "strategy_periodic_report",
|
|
477
|
+
label: "Strategy periodic report",
|
|
478
|
+
description:
|
|
479
|
+
"Build a weekly (7-day rolling) or monthly (30-day rolling) Markdown report: " +
|
|
480
|
+
"backtest ranking snapshot, scan_history counts by type, and price alert count.",
|
|
481
|
+
parameters: Type.Object({
|
|
482
|
+
period: Type.String({
|
|
483
|
+
enum: ["weekly", "monthly"],
|
|
484
|
+
description: "weekly = past 7×24h from now; monthly = past 30×24h from now (rolling)",
|
|
485
|
+
}),
|
|
486
|
+
}),
|
|
487
|
+
execute: withLogging(
|
|
488
|
+
getDb,
|
|
489
|
+
"strategy_periodic_report",
|
|
490
|
+
"scheduler",
|
|
491
|
+
async (_toolCallId, params) => {
|
|
492
|
+
const period = params.period === "monthly" ? "monthly" : "weekly";
|
|
493
|
+
const scanId = randomUUID();
|
|
494
|
+
const now = new Date();
|
|
495
|
+
const start = new Date(now.getTime());
|
|
496
|
+
const days = period === "weekly" ? 7 : 30;
|
|
497
|
+
start.setTime(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
498
|
+
const startIso = start.toISOString();
|
|
499
|
+
const endIso = now.toISOString();
|
|
500
|
+
const scanType = period === "weekly" ? "weekly_report" : "monthly_report";
|
|
232
501
|
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
);
|
|
239
|
-
if (e.summary) lines.push(` ${e.summary}`);
|
|
240
|
-
}
|
|
502
|
+
try {
|
|
503
|
+
const db = getDb();
|
|
504
|
+
// Stats before persisting this run's scan_history row so the report does not count itself.
|
|
505
|
+
const scanCounts = countScanHistoryByTypeSince(db, startIso);
|
|
506
|
+
const priceAlertCount = countPriceAlertsSince(db, startIso);
|
|
507
|
+
const strategies = queryStrategies(db);
|
|
241
508
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
509
|
+
const rankRows = strategies.map((s) => {
|
|
510
|
+
const bts = queryBacktestResults(db, s.id);
|
|
511
|
+
const latest = bts[0];
|
|
512
|
+
return {
|
|
513
|
+
strategyId: s.id,
|
|
514
|
+
strategyName: s.name,
|
|
515
|
+
totalReturn: latest?.total_return ?? null,
|
|
516
|
+
sharpe: latest?.sharpe ?? null,
|
|
517
|
+
maxDrawdown: latest?.max_drawdown ?? null,
|
|
518
|
+
};
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const markdown = formatPeriodicReportMarkdown({
|
|
522
|
+
period,
|
|
523
|
+
windowStartIso: startIso,
|
|
524
|
+
windowEndIso: endIso,
|
|
525
|
+
scanCountsByType: scanCounts,
|
|
526
|
+
priceAlertCount,
|
|
527
|
+
rankRows,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const completedAt = new Date().toISOString();
|
|
531
|
+
insertScanHistory(getDb(), {
|
|
532
|
+
id: scanId,
|
|
533
|
+
scan_type: scanType,
|
|
534
|
+
started_at: endIso,
|
|
535
|
+
completed_at: completedAt,
|
|
536
|
+
status: "completed",
|
|
537
|
+
strategies_scanned: strategies.length,
|
|
538
|
+
news_found: 0,
|
|
539
|
+
actions_taken: 0,
|
|
540
|
+
summary: `${period} report: ${strategies.length} strategies, ${priceAlertCount} alerts in window.`,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
content: [{ type: "text" as const, text: markdown }],
|
|
545
|
+
details: {
|
|
546
|
+
success: true,
|
|
547
|
+
period,
|
|
548
|
+
windowStartIso: startIso,
|
|
549
|
+
windowEndIso: endIso,
|
|
550
|
+
strategyCount: strategies.length,
|
|
551
|
+
priceAlertCount,
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
} catch (err) {
|
|
555
|
+
insertScanHistory(getDb(), {
|
|
556
|
+
id: scanId,
|
|
557
|
+
scan_type: scanType,
|
|
558
|
+
started_at: endIso,
|
|
559
|
+
completed_at: new Date().toISOString(),
|
|
560
|
+
status: "failed",
|
|
561
|
+
strategies_scanned: 0,
|
|
562
|
+
news_found: 0,
|
|
563
|
+
actions_taken: 0,
|
|
564
|
+
summary: `Periodic report failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
565
|
+
});
|
|
566
|
+
return json({
|
|
567
|
+
success: false,
|
|
568
|
+
error: err instanceof Error ? err.message : String(err),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
),
|
|
253
573
|
},
|
|
254
|
-
{ names: ["
|
|
574
|
+
{ names: ["strategy_periodic_report"] },
|
|
255
575
|
);
|
|
256
576
|
}
|