@openfinclaw/openfinclaw-strategy 2026.3.276 → 2026.4.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/index.test.ts +4 -2
- package/index.ts +7 -10
- package/openclaw.plugin.json +12 -2
- package/package.json +1 -1
- package/src/config.ts +27 -1
- package/src/db/repositories.ts +67 -20
- package/src/db/schema.ts +1 -1
- package/src/scheduler/cron-setup.ts +93 -26
- package/src/scheduler/periodic-report-builder.ts +71 -0
- package/src/scheduler/tools.ts +317 -2
- package/src/types.ts +6 -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";
|
|
@@ -21,14 +21,13 @@ import { AggregatedNewsProvider, createNewsProviders } from "./src/scheduler/new
|
|
|
21
21
|
import { registerSchedulerTools } from "./src/scheduler/tools.js";
|
|
22
22
|
import { registerStrategyTools } from "./src/strategy/tools.js";
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
export default definePluginEntry({
|
|
25
25
|
id: "openfinclaw-strategy",
|
|
26
26
|
name: "OpenFinClaw",
|
|
27
27
|
description:
|
|
28
28
|
"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
29
|
|
|
31
|
-
register(api
|
|
30
|
+
register(api) {
|
|
32
31
|
const config = resolvePluginConfig(api);
|
|
33
32
|
|
|
34
33
|
// Register DataHub market data tools (fin_price, fin_kline, fin_crypto, fin_compare, fin_slim_search)
|
|
@@ -38,7 +37,7 @@ const openfinclawPlugin = {
|
|
|
38
37
|
// Register strategy tools (skill_publish, skill_validate, skill_fork, skill_leaderboard, etc.)
|
|
39
38
|
registerStrategyTools(api, config, getDb);
|
|
40
39
|
|
|
41
|
-
// Register scheduler tools (strategy_daily_scan, strategy_scan_history)
|
|
40
|
+
// Register scheduler tools (strategy_daily_scan, strategy_price_monitor, strategy_scan_history, strategy_periodic_report)
|
|
42
41
|
const newsProviders = createNewsProviders(config);
|
|
43
42
|
const newsProvider = new AggregatedNewsProvider(newsProviders);
|
|
44
43
|
registerSchedulerTools(api, config, getDb, newsProvider);
|
|
@@ -82,9 +81,9 @@ const openfinclawPlugin = {
|
|
|
82
81
|
if (config.schedulerEnabled) {
|
|
83
82
|
setupOpenfinclawCronJobs(config)
|
|
84
83
|
.then((result) => {
|
|
85
|
-
if (result.created > 0) {
|
|
84
|
+
if (result.created > 0 || result.migrated > 0) {
|
|
86
85
|
api.logger.info(
|
|
87
|
-
`[OpenFinClaw] Cron jobs
|
|
86
|
+
`[OpenFinClaw] Cron jobs: ${result.created} created, ${result.migrated} migrated, ${result.existing} existing`,
|
|
88
87
|
);
|
|
89
88
|
}
|
|
90
89
|
})
|
|
@@ -95,6 +94,4 @@ const openfinclawPlugin = {
|
|
|
95
94
|
});
|
|
96
95
|
}
|
|
97
96
|
},
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
export default openfinclawPlugin;
|
|
97
|
+
});
|
package/openclaw.plugin.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"datahubGatewayUrl": {
|
|
24
24
|
"type": "string",
|
|
25
25
|
"description": "DataHub Gateway URL for market data",
|
|
26
|
-
"default": "
|
|
26
|
+
"default": "https://datahub.openfinclaw.ai"
|
|
27
27
|
},
|
|
28
28
|
"requestTimeoutMs": {
|
|
29
29
|
"type": "number",
|
|
@@ -69,6 +69,16 @@
|
|
|
69
69
|
"default": "Asia/Shanghai",
|
|
70
70
|
"description": "Timezone for scheduled tasks"
|
|
71
71
|
},
|
|
72
|
+
"cronSessionTarget": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"default": "isolated",
|
|
75
|
+
"description": "Cron job session mode: 'isolated' (own session, won't block main chat) or 'main' (legacy, runs in main conversation)"
|
|
76
|
+
},
|
|
77
|
+
"cronDeliveryMode": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"default": "announce",
|
|
80
|
+
"description": "Cron result delivery: 'announce' (push to user's last active channel) or 'none' (silent)"
|
|
81
|
+
},
|
|
72
82
|
"newsApiKey": {
|
|
73
83
|
"type": "string",
|
|
74
84
|
"description": "Optional API key for Finnhub or NewsAPI news provider",
|
|
@@ -101,7 +111,7 @@
|
|
|
101
111
|
},
|
|
102
112
|
"datahubGatewayUrl": {
|
|
103
113
|
"label": "DataHub Gateway URL",
|
|
104
|
-
"placeholder": "
|
|
114
|
+
"placeholder": "https://datahub.openfinclaw.ai"
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfinclaw/openfinclaw-strategy",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.4.2",
|
|
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/config.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
|
6
6
|
import type { NewsProviderType, UnifiedPluginConfig } from "./types.js";
|
|
7
7
|
|
|
8
8
|
const DEFAULT_HUB_API_URL = "https://hub.openfinclaw.ai";
|
|
9
|
-
const DEFAULT_DATAHUB_GATEWAY_URL = "
|
|
9
|
+
const DEFAULT_DATAHUB_GATEWAY_URL = "https://datahub.openfinclaw.ai";
|
|
10
10
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
11
11
|
|
|
12
12
|
const DEFAULT_SCAN_CRON = "0 8 * * *";
|
|
@@ -92,6 +92,29 @@ export function resolvePluginConfig(api: OpenClawPluginApi): UnifiedPluginConfig
|
|
|
92
92
|
readEnv(["OPENFINCLAW_SCAN_TZ"]) ??
|
|
93
93
|
DEFAULT_SCAN_TZ;
|
|
94
94
|
|
|
95
|
+
const cronAgentIdRaw =
|
|
96
|
+
(typeof raw?.cronAgentId === "string" ? raw.cronAgentId : undefined) ??
|
|
97
|
+
readEnv(["OPENFINCLAW_CRON_AGENT_ID"]);
|
|
98
|
+
const cronAgentId = cronAgentIdRaw?.trim() || undefined;
|
|
99
|
+
|
|
100
|
+
const VALID_SESSION_TARGETS = new Set(["isolated", "main"]);
|
|
101
|
+
const cronSessionTargetRaw =
|
|
102
|
+
(typeof raw?.cronSessionTarget === "string" ? raw.cronSessionTarget : undefined) ??
|
|
103
|
+
readEnv(["OPENFINCLAW_CRON_SESSION_TARGET"]) ??
|
|
104
|
+
"isolated";
|
|
105
|
+
const cronSessionTarget = VALID_SESSION_TARGETS.has(cronSessionTargetRaw)
|
|
106
|
+
? cronSessionTargetRaw
|
|
107
|
+
: "isolated";
|
|
108
|
+
|
|
109
|
+
const VALID_DELIVERY_MODES = new Set(["announce", "none"]);
|
|
110
|
+
const cronDeliveryModeRaw =
|
|
111
|
+
(typeof raw?.cronDeliveryMode === "string" ? raw.cronDeliveryMode : undefined) ??
|
|
112
|
+
readEnv(["OPENFINCLAW_CRON_DELIVERY_MODE"]) ??
|
|
113
|
+
"announce";
|
|
114
|
+
const cronDeliveryMode = VALID_DELIVERY_MODES.has(cronDeliveryModeRaw)
|
|
115
|
+
? cronDeliveryModeRaw
|
|
116
|
+
: "announce";
|
|
117
|
+
|
|
95
118
|
// ── News config ──
|
|
96
119
|
const newsApiKey =
|
|
97
120
|
(typeof raw?.newsApiKey === "string" ? raw.newsApiKey : undefined) ??
|
|
@@ -124,6 +147,9 @@ export function resolvePluginConfig(api: OpenClawPluginApi): UnifiedPluginConfig
|
|
|
124
147
|
weeklyReportCronExpr,
|
|
125
148
|
monthlyReportCronExpr,
|
|
126
149
|
scanTimezone,
|
|
150
|
+
cronAgentId,
|
|
151
|
+
cronSessionTarget,
|
|
152
|
+
cronDeliveryMode,
|
|
127
153
|
newsApiKey: newsApiKey && newsApiKey.length > 0 ? newsApiKey : undefined,
|
|
128
154
|
newsProvider,
|
|
129
155
|
priceAlertThreshold,
|
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
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* The running CronService watches the store file and picks up new jobs
|
|
9
9
|
* on its next tick.
|
|
10
10
|
*
|
|
11
|
-
* Idempotent:
|
|
11
|
+
* Idempotent: creates missing jobs and migrates legacy "main" session jobs
|
|
12
|
+
* to "isolated" so cron tasks run in their own session without blocking the
|
|
13
|
+
* user's active conversation.
|
|
12
14
|
*
|
|
13
15
|
* Jobs:
|
|
14
16
|
* - openfinclaw:daily-scan (0 8 * * *) 每日策略扫描
|
|
@@ -23,16 +25,21 @@ import type { UnifiedPluginConfig } from "../types.js";
|
|
|
23
25
|
|
|
24
26
|
// ── Cron store types (mirrors src/cron/types.ts subset) ──────────────────
|
|
25
27
|
|
|
28
|
+
type CronPayload =
|
|
29
|
+
| { kind: "systemEvent"; text: string }
|
|
30
|
+
| { kind: "agentTurn"; message: string };
|
|
31
|
+
|
|
26
32
|
/** Minimal stored cron job shape. */
|
|
27
33
|
interface StoredCronJob {
|
|
28
34
|
id: string;
|
|
29
35
|
name: string;
|
|
36
|
+
agentId?: string;
|
|
30
37
|
enabled: boolean;
|
|
31
38
|
schedule: { kind: "cron"; expr: string; tz?: string };
|
|
32
|
-
payload:
|
|
39
|
+
payload: CronPayload;
|
|
33
40
|
sessionTarget: string;
|
|
34
41
|
wakeMode: string;
|
|
35
|
-
delivery: { mode: string };
|
|
42
|
+
delivery: { mode: string; channel?: string; to?: string };
|
|
36
43
|
createdAtMs: number;
|
|
37
44
|
updatedAtMs: number;
|
|
38
45
|
state: Record<string, unknown>;
|
|
@@ -43,6 +50,14 @@ interface CronStoreFile {
|
|
|
43
50
|
jobs: StoredCronJob[];
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
/** Names managed by this plugin — used for migration detection. */
|
|
54
|
+
const OPENFINCLAW_JOB_NAMES = new Set([
|
|
55
|
+
"openfinclaw:daily-scan",
|
|
56
|
+
"openfinclaw:price-monitor",
|
|
57
|
+
"openfinclaw:weekly-report",
|
|
58
|
+
"openfinclaw:monthly-report",
|
|
59
|
+
]);
|
|
60
|
+
|
|
46
61
|
// ── File I/O ──────────────────────────────────────────────────────────────
|
|
47
62
|
|
|
48
63
|
/** Resolve default cron store path: ~/.openclaw/cron/jobs.json */
|
|
@@ -82,15 +97,26 @@ async function saveStore(storePath: string, store: CronStoreFile): Promise<void>
|
|
|
82
97
|
function buildCronJobDefs(config: UnifiedPluginConfig): Array<{
|
|
83
98
|
name: string;
|
|
84
99
|
schedule: { kind: "cron"; expr: string; tz?: string };
|
|
85
|
-
payload:
|
|
100
|
+
payload: CronPayload;
|
|
101
|
+
sessionTarget: string;
|
|
102
|
+
delivery: { mode: string };
|
|
86
103
|
}> {
|
|
104
|
+
const sessionTarget = config.cronSessionTarget;
|
|
105
|
+
const delivery = { mode: config.cronDeliveryMode };
|
|
106
|
+
const useAgentTurn = sessionTarget !== "main";
|
|
107
|
+
|
|
108
|
+
function makePayload(text: string): CronPayload {
|
|
109
|
+
return useAgentTurn ? { kind: "agentTurn", message: text } : { kind: "systemEvent", text };
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
return [
|
|
88
113
|
{
|
|
89
114
|
name: "openfinclaw:daily-scan",
|
|
90
115
|
schedule: { kind: "cron", expr: config.scanCronExpr, tz: config.scanTimezone },
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
116
|
+
sessionTarget,
|
|
117
|
+
delivery,
|
|
118
|
+
payload: makePayload(
|
|
119
|
+
[
|
|
94
120
|
"[openfinclaw-strategy 每日扫描]",
|
|
95
121
|
"1. 调用 strategy_daily_scan 获取策略扫描报告",
|
|
96
122
|
"2. 分析每条新闻对策略的影响(利好/利空/中性)",
|
|
@@ -98,56 +124,94 @@ function buildCronJobDefs(config: UnifiedPluginConfig): Array<{
|
|
|
98
124
|
"4. 用 skill_publish_verify 确认回测完成",
|
|
99
125
|
"5. 将分析报告和建议操作发送给用户",
|
|
100
126
|
].join("\n"),
|
|
101
|
-
|
|
127
|
+
),
|
|
102
128
|
},
|
|
103
129
|
{
|
|
104
130
|
name: "openfinclaw:price-monitor",
|
|
105
131
|
schedule: { kind: "cron", expr: config.monitorCronExpr, tz: config.scanTimezone },
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
sessionTarget,
|
|
133
|
+
delivery,
|
|
134
|
+
payload: makePayload(
|
|
135
|
+
[
|
|
109
136
|
"[openfinclaw-strategy 价格监控]",
|
|
110
137
|
"调用 strategy_price_monitor 检查所有策略标的的价格异动。",
|
|
111
138
|
"如有告警(涨跌幅超阈值),分析可能的原因并通知用户。",
|
|
112
139
|
].join("\n"),
|
|
113
|
-
|
|
140
|
+
),
|
|
114
141
|
},
|
|
115
142
|
{
|
|
116
143
|
name: "openfinclaw:weekly-report",
|
|
117
144
|
schedule: { kind: "cron", expr: config.weeklyReportCronExpr, tz: config.scanTimezone },
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
145
|
+
sessionTarget,
|
|
146
|
+
delivery,
|
|
147
|
+
payload: makePayload(
|
|
148
|
+
[
|
|
121
149
|
"[openfinclaw-strategy 周报]",
|
|
122
150
|
'调用 strategy_periodic_report(period="weekly") 生成策略绩效周报。',
|
|
123
151
|
"汇总本周回测结果、价格告警和扫描记录,发送给用户。",
|
|
124
152
|
].join("\n"),
|
|
125
|
-
|
|
153
|
+
),
|
|
126
154
|
},
|
|
127
155
|
{
|
|
128
156
|
name: "openfinclaw:monthly-report",
|
|
129
157
|
schedule: { kind: "cron", expr: config.monthlyReportCronExpr, tz: config.scanTimezone },
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
158
|
+
sessionTarget,
|
|
159
|
+
delivery,
|
|
160
|
+
payload: makePayload(
|
|
161
|
+
[
|
|
133
162
|
"[openfinclaw-strategy 月报]",
|
|
134
163
|
'调用 strategy_periodic_report(period="monthly") 生成策略绩效月报。',
|
|
135
164
|
"汇总本月回测结果、价格告警和扫描记录,发送给用户。",
|
|
136
165
|
].join("\n"),
|
|
137
|
-
|
|
166
|
+
),
|
|
138
167
|
},
|
|
139
168
|
];
|
|
140
169
|
}
|
|
141
170
|
|
|
171
|
+
// ── Migration ─────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Migrate legacy openfinclaw jobs that still use sessionTarget "main" +
|
|
175
|
+
* systemEvent payload to the new isolated agentTurn pattern.
|
|
176
|
+
* Returns the number of jobs migrated.
|
|
177
|
+
*/
|
|
178
|
+
function migrateLegacyJobs(
|
|
179
|
+
store: CronStoreFile,
|
|
180
|
+
defs: ReturnType<typeof buildCronJobDefs>,
|
|
181
|
+
): number {
|
|
182
|
+
const defsByName = new Map(defs.map((d) => [d.name, d]));
|
|
183
|
+
let migrated = 0;
|
|
184
|
+
|
|
185
|
+
for (const job of store.jobs) {
|
|
186
|
+
if (!OPENFINCLAW_JOB_NAMES.has(job.name)) continue;
|
|
187
|
+
if (job.sessionTarget !== "main") continue;
|
|
188
|
+
|
|
189
|
+
const def = defsByName.get(job.name);
|
|
190
|
+
if (!def) continue;
|
|
191
|
+
|
|
192
|
+
job.payload = def.payload;
|
|
193
|
+
job.sessionTarget = def.sessionTarget;
|
|
194
|
+
job.delivery = def.delivery;
|
|
195
|
+
job.updatedAtMs = Date.now();
|
|
196
|
+
migrated++;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return migrated;
|
|
200
|
+
}
|
|
201
|
+
|
|
142
202
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
143
203
|
|
|
144
204
|
/**
|
|
145
|
-
*
|
|
205
|
+
* Register and migrate openfinclaw cron jobs by writing to the
|
|
146
206
|
* Gateway cron store file. Safe to call at plugin startup.
|
|
207
|
+
*
|
|
208
|
+
* - Creates missing jobs with the current config (isolated session by default).
|
|
209
|
+
* - Migrates existing legacy "main" session jobs to "isolated" + "agentTurn"
|
|
210
|
+
* so cron tasks no longer block the user's active conversation.
|
|
147
211
|
*/
|
|
148
212
|
export async function setupOpenfinclawCronJobs(
|
|
149
213
|
config: UnifiedPluginConfig,
|
|
150
|
-
): Promise<{ ok: boolean; created: number; existing: number }> {
|
|
214
|
+
): Promise<{ ok: boolean; created: number; existing: number; migrated: number }> {
|
|
151
215
|
const storePath = defaultStorePath();
|
|
152
216
|
const store = await loadStore(storePath);
|
|
153
217
|
const existingNames = new Set(store.jobs.map((j) => j.name));
|
|
@@ -160,12 +224,13 @@ export async function setupOpenfinclawCronJobs(
|
|
|
160
224
|
store.jobs.push({
|
|
161
225
|
id: randomUUID(),
|
|
162
226
|
name: def.name,
|
|
227
|
+
agentId: config.cronAgentId,
|
|
163
228
|
enabled: true,
|
|
164
229
|
schedule: def.schedule,
|
|
165
230
|
payload: def.payload,
|
|
166
|
-
sessionTarget:
|
|
231
|
+
sessionTarget: def.sessionTarget,
|
|
167
232
|
wakeMode: "now",
|
|
168
|
-
delivery:
|
|
233
|
+
delivery: def.delivery,
|
|
169
234
|
createdAtMs: now,
|
|
170
235
|
updatedAtMs: now,
|
|
171
236
|
state: {},
|
|
@@ -173,9 +238,11 @@ export async function setupOpenfinclawCronJobs(
|
|
|
173
238
|
created++;
|
|
174
239
|
}
|
|
175
240
|
|
|
176
|
-
|
|
241
|
+
const migrated = migrateLegacyJobs(store, defs);
|
|
242
|
+
|
|
243
|
+
if (created > 0 || migrated > 0) {
|
|
177
244
|
await saveStore(storePath, store);
|
|
178
245
|
}
|
|
179
246
|
|
|
180
|
-
return { ok: true, created, existing: existingNames.size };
|
|
247
|
+
return { ok: true, created, existing: existingNames.size, migrated };
|
|
181
248
|
}
|
|
@@ -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
|
}
|
package/src/types.ts
CHANGED
|
@@ -552,6 +552,12 @@ export interface UnifiedPluginConfig {
|
|
|
552
552
|
monthlyReportCronExpr: string;
|
|
553
553
|
/** 调度时区 */
|
|
554
554
|
scanTimezone: string;
|
|
555
|
+
/** 定时任务绑定的专用 agent id;未设置时沿用默认 agent */
|
|
556
|
+
cronAgentId: string | undefined;
|
|
557
|
+
/** Cron session 模式:"isolated"(独立 session,不阻塞主对话)或 "main"(旧行为) */
|
|
558
|
+
cronSessionTarget: string;
|
|
559
|
+
/** Cron 投递模式:"announce"(推送到用户最近活跃频道)或 "none" */
|
|
560
|
+
cronDeliveryMode: string;
|
|
555
561
|
|
|
556
562
|
// ── 新闻源配置 ────────────────────────────────────────────
|
|
557
563
|
/** 外部新闻 API Key(Finnhub 或 NewsAPI) */
|