@openfinclaw/openfinclaw-strategy 2026.3.275 → 2026.3.276
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.ts +9 -7
- package/package.json +1 -1
- package/src/config.ts +3 -1
- package/src/db/repositories.ts +18 -3
- package/src/scheduler/news-provider.ts +1 -1
- package/src/scheduler/scan-report-builder.ts +6 -2
- package/src/scheduler/tools.ts +48 -43
- package/src/strategy/tools.ts +114 -105
package/index.ts
CHANGED
|
@@ -75,22 +75,24 @@ const openfinclawPlugin = {
|
|
|
75
75
|
}));
|
|
76
76
|
|
|
77
77
|
// ── Gateway Cron registration ──
|
|
78
|
-
//
|
|
78
|
+
// Write cron jobs directly to ~/.openclaw/cron/jobs.json during register().
|
|
79
|
+
// This ensures jobs are available immediately on both gateway startup AND
|
|
80
|
+
// hot-reload (plugin install without restart). The CronService picks up
|
|
81
|
+
// file changes on its next tick.
|
|
79
82
|
if (config.schedulerEnabled) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const result = await setupOpenfinclawCronJobs(config);
|
|
83
|
+
setupOpenfinclawCronJobs(config)
|
|
84
|
+
.then((result) => {
|
|
83
85
|
if (result.created > 0) {
|
|
84
86
|
api.logger.info(
|
|
85
87
|
`[OpenFinClaw] Cron jobs registered: ${result.created} created, ${result.existing} existing`,
|
|
86
88
|
);
|
|
87
89
|
}
|
|
88
|
-
}
|
|
90
|
+
})
|
|
91
|
+
.catch((err) => {
|
|
89
92
|
api.logger.info(
|
|
90
93
|
`[OpenFinClaw] Cron setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
91
94
|
);
|
|
92
|
-
}
|
|
93
|
-
});
|
|
95
|
+
});
|
|
94
96
|
}
|
|
95
97
|
},
|
|
96
98
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfinclaw/openfinclaw-strategy",
|
|
3
|
-
"version": "2026.3.
|
|
3
|
+
"version": "2026.3.276",
|
|
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
|
@@ -101,7 +101,9 @@ export function resolvePluginConfig(api: OpenClawPluginApi): UnifiedPluginConfig
|
|
|
101
101
|
(typeof raw?.newsProvider === "string" ? raw.newsProvider : undefined) ??
|
|
102
102
|
readEnv(["OPENFINCLAW_NEWS_PROVIDER"]) ??
|
|
103
103
|
"coingecko";
|
|
104
|
-
const newsProvider: NewsProviderType = VALID_NEWS_PROVIDERS.has(
|
|
104
|
+
const newsProvider: NewsProviderType = VALID_NEWS_PROVIDERS.has(
|
|
105
|
+
newsProviderRaw as NewsProviderType,
|
|
106
|
+
)
|
|
105
107
|
? (newsProviderRaw as NewsProviderType)
|
|
106
108
|
: "coingecko";
|
|
107
109
|
|
package/src/db/repositories.ts
CHANGED
|
@@ -325,7 +325,18 @@ export function insertScanHistory(db: DatabaseSync, entry: ScanHistoryEntry): vo
|
|
|
325
325
|
export function updateScanHistory(
|
|
326
326
|
db: DatabaseSync,
|
|
327
327
|
id: string,
|
|
328
|
-
patch: Partial<
|
|
328
|
+
patch: Partial<
|
|
329
|
+
Pick<
|
|
330
|
+
ScanHistoryEntry,
|
|
331
|
+
| "completed_at"
|
|
332
|
+
| "status"
|
|
333
|
+
| "strategies_scanned"
|
|
334
|
+
| "news_found"
|
|
335
|
+
| "actions_taken"
|
|
336
|
+
| "summary"
|
|
337
|
+
| "detail_json"
|
|
338
|
+
>
|
|
339
|
+
>,
|
|
329
340
|
): void {
|
|
330
341
|
try {
|
|
331
342
|
const sets: string[] = [];
|
|
@@ -350,7 +361,9 @@ export function queryScanHistory(
|
|
|
350
361
|
const { limit = 20, offset = 0, scanType } = opts;
|
|
351
362
|
if (scanType) {
|
|
352
363
|
return db
|
|
353
|
-
.prepare(
|
|
364
|
+
.prepare(
|
|
365
|
+
`SELECT * FROM scan_history WHERE scan_type = ? ORDER BY started_at DESC LIMIT ? OFFSET ?`,
|
|
366
|
+
)
|
|
354
367
|
.all(scanType, limit, offset) as ScanHistoryEntry[];
|
|
355
368
|
}
|
|
356
369
|
return db
|
|
@@ -403,7 +416,9 @@ export function queryPriceAlerts(
|
|
|
403
416
|
const { limit = 50, offset = 0, strategyId } = opts;
|
|
404
417
|
if (strategyId) {
|
|
405
418
|
return db
|
|
406
|
-
.prepare(
|
|
419
|
+
.prepare(
|
|
420
|
+
`SELECT * FROM price_alerts WHERE strategy_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
421
|
+
)
|
|
407
422
|
.all(strategyId, limit, offset) as PriceAlertEntry[];
|
|
408
423
|
}
|
|
409
424
|
return db
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { guessMarket } from "../datahub/client.js";
|
|
1
2
|
/**
|
|
2
3
|
* External news providers for strategy symbol monitoring.
|
|
3
4
|
*
|
|
@@ -10,7 +11,6 @@
|
|
|
10
11
|
*/
|
|
11
12
|
import type { MarketType } from "../types.js";
|
|
12
13
|
import type { NewsItem, NewsProvider, SymbolNewsResult } from "./types.js";
|
|
13
|
-
import { guessMarket } from "../datahub/client.js";
|
|
14
14
|
|
|
15
15
|
// ── CoinGecko Trending (no key required) ─────────────────────────────────
|
|
16
16
|
|
|
@@ -101,7 +101,9 @@ export function formatScanReportMarkdown(report: ScanReport): string {
|
|
|
101
101
|
for (const sd of entry.symbolData) {
|
|
102
102
|
if (sd.currentPrice != null) {
|
|
103
103
|
const change =
|
|
104
|
-
sd.priceChange24h != null
|
|
104
|
+
sd.priceChange24h != null
|
|
105
|
+
? ` (24h ${sd.priceChange24h >= 0 ? "+" : ""}${sd.priceChange24h.toFixed(1)}%)`
|
|
106
|
+
: "";
|
|
105
107
|
lines.push(`- ${sd.symbol} 当前价格: $${sd.currentPrice.toFixed(2)}${change}`);
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -127,7 +129,9 @@ export function formatScanReportMarkdown(report: ScanReport): string {
|
|
|
127
129
|
if (strategiesWithNews.length > 0) {
|
|
128
130
|
lines.push("### 需要关注的策略");
|
|
129
131
|
for (const e of strategiesWithNews) {
|
|
130
|
-
lines.push(
|
|
132
|
+
lines.push(
|
|
133
|
+
`- ${e.strategyName}: ${e.significantNewsCount} 条相关新闻,建议分析影响并考虑是否需要优化`,
|
|
134
|
+
);
|
|
131
135
|
}
|
|
132
136
|
} else {
|
|
133
137
|
lines.push("### 汇总");
|
package/src/scheduler/tools.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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
10
|
import {
|
|
10
11
|
insertScanHistory,
|
|
11
12
|
queryBacktestResults,
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
queryStrategies,
|
|
14
15
|
updateScanHistory,
|
|
15
16
|
} from "../db/repositories.js";
|
|
16
|
-
import { DataHubClient } from "../datahub/client.js";
|
|
17
17
|
import { withLogging } from "../middleware/with-logging.js";
|
|
18
18
|
import type { UnifiedPluginConfig } from "../types.js";
|
|
19
19
|
import type { AggregatedNewsProvider } from "./news-provider.js";
|
|
@@ -200,56 +200,61 @@ export function registerSchedulerTools(
|
|
|
200
200
|
parameters: Type.Object({
|
|
201
201
|
scanType: Type.Optional(
|
|
202
202
|
Type.String({
|
|
203
|
-
description:
|
|
204
|
-
"Filter by type: daily_scan, price_monitor, weekly_report, monthly_report",
|
|
203
|
+
description: "Filter by type: daily_scan, price_monitor, weekly_report, monthly_report",
|
|
205
204
|
}),
|
|
206
205
|
),
|
|
207
206
|
limit: Type.Optional(Type.Number({ description: "Max results (default 10)" })),
|
|
208
207
|
}),
|
|
209
|
-
execute: withLogging(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
208
|
+
execute: withLogging(
|
|
209
|
+
getDb,
|
|
210
|
+
"strategy_scan_history",
|
|
211
|
+
"scheduler",
|
|
212
|
+
async (_toolCallId, params) => {
|
|
213
|
+
try {
|
|
214
|
+
const db = getDb();
|
|
215
|
+
const entries = queryScanHistory(db, {
|
|
216
|
+
scanType: params.scanType ? String(params.scanType) : undefined,
|
|
217
|
+
limit: Number(params.limit) || 10,
|
|
218
|
+
});
|
|
216
219
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
220
|
+
if (entries.length === 0) {
|
|
221
|
+
return {
|
|
222
|
+
content: [
|
|
223
|
+
{
|
|
224
|
+
type: "text" as const,
|
|
225
|
+
text: "暂无扫描记录。调用 strategy_daily_scan 执行首次扫描。",
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
details: { success: true, entries: [] },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
228
231
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
const lines: string[] = [];
|
|
233
|
+
lines.push(`## 扫描历史 (最近 ${entries.length} 条)`);
|
|
234
|
+
lines.push("");
|
|
232
235
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
236
|
+
for (const e of entries) {
|
|
237
|
+
const date = e.started_at.slice(0, 16).replace("T", " ");
|
|
238
|
+
const status =
|
|
239
|
+
e.status === "completed" ? "OK" : e.status === "failed" ? "FAIL" : "...";
|
|
240
|
+
lines.push(
|
|
241
|
+
`- [${status}] ${date} | ${e.scan_type} | 策略: ${e.strategies_scanned} | 新闻: ${e.news_found}`,
|
|
242
|
+
);
|
|
243
|
+
if (e.summary) lines.push(` ${e.summary}`);
|
|
244
|
+
}
|
|
241
245
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
246
|
+
return {
|
|
247
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
248
|
+
details: { success: true, entries },
|
|
249
|
+
};
|
|
250
|
+
} catch (err) {
|
|
251
|
+
return json({
|
|
252
|
+
success: false,
|
|
253
|
+
error: err instanceof Error ? err.message : String(err),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
),
|
|
253
258
|
},
|
|
254
259
|
{ names: ["strategy_scan_history"] },
|
|
255
260
|
);
|
package/src/strategy/tools.ts
CHANGED
|
@@ -204,122 +204,131 @@ export function registerStrategyTools(
|
|
|
204
204
|
Type.String({ description: "Backtest task ID from skill_publish response" }),
|
|
205
205
|
),
|
|
206
206
|
}),
|
|
207
|
-
execute: withLogging(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
execute: withLogging(
|
|
208
|
+
getDb,
|
|
209
|
+
"skill_publish_verify",
|
|
210
|
+
"strategy",
|
|
211
|
+
async (_toolCallId, params) => {
|
|
212
|
+
try {
|
|
213
|
+
const submissionId = String(params.submissionId ?? "").trim() || undefined;
|
|
214
|
+
const backtestTaskId = String(params.backtestTaskId ?? "").trim() || undefined;
|
|
211
215
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
if (!submissionId && !backtestTaskId) {
|
|
217
|
+
return json({
|
|
218
|
+
success: false,
|
|
219
|
+
error: "Either submissionId or backtestTaskId is required",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
if (!config.apiKey) {
|
|
224
|
+
return json({
|
|
225
|
+
success: false,
|
|
226
|
+
error: NO_API_KEY,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
225
229
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
230
|
+
const searchParams: Record<string, string> = {};
|
|
231
|
+
if (submissionId) searchParams.submissionId = submissionId;
|
|
232
|
+
if (backtestTaskId) searchParams.backtestTaskId = backtestTaskId;
|
|
229
233
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
const { status, data } = await hubApiRequest(config, "GET", "/skill/publish/verify", {
|
|
235
|
+
searchParams,
|
|
236
|
+
});
|
|
233
237
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
typeof perf?.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
monthly_returns:
|
|
260
|
-
report?.monthly_returns && typeof report.monthly_returns === "object"
|
|
261
|
-
? JSON.stringify(report.monthly_returns)
|
|
238
|
+
if (status >= 200 && status < 300) {
|
|
239
|
+
const resp = data as Record<string, unknown>;
|
|
240
|
+
|
|
241
|
+
// Update backtest result metrics and strategy level
|
|
242
|
+
const verifyStrategyId = resp.entryId as string | undefined;
|
|
243
|
+
if (resp.backtestStatus === "completed" && backtestTaskId) {
|
|
244
|
+
const report = resp.backtestReport as Record<string, unknown> | undefined;
|
|
245
|
+
const perf = report?.performance as Record<string, unknown> | undefined;
|
|
246
|
+
updateBacktestResult(getDb(), backtestTaskId, {
|
|
247
|
+
status: "completed",
|
|
248
|
+
total_return:
|
|
249
|
+
typeof perf?.totalReturn === "number" ? perf.totalReturn : undefined,
|
|
250
|
+
sharpe: typeof perf?.sharpe === "number" ? perf.sharpe : undefined,
|
|
251
|
+
sortino: typeof perf?.sortino === "number" ? perf.sortino : undefined,
|
|
252
|
+
max_drawdown:
|
|
253
|
+
typeof perf?.maxDrawdown === "number" ? perf.maxDrawdown : undefined,
|
|
254
|
+
win_rate: typeof perf?.winRate === "number" ? perf.winRate : undefined,
|
|
255
|
+
profit_factor:
|
|
256
|
+
typeof perf?.profitFactor === "number" ? perf.profitFactor : undefined,
|
|
257
|
+
total_trades:
|
|
258
|
+
typeof perf?.totalTrades === "number" ? perf.totalTrades : undefined,
|
|
259
|
+
final_equity:
|
|
260
|
+
typeof perf?.finalEquity === "number" ? perf.finalEquity : undefined,
|
|
261
|
+
equity_curve: Array.isArray(report?.equity_curve)
|
|
262
|
+
? JSON.stringify(report.equity_curve)
|
|
262
263
|
: undefined,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
264
|
+
trade_journal: Array.isArray(report?.trade_journal)
|
|
265
|
+
? JSON.stringify(report.trade_journal)
|
|
266
|
+
: undefined,
|
|
267
|
+
monthly_returns:
|
|
268
|
+
report?.monthly_returns && typeof report.monthly_returns === "object"
|
|
269
|
+
? JSON.stringify(report.monthly_returns)
|
|
270
|
+
: undefined,
|
|
271
|
+
tearsheet_html:
|
|
272
|
+
typeof report?.tearsheet_html === "string" ? report.tearsheet_html : undefined,
|
|
273
|
+
completed_at: new Date().toISOString(),
|
|
274
|
+
});
|
|
275
|
+
// Backtest completed → strategy stays at L1
|
|
276
|
+
if (verifyStrategyId) updateStrategyLevel(getDb(), verifyStrategyId, "L1");
|
|
277
|
+
} else if (resp.backtestStatus === "failed" && backtestTaskId) {
|
|
278
|
+
updateBacktestResult(getDb(), backtestTaskId, {
|
|
279
|
+
status: "failed",
|
|
280
|
+
completed_at: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
// Backtest failed → revert to L0
|
|
283
|
+
if (verifyStrategyId) updateStrategyLevel(getDb(), verifyStrategyId, "L0");
|
|
284
|
+
}
|
|
283
285
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
lines.push(
|
|
297
|
-
|
|
298
|
-
|
|
286
|
+
const lines: string[] = [];
|
|
287
|
+
lines.push("发布验证结果:");
|
|
288
|
+
lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
|
|
289
|
+
lines.push(`- Version: ${resp.version ?? "(未知)"}`);
|
|
290
|
+
lines.push(`- Backtest Status: ${resp.backtestStatus ?? "(未知)"}`);
|
|
291
|
+
|
|
292
|
+
if (resp.backtestStatus === "completed" && resp.backtestReport) {
|
|
293
|
+
const perf = (resp.backtestReport as Record<string, unknown>).performance as
|
|
294
|
+
| Record<string, unknown>
|
|
295
|
+
| undefined;
|
|
296
|
+
if (perf) {
|
|
297
|
+
lines.push("");
|
|
298
|
+
lines.push("回测报告摘要:");
|
|
299
|
+
if (typeof perf.totalReturn === "number")
|
|
300
|
+
lines.push(`- 总收益率: ${(perf.totalReturn * 100).toFixed(2)}%`);
|
|
301
|
+
if (typeof perf.sharpe === "number")
|
|
302
|
+
lines.push(`- 夏普比率: ${perf.sharpe.toFixed(3)}`);
|
|
303
|
+
if (typeof perf.maxDrawdown === "number")
|
|
304
|
+
lines.push(`- 最大回撤: ${(perf.maxDrawdown * 100).toFixed(2)}%`);
|
|
305
|
+
if (typeof perf.winRate === "number")
|
|
306
|
+
lines.push(`- 胜率: ${(perf.winRate * 100).toFixed(1)}%`);
|
|
307
|
+
}
|
|
299
308
|
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
312
|
+
details: { success: true, ...resp },
|
|
313
|
+
};
|
|
300
314
|
}
|
|
301
315
|
|
|
302
|
-
return {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
316
|
+
return json({
|
|
317
|
+
success: false,
|
|
318
|
+
status,
|
|
319
|
+
error:
|
|
320
|
+
(data as { code?: string; message?: string })?.message ??
|
|
321
|
+
(data as { detail?: string })?.detail ??
|
|
322
|
+
data,
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
return json({
|
|
326
|
+
success: false,
|
|
327
|
+
error: err instanceof Error ? err.message : String(err),
|
|
328
|
+
});
|
|
306
329
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
success: false,
|
|
310
|
-
status,
|
|
311
|
-
error:
|
|
312
|
-
(data as { code?: string; message?: string })?.message ??
|
|
313
|
-
(data as { detail?: string })?.detail ??
|
|
314
|
-
data,
|
|
315
|
-
});
|
|
316
|
-
} catch (err) {
|
|
317
|
-
return json({
|
|
318
|
-
success: false,
|
|
319
|
-
error: err instanceof Error ? err.message : String(err),
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
}),
|
|
330
|
+
},
|
|
331
|
+
),
|
|
323
332
|
},
|
|
324
333
|
{ names: ["skill_publish_verify"] },
|
|
325
334
|
);
|