@openfinclaw/openfinclaw-strategy 2026.3.276 → 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 +28 -9
- package/package.json +1 -1
- package/src/db/repositories.ts +67 -20
- package/src/db/schema.ts +5 -1
- package/src/scheduler/periodic-report-builder.ts +71 -0
- package/src/scheduler/tools.ts +317 -2
- 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/index.test.ts
CHANGED
|
@@ -77,7 +77,7 @@ describe("openfinclaw plugin", () => {
|
|
|
77
77
|
expect.objectContaining({
|
|
78
78
|
path: "/plugins/openfinclaw",
|
|
79
79
|
match: "prefix",
|
|
80
|
-
auth: "
|
|
80
|
+
auth: "plugin",
|
|
81
81
|
}),
|
|
82
82
|
);
|
|
83
83
|
});
|
|
@@ -86,10 +86,12 @@ describe("openfinclaw plugin", () => {
|
|
|
86
86
|
const { api, tools } = createFakeApi({});
|
|
87
87
|
plugin.register(api);
|
|
88
88
|
|
|
89
|
-
expect(tools.size).toBeGreaterThanOrEqual(
|
|
89
|
+
expect(tools.size).toBeGreaterThanOrEqual(9);
|
|
90
90
|
expect(tools.has("skill_publish")).toBe(true);
|
|
91
91
|
expect(tools.has("skill_publish_verify")).toBe(true);
|
|
92
92
|
expect(tools.has("skill_validate")).toBe(true);
|
|
93
|
+
expect(tools.has("strategy_price_monitor")).toBe(true);
|
|
94
|
+
expect(tools.has("strategy_periodic_report")).toBe(true);
|
|
93
95
|
});
|
|
94
96
|
|
|
95
97
|
it("skill_publish_verify returns success when API returns 200 with submissionId", async () => {
|
package/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Command } from "commander";
|
|
|
8
8
|
* - Dashboard: embedded HTTP server at http://127.0.0.1:<httpPort> (default 18792)
|
|
9
9
|
* Supports FEP v2.0 protocol for strategy packages.
|
|
10
10
|
*/
|
|
11
|
-
import
|
|
11
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
12
12
|
import { registerStrategyCli } from "./src/cli.js";
|
|
13
13
|
import { resolvePluginConfig } from "./src/config.js";
|
|
14
14
|
import { registerDatahubTools } from "./src/datahub/tools.js";
|
|
@@ -20,15 +20,18 @@ import { setupOpenfinclawCronJobs } from "./src/scheduler/cron-setup.js";
|
|
|
20
20
|
import { AggregatedNewsProvider, createNewsProviders } from "./src/scheduler/news-provider.js";
|
|
21
21
|
import { registerSchedulerTools } from "./src/scheduler/tools.js";
|
|
22
22
|
import { registerStrategyTools } from "./src/strategy/tools.js";
|
|
23
|
+
import { setupTournamentCronJob } from "./src/tournament/cron-setup.js";
|
|
24
|
+
import { TournamentDb } from "./src/tournament/db.js";
|
|
25
|
+
import { buildOrchestratorPrompt } from "./src/tournament/prompts.js";
|
|
26
|
+
import { registerTournamentTools } from "./src/tournament/tools.js";
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
export default definePluginEntry({
|
|
25
29
|
id: "openfinclaw-strategy",
|
|
26
30
|
name: "OpenFinClaw",
|
|
27
31
|
description:
|
|
28
32
|
"Unified financial tools: strategy publishing/fork/validation, market data (price/K-line/crypto/compare/search). Single API key for Hub and DataHub.",
|
|
29
|
-
kind: "financial" as const,
|
|
30
33
|
|
|
31
|
-
register(api
|
|
34
|
+
register(api) {
|
|
32
35
|
const config = resolvePluginConfig(api);
|
|
33
36
|
|
|
34
37
|
// Register DataHub market data tools (fin_price, fin_kline, fin_crypto, fin_compare, fin_slim_search)
|
|
@@ -38,7 +41,7 @@ const openfinclawPlugin = {
|
|
|
38
41
|
// Register strategy tools (skill_publish, skill_validate, skill_fork, skill_leaderboard, etc.)
|
|
39
42
|
registerStrategyTools(api, config, getDb);
|
|
40
43
|
|
|
41
|
-
// Register scheduler tools (strategy_daily_scan, strategy_scan_history)
|
|
44
|
+
// Register scheduler tools (strategy_daily_scan, strategy_price_monitor, strategy_scan_history, strategy_periodic_report)
|
|
42
45
|
const newsProviders = createNewsProviders(config);
|
|
43
46
|
const newsProvider = new AggregatedNewsProvider(newsProviders);
|
|
44
47
|
registerSchedulerTools(api, config, getDb, newsProvider);
|
|
@@ -69,9 +72,14 @@ const openfinclawPlugin = {
|
|
|
69
72
|
handler: createOpenFinclawGatewayProxy({ port: config.httpPort, logger: api.logger }),
|
|
70
73
|
});
|
|
71
74
|
|
|
75
|
+
// Register tournament tools (tournament_pick, tournament_leaderboard, tournament_result)
|
|
76
|
+
const getTournamentDb = () => new TournamentDb(getDb());
|
|
77
|
+
registerTournamentTools(api.registerTool.bind(api), getTournamentDb);
|
|
78
|
+
|
|
72
79
|
// Inject agent system prompt: prioritise tool calls so data lands in SQLite
|
|
80
|
+
const tournamentPrompt = buildOrchestratorPrompt();
|
|
73
81
|
api.on("before_prompt_build", async () => ({
|
|
74
|
-
prependSystemContext: OPENFINCLAW_AGENT_GUIDANCE
|
|
82
|
+
prependSystemContext: `${OPENFINCLAW_AGENT_GUIDANCE}\n\n${tournamentPrompt}`,
|
|
75
83
|
}));
|
|
76
84
|
|
|
77
85
|
// ── Gateway Cron registration ──
|
|
@@ -93,8 +101,19 @@ const openfinclawPlugin = {
|
|
|
93
101
|
`[OpenFinClaw] Cron setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
94
102
|
);
|
|
95
103
|
});
|
|
104
|
+
|
|
105
|
+
// Register tournament cron job
|
|
106
|
+
setupTournamentCronJob()
|
|
107
|
+
.then((result) => {
|
|
108
|
+
if (result.created) {
|
|
109
|
+
api.logger.info("[OpenFinClaw] Tournament cron job registered");
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
.catch((err) => {
|
|
113
|
+
api.logger.info(
|
|
114
|
+
`[OpenFinClaw] Tournament cron setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
96
117
|
}
|
|
97
118
|
},
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
export default openfinclawPlugin;
|
|
119
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfinclaw/openfinclaw-strategy",
|
|
3
|
-
"version": "2026.3.
|
|
3
|
+
"version": "2026.3.310",
|
|
4
4
|
"description": "OpenFinClaw - Unified financial tools: market data (price/K-line/crypto/compare/search), strategy publishing, fork, and validation. Single API key for Hub and DataHub.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backtest",
|
package/src/db/repositories.ts
CHANGED
|
@@ -353,22 +353,51 @@ export function updateScanHistory(
|
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
-
/** Query scan history, newest first. Optionally filter by scan_type. */
|
|
356
|
+
/** Query scan history, newest first. Optionally filter by scan_type and started_at lower bound (ISO 8601). */
|
|
357
357
|
export function queryScanHistory(
|
|
358
358
|
db: DatabaseSync,
|
|
359
|
-
opts: {
|
|
359
|
+
opts: {
|
|
360
|
+
scanType?: string;
|
|
361
|
+
startedAfter?: string;
|
|
362
|
+
limit?: number;
|
|
363
|
+
offset?: number;
|
|
364
|
+
} = {},
|
|
360
365
|
): ScanHistoryEntry[] {
|
|
361
|
-
const { limit = 20, offset = 0, scanType } = opts;
|
|
366
|
+
const { limit = 20, offset = 0, scanType, startedAfter } = opts;
|
|
367
|
+
const conditions: string[] = [];
|
|
368
|
+
const params: unknown[] = [];
|
|
362
369
|
if (scanType) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
370
|
+
conditions.push("scan_type = ?");
|
|
371
|
+
params.push(scanType);
|
|
372
|
+
}
|
|
373
|
+
if (startedAfter) {
|
|
374
|
+
conditions.push("started_at >= ?");
|
|
375
|
+
params.push(startedAfter);
|
|
368
376
|
}
|
|
377
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
378
|
+
params.push(limit, offset);
|
|
369
379
|
return db
|
|
370
|
-
.prepare(`SELECT * FROM scan_history ORDER BY started_at DESC LIMIT ? OFFSET ?`)
|
|
371
|
-
.all(
|
|
380
|
+
.prepare(`SELECT * FROM scan_history ${where} ORDER BY started_at DESC LIMIT ? OFFSET ?`)
|
|
381
|
+
.all(...params) as ScanHistoryEntry[];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Count scan_history rows grouped by scan_type since an inclusive time bound (ISO 8601 started_at).
|
|
386
|
+
*/
|
|
387
|
+
export function countScanHistoryByTypeSince(
|
|
388
|
+
db: DatabaseSync,
|
|
389
|
+
startedAfter: string,
|
|
390
|
+
): Map<string, number> {
|
|
391
|
+
const rows = db
|
|
392
|
+
.prepare(
|
|
393
|
+
`SELECT scan_type, COUNT(*) AS cnt FROM scan_history WHERE started_at >= ? GROUP BY scan_type`,
|
|
394
|
+
)
|
|
395
|
+
.all(startedAfter) as Array<{ scan_type: string; cnt: number }>;
|
|
396
|
+
const map = new Map<string, number>();
|
|
397
|
+
for (const r of rows) {
|
|
398
|
+
map.set(r.scan_type, Number(r.cnt));
|
|
399
|
+
}
|
|
400
|
+
return map;
|
|
372
401
|
}
|
|
373
402
|
|
|
374
403
|
// ── Price Alerts ──────────────────────────────────────────────────────────
|
|
@@ -408,22 +437,40 @@ export function insertPriceAlert(db: DatabaseSync, alert: PriceAlertEntry): void
|
|
|
408
437
|
}
|
|
409
438
|
}
|
|
410
439
|
|
|
411
|
-
/** Query price alerts, newest first. */
|
|
440
|
+
/** Query price alerts, newest first. Optionally filter by strategy and created_at lower bound (ISO 8601). */
|
|
412
441
|
export function queryPriceAlerts(
|
|
413
442
|
db: DatabaseSync,
|
|
414
|
-
opts: {
|
|
443
|
+
opts: {
|
|
444
|
+
strategyId?: string;
|
|
445
|
+
createdAfter?: string;
|
|
446
|
+
limit?: number;
|
|
447
|
+
offset?: number;
|
|
448
|
+
} = {},
|
|
415
449
|
): PriceAlertEntry[] {
|
|
416
|
-
const { limit = 50, offset = 0, strategyId } = opts;
|
|
450
|
+
const { limit = 50, offset = 0, strategyId, createdAfter } = opts;
|
|
451
|
+
const conditions: string[] = [];
|
|
452
|
+
const params: unknown[] = [];
|
|
417
453
|
if (strategyId) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
`SELECT * FROM price_alerts WHERE strategy_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
421
|
-
)
|
|
422
|
-
.all(strategyId, limit, offset) as PriceAlertEntry[];
|
|
454
|
+
conditions.push("strategy_id = ?");
|
|
455
|
+
params.push(strategyId);
|
|
423
456
|
}
|
|
457
|
+
if (createdAfter) {
|
|
458
|
+
conditions.push("created_at >= ?");
|
|
459
|
+
params.push(createdAfter);
|
|
460
|
+
}
|
|
461
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
462
|
+
params.push(limit, offset);
|
|
424
463
|
return db
|
|
425
|
-
.prepare(`SELECT * FROM price_alerts ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
426
|
-
.all(
|
|
464
|
+
.prepare(`SELECT * FROM price_alerts ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
465
|
+
.all(...params) as PriceAlertEntry[];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Count price_alerts rows since an inclusive time bound (ISO 8601 created_at). */
|
|
469
|
+
export function countPriceAlertsSince(db: DatabaseSync, createdAfter: string): number {
|
|
470
|
+
const row = db
|
|
471
|
+
.prepare(`SELECT COUNT(*) AS cnt FROM price_alerts WHERE created_at >= ?`)
|
|
472
|
+
.get(createdAfter) as { cnt: number } | undefined;
|
|
473
|
+
return Number(row?.cnt ?? 0);
|
|
427
474
|
}
|
|
428
475
|
|
|
429
476
|
/** Mark a price alert as acknowledged. */
|
package/src/db/schema.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SQLite schema for OpenFinClaw plugin (MVP 4 tables).
|
|
3
|
-
* Based on ER diagram v0.3
|
|
3
|
+
* Based on ER diagram v0.3.
|
|
4
4
|
*/
|
|
5
5
|
import type { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { ensureTournamentSchema } from "../tournament/db.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Create (or migrate) all MVP tables.
|
|
@@ -134,6 +135,9 @@ export function ensureSchema(db: DatabaseSync): void {
|
|
|
134
135
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_price_alerts_created ON price_alerts(created_at);`);
|
|
135
136
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_price_alerts_strategy ON price_alerts(strategy_id);`);
|
|
136
137
|
|
|
138
|
+
// ── tournament tables ──────────────────────────────────────────────────
|
|
139
|
+
ensureTournamentSchema(db);
|
|
140
|
+
|
|
137
141
|
// ── migrations (add columns to existing databases) ──────────────────────
|
|
138
142
|
}
|
|
139
143
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown builder for `strategy_periodic_report` (weekly / monthly rolling windows).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** One row for the performance ranking table. */
|
|
6
|
+
export interface StrategyRankRow {
|
|
7
|
+
strategyId: string;
|
|
8
|
+
strategyName: string;
|
|
9
|
+
totalReturn: number | null;
|
|
10
|
+
sharpe: number | null;
|
|
11
|
+
maxDrawdown: number | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Inputs collected by the tool before formatting. */
|
|
15
|
+
export interface PeriodicReportPayload {
|
|
16
|
+
period: "weekly" | "monthly";
|
|
17
|
+
windowStartIso: string;
|
|
18
|
+
windowEndIso: string;
|
|
19
|
+
scanCountsByType: Map<string, number>;
|
|
20
|
+
priceAlertCount: number;
|
|
21
|
+
rankRows: StrategyRankRow[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Format a periodic performance report as Markdown for the agent.
|
|
26
|
+
*
|
|
27
|
+
* @param payload - Aggregated stats and ranking rows for the reporting window.
|
|
28
|
+
* @returns Markdown string (zh headings, decimal returns as percent like daily scan).
|
|
29
|
+
*/
|
|
30
|
+
export function formatPeriodicReportMarkdown(payload: PeriodicReportPayload): string {
|
|
31
|
+
const periodLabel = payload.period === "weekly" ? "周报" : "月报";
|
|
32
|
+
const start = payload.windowStartIso.slice(0, 10);
|
|
33
|
+
const end = payload.windowEndIso.slice(0, 10);
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
|
|
36
|
+
lines.push(`## 策略绩效${periodLabel} — ${start} ~ ${end}`);
|
|
37
|
+
lines.push("");
|
|
38
|
+
|
|
39
|
+
lines.push("### 绩效排名");
|
|
40
|
+
lines.push("");
|
|
41
|
+
lines.push("| 排名 | 策略 | 总收益 | 夏普 | 最大回撤 |");
|
|
42
|
+
lines.push("|------|------|--------|------|----------|");
|
|
43
|
+
|
|
44
|
+
const sorted = [...payload.rankRows].sort((a, b) => {
|
|
45
|
+
const ar = a.totalReturn ?? Number.NEGATIVE_INFINITY;
|
|
46
|
+
const br = b.totalReturn ?? Number.NEGATIVE_INFINITY;
|
|
47
|
+
return br - ar;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
sorted.forEach((row, i) => {
|
|
51
|
+
const ret = row.totalReturn == null ? "—" : `${(row.totalReturn * 100).toFixed(2)}%`;
|
|
52
|
+
const sharpe = row.sharpe == null ? "—" : row.sharpe.toFixed(2);
|
|
53
|
+
const dd = row.maxDrawdown == null ? "—" : `${(row.maxDrawdown * 100).toFixed(2)}%`;
|
|
54
|
+
lines.push(`| ${i + 1} | ${row.strategyName} | ${ret} | ${sharpe} | ${dd} |`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
lines.push("");
|
|
58
|
+
lines.push("### 活动统计");
|
|
59
|
+
const keys = [...payload.scanCountsByType.keys()].sort();
|
|
60
|
+
if (keys.length === 0) {
|
|
61
|
+
lines.push("- (窗口内无扫描记录)");
|
|
62
|
+
} else {
|
|
63
|
+
for (const k of keys) {
|
|
64
|
+
lines.push(`- ${k}: ${payload.scanCountsByType.get(k)} 次`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
lines.push(`- 价格告警: ${payload.priceAlertCount} 条`);
|
|
68
|
+
lines.push("");
|
|
69
|
+
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
package/src/scheduler/tools.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
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 } from "../datahub/client.js";
|
|
9
|
+
import { DataHubClient, guessMarket } from "../datahub/client.js";
|
|
10
10
|
import {
|
|
11
|
+
countPriceAlertsSince,
|
|
12
|
+
countScanHistoryByTypeSince,
|
|
13
|
+
insertPriceAlert,
|
|
11
14
|
insertScanHistory,
|
|
12
15
|
queryBacktestResults,
|
|
13
16
|
queryScanHistory,
|
|
@@ -17,6 +20,7 @@ import {
|
|
|
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
|
{
|
|
@@ -258,4 +469,108 @@ export function registerSchedulerTools(
|
|
|
258
469
|
},
|
|
259
470
|
{ names: ["strategy_scan_history"] },
|
|
260
471
|
);
|
|
472
|
+
|
|
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";
|
|
501
|
+
|
|
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);
|
|
508
|
+
|
|
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
|
+
),
|
|
573
|
+
},
|
|
574
|
+
{ names: ["strategy_periodic_report"] },
|
|
575
|
+
);
|
|
261
576
|
}
|