@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 CHANGED
@@ -77,7 +77,7 @@ describe("openfinclaw plugin", () => {
77
77
  expect.objectContaining({
78
78
  path: "/plugins/openfinclaw",
79
79
  match: "prefix",
80
- auth: "gateway",
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(7);
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 type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
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
- const openfinclawPlugin = {
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: OpenClawPluginApi) {
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 registered: ${result.created} created, ${result.existing} existing`,
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
+ });
@@ -23,7 +23,7 @@
23
23
  "datahubGatewayUrl": {
24
24
  "type": "string",
25
25
  "description": "DataHub Gateway URL for market data",
26
- "default": "http://43.134.61.136:9080"
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": "http://43.134.61.136:9080"
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.276",
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 = "http://43.134.61.136:9080";
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,
@@ -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: { scanType?: string; limit?: number; offset?: number } = {},
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
- return db
364
- .prepare(
365
- `SELECT * FROM scan_history WHERE scan_type = ? ORDER BY started_at DESC LIMIT ? OFFSET ?`,
366
- )
367
- .all(scanType, limit, offset) as ScanHistoryEntry[];
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(limit, offset) as ScanHistoryEntry[];
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: { strategyId?: string; limit?: number; offset?: number } = {},
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
- return db
419
- .prepare(
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(limit, offset) as PriceAlertEntry[];
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,6 +1,6 @@
1
1
  /**
2
2
  * SQLite schema for OpenFinClaw plugin (MVP 4 tables).
3
- * Based on ER diagram v0.3 — openfinclaw-opc-fund-plugin.
3
+ * Based on ER diagram v0.3.
4
4
  */
5
5
  import type { DatabaseSync } from "node:sqlite";
6
6
 
@@ -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: safe to call multiple times (skips existing jobs by name).
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: { kind: "systemEvent"; text: string };
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: { kind: "systemEvent"; text: string };
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
- payload: {
92
- kind: "systemEvent",
93
- text: [
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
- payload: {
107
- kind: "systemEvent",
108
- text: [
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
- payload: {
119
- kind: "systemEvent",
120
- text: [
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
- payload: {
131
- kind: "systemEvent",
132
- text: [
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
- * Idempotently register openfinclaw cron jobs by writing to the
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: "main",
231
+ sessionTarget: def.sessionTarget,
167
232
  wakeMode: "now",
168
- delivery: { mode: "none" },
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
- if (created > 0) {
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
+ }
@@ -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) */