@openfinclaw/openfinclaw-strategy 2026.3.26 → 2026.3.27
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 +12 -2
- package/openclaw.plugin.json +7 -0
- package/package.json +2 -2
- package/src/config.ts +8 -0
- package/src/datahub/tools.ts +19 -12
- package/src/db/db.ts +44 -0
- package/src/db/repositories.ts +315 -0
- package/src/db/schema.ts +110 -0
- package/src/http/routes.ts +107 -0
- package/src/http/server.ts +36 -0
- package/src/middleware/with-logging.ts +72 -0
- package/src/strategy/tools.ts +213 -80
- package/src/types.ts +2 -0
package/index.ts
CHANGED
|
@@ -4,12 +4,16 @@ import type { Command } from "commander";
|
|
|
4
4
|
* Features:
|
|
5
5
|
* - Strategy tools: publish, validate, fork, leaderboard
|
|
6
6
|
* - Market data tools: price, K-line, crypto data, compare, search
|
|
7
|
+
* - SQLite persistence: all tool executions logged; domain tables for strategies and backtests
|
|
8
|
+
* - Dashboard: embedded HTTP server at http://127.0.0.1:<httpPort> (default 18792)
|
|
7
9
|
* Supports FEP v2.0 protocol for strategy packages.
|
|
8
10
|
*/
|
|
9
11
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
10
12
|
import { registerStrategyCli } from "./src/cli.js";
|
|
11
13
|
import { resolvePluginConfig } from "./src/config.js";
|
|
12
14
|
import { registerDatahubTools } from "./src/datahub/tools.js";
|
|
15
|
+
import { getDb } from "./src/db/db.js";
|
|
16
|
+
import { startHttpServer } from "./src/http/server.js";
|
|
13
17
|
import { registerStrategyTools } from "./src/strategy/tools.js";
|
|
14
18
|
|
|
15
19
|
const openfinclawPlugin = {
|
|
@@ -22,11 +26,14 @@ const openfinclawPlugin = {
|
|
|
22
26
|
register(api: OpenClawPluginApi) {
|
|
23
27
|
const config = resolvePluginConfig(api);
|
|
24
28
|
|
|
29
|
+
// Initialise SQLite database (creates tables on first run)
|
|
30
|
+
const db = getDb();
|
|
31
|
+
|
|
25
32
|
// Register DataHub market data tools (fin_price, fin_kline, fin_crypto, fin_compare, fin_slim_search)
|
|
26
|
-
registerDatahubTools(api, config);
|
|
33
|
+
registerDatahubTools(api, config, db);
|
|
27
34
|
|
|
28
35
|
// Register strategy tools (skill_publish, skill_validate, skill_fork, skill_leaderboard, etc.)
|
|
29
|
-
registerStrategyTools(api, config);
|
|
36
|
+
registerStrategyTools(api, config, db);
|
|
30
37
|
|
|
31
38
|
// Register CLI commands
|
|
32
39
|
api.registerCli(
|
|
@@ -38,6 +45,9 @@ const openfinclawPlugin = {
|
|
|
38
45
|
}),
|
|
39
46
|
{ commands: ["strategy"] },
|
|
40
47
|
);
|
|
48
|
+
|
|
49
|
+
// Start embedded dashboard HTTP server (loopback only, configurable port)
|
|
50
|
+
startHttpServer(db, config.httpPort, api.logger);
|
|
41
51
|
},
|
|
42
52
|
};
|
|
43
53
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
"minimum": 5000,
|
|
31
31
|
"maximum": 300000,
|
|
32
32
|
"description": "HTTP request timeout in milliseconds"
|
|
33
|
+
},
|
|
34
|
+
"httpPort": {
|
|
35
|
+
"type": "number",
|
|
36
|
+
"default": 18792,
|
|
37
|
+
"minimum": 1024,
|
|
38
|
+
"maximum": 65535,
|
|
39
|
+
"description": "Dashboard HTTP server port (loopback only)"
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfinclaw/openfinclaw-strategy",
|
|
3
|
-
"version": "2026.3.
|
|
3
|
+
"version": "2026.3.27",
|
|
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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"registry": "https://registry.npmjs.org"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@sinclair/typebox": "
|
|
37
|
+
"@sinclair/typebox": "0.34.48",
|
|
38
38
|
"adm-zip": "^0.5.16"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
package/src/config.ts
CHANGED
|
@@ -48,10 +48,18 @@ export function resolvePluginConfig(api: OpenClawPluginApi): UnifiedPluginConfig
|
|
|
48
48
|
? Math.floor(Number(timeoutRaw))
|
|
49
49
|
: DEFAULT_TIMEOUT_MS;
|
|
50
50
|
|
|
51
|
+
const httpPortRaw = raw?.httpPort ?? readEnv(["OPENFINCLAW_HTTP_PORT"]);
|
|
52
|
+
const httpPortNum = Number(httpPortRaw);
|
|
53
|
+
const httpPort =
|
|
54
|
+
Number.isFinite(httpPortNum) && httpPortNum >= 1024 && httpPortNum <= 65535
|
|
55
|
+
? Math.floor(httpPortNum)
|
|
56
|
+
: 18792;
|
|
57
|
+
|
|
51
58
|
return {
|
|
52
59
|
apiKey: apiKey && apiKey.length > 0 ? apiKey : undefined,
|
|
53
60
|
hubApiUrl: hubApiUrl.replace(/\/$/, ""),
|
|
54
61
|
datahubGatewayUrl: datahubGatewayUrl.replace(/\/+$/, ""),
|
|
55
62
|
requestTimeoutMs,
|
|
63
|
+
httpPort,
|
|
56
64
|
};
|
|
57
65
|
}
|
package/src/datahub/tools.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
1
2
|
/**
|
|
2
3
|
* DataHub market data tools registration.
|
|
3
4
|
* Tools: fin_price, fin_kline, fin_crypto, fin_compare, fin_slim_search
|
|
4
5
|
*/
|
|
5
6
|
import { Type } from "@sinclair/typebox";
|
|
6
|
-
import type { OpenClawPluginApi } from "
|
|
7
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
8
|
+
import { withLogging } from "../middleware/with-logging.js";
|
|
7
9
|
import type { UnifiedPluginConfig, MarketType } from "../types.js";
|
|
8
10
|
import { DataHubClient, guessMarket } from "./client.js";
|
|
9
11
|
|
|
@@ -29,8 +31,13 @@ const NO_KEY =
|
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Register DataHub market data tools.
|
|
34
|
+
* @param db - SQLite database for activity logging.
|
|
32
35
|
*/
|
|
33
|
-
export function registerDatahubTools(
|
|
36
|
+
export function registerDatahubTools(
|
|
37
|
+
api: OpenClawPluginApi,
|
|
38
|
+
config: UnifiedPluginConfig,
|
|
39
|
+
db: DatabaseSync,
|
|
40
|
+
): void {
|
|
34
41
|
const datahubClient = config.apiKey
|
|
35
42
|
? new DataHubClient(config.datahubGatewayUrl, config.apiKey, config.requestTimeoutMs)
|
|
36
43
|
: null;
|
|
@@ -84,7 +91,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
84
91
|
}),
|
|
85
92
|
),
|
|
86
93
|
}),
|
|
87
|
-
|
|
94
|
+
execute: withLogging(db, "fin_price", "market-data", async (_id, params) => {
|
|
88
95
|
try {
|
|
89
96
|
if (!datahubClient) return json({ error: NO_KEY });
|
|
90
97
|
const symbol = String(params.symbol);
|
|
@@ -100,7 +107,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
100
107
|
} catch (err) {
|
|
101
108
|
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
102
109
|
}
|
|
103
|
-
},
|
|
110
|
+
}),
|
|
104
111
|
},
|
|
105
112
|
{ names: ["fin_price"] },
|
|
106
113
|
);
|
|
@@ -128,7 +135,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
128
135
|
Type.Number({ description: "Number of bars to return (default: 30)" }),
|
|
129
136
|
),
|
|
130
137
|
}),
|
|
131
|
-
|
|
138
|
+
execute: withLogging(db, "fin_kline", "market-data", async (_id, params) => {
|
|
132
139
|
try {
|
|
133
140
|
if (!datahubClient) return json({ error: NO_KEY });
|
|
134
141
|
const symbol = String(params.symbol);
|
|
@@ -151,7 +158,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
151
158
|
} catch (err) {
|
|
152
159
|
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
153
160
|
}
|
|
154
|
-
},
|
|
161
|
+
}),
|
|
155
162
|
},
|
|
156
163
|
{ names: ["fin_kline"] },
|
|
157
164
|
);
|
|
@@ -202,7 +209,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
202
209
|
end_date: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
|
|
203
210
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })),
|
|
204
211
|
}),
|
|
205
|
-
|
|
212
|
+
execute: withLogging(db, "fin_crypto", "market-data", async (_id, params) => {
|
|
206
213
|
try {
|
|
207
214
|
if (!datahubClient) return json({ error: NO_KEY });
|
|
208
215
|
const endpoint = String(params.endpoint ?? "coin/market");
|
|
@@ -231,7 +238,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
231
238
|
} catch (err) {
|
|
232
239
|
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
233
240
|
}
|
|
234
|
-
},
|
|
241
|
+
}),
|
|
235
242
|
},
|
|
236
243
|
{ names: ["fin_crypto"] },
|
|
237
244
|
);
|
|
@@ -249,7 +256,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
249
256
|
description: "Comma-separated symbols (2-5). Example: BTC/USDT,ETH/USDT,600519.SH",
|
|
250
257
|
}),
|
|
251
258
|
}),
|
|
252
|
-
|
|
259
|
+
execute: withLogging(db, "fin_compare", "market-data", async (_id, params) => {
|
|
253
260
|
try {
|
|
254
261
|
if (!datahubClient) return json({ error: NO_KEY });
|
|
255
262
|
const raw = String(params.symbols);
|
|
@@ -287,7 +294,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
287
294
|
} catch (err) {
|
|
288
295
|
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
289
296
|
}
|
|
290
|
-
},
|
|
297
|
+
}),
|
|
291
298
|
},
|
|
292
299
|
{ names: ["fin_compare"] },
|
|
293
300
|
);
|
|
@@ -310,7 +317,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
310
317
|
}),
|
|
311
318
|
),
|
|
312
319
|
}),
|
|
313
|
-
|
|
320
|
+
execute: withLogging(db, "fin_slim_search", "market-data", async (_id, params) => {
|
|
314
321
|
try {
|
|
315
322
|
if (!datahubClient) return json({ error: NO_KEY });
|
|
316
323
|
const q = String(params.query);
|
|
@@ -340,7 +347,7 @@ export function registerDatahubTools(api: OpenClawPluginApi, config: UnifiedPlug
|
|
|
340
347
|
} catch (err) {
|
|
341
348
|
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
342
349
|
}
|
|
343
|
-
},
|
|
350
|
+
}),
|
|
344
351
|
},
|
|
345
352
|
{ names: ["fin_slim_search"] },
|
|
346
353
|
);
|
package/src/db/db.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* SQLite database singleton for OpenFinClaw plugin.
|
|
4
|
+
* Database path: ~/.openfinclaw/workspace/openfinclaw-plugin.db
|
|
5
|
+
*/
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { ensureSchema } from "./schema.js";
|
|
11
|
+
|
|
12
|
+
// Use createRequire to load the built-in node:sqlite in an ESM context.
|
|
13
|
+
const _require = createRequire(import.meta.url);
|
|
14
|
+
|
|
15
|
+
let _db: DatabaseSync | null = null;
|
|
16
|
+
|
|
17
|
+
/** Resolve the database file path under ~/.openfinclaw/workspace/. */
|
|
18
|
+
function resolveDbPath(): string {
|
|
19
|
+
const base = join(homedir(), ".openfinclaw", "workspace");
|
|
20
|
+
mkdirSync(base, { recursive: true });
|
|
21
|
+
return join(base, "openfinclaw-plugin.db");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get (or lazily initialise) the SQLite database singleton.
|
|
26
|
+
* Calls ensureSchema on first access to create missing tables.
|
|
27
|
+
*/
|
|
28
|
+
export function getDb(): DatabaseSync {
|
|
29
|
+
if (_db) return _db;
|
|
30
|
+
// node:sqlite is available in Node 22+
|
|
31
|
+
const { DatabaseSync } = _require("node:sqlite") as typeof import("node:sqlite");
|
|
32
|
+
const dbPath = resolveDbPath();
|
|
33
|
+
_db = new DatabaseSync(dbPath);
|
|
34
|
+
ensureSchema(_db);
|
|
35
|
+
return _db;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Close the database (used in tests / graceful shutdown). */
|
|
39
|
+
export function closeDb(): void {
|
|
40
|
+
if (_db) {
|
|
41
|
+
_db.close();
|
|
42
|
+
_db = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data access helpers for OpenFinClaw plugin SQLite tables.
|
|
3
|
+
*/
|
|
4
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
5
|
+
|
|
6
|
+
// ── Row types ─────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface ActivityLogEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
category: string;
|
|
12
|
+
action: string;
|
|
13
|
+
strategy_id?: string | null;
|
|
14
|
+
detail?: string | null;
|
|
15
|
+
metadata_json?: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AgentEventEntry {
|
|
19
|
+
id: string;
|
|
20
|
+
type: string;
|
|
21
|
+
title: string;
|
|
22
|
+
detail?: string | null;
|
|
23
|
+
timestamp: string;
|
|
24
|
+
status?: string | null;
|
|
25
|
+
action_params_json?: string | null;
|
|
26
|
+
narration?: string | null;
|
|
27
|
+
feed_type?: string | null;
|
|
28
|
+
chips_json?: string | null;
|
|
29
|
+
sparkline_json?: string | null;
|
|
30
|
+
category?: string | null;
|
|
31
|
+
severity?: string | null;
|
|
32
|
+
strategy_id?: string | null;
|
|
33
|
+
reasoning?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strategy lifecycle levels:
|
|
38
|
+
* - L0: 创建 / Fork(尚未回测)
|
|
39
|
+
* - L1: 回测中 / 回测完成
|
|
40
|
+
* - L2: 模拟盘运行中(预留)
|
|
41
|
+
* - L3: 实盘运行中(预留)
|
|
42
|
+
*/
|
|
43
|
+
export type StrategyLevel = "L0" | "L1" | "L2" | "L3";
|
|
44
|
+
|
|
45
|
+
export interface StrategyRow {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
template_id?: string | null;
|
|
49
|
+
/** 策略生命周期阶段:L0 创建 → L1 回测 → L2 模拟盘 → L3 实盘 */
|
|
50
|
+
level?: StrategyLevel | null;
|
|
51
|
+
status: string;
|
|
52
|
+
symbols?: string | null;
|
|
53
|
+
timeframes?: string | null;
|
|
54
|
+
markets?: string | null;
|
|
55
|
+
exchange_id?: string | null;
|
|
56
|
+
parameters?: string | null;
|
|
57
|
+
definition?: string | null;
|
|
58
|
+
version: number;
|
|
59
|
+
created_at: string;
|
|
60
|
+
updated_at: string;
|
|
61
|
+
promoted_at?: string | null;
|
|
62
|
+
last_backtest_id?: string | null;
|
|
63
|
+
last_paper_session_id?: string | null;
|
|
64
|
+
tags?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BacktestRow {
|
|
68
|
+
id: string;
|
|
69
|
+
strategy_id: string;
|
|
70
|
+
remote_task_id?: string | null;
|
|
71
|
+
status: string;
|
|
72
|
+
total_return?: number | null;
|
|
73
|
+
sharpe?: number | null;
|
|
74
|
+
sortino?: number | null;
|
|
75
|
+
max_drawdown?: number | null;
|
|
76
|
+
win_rate?: number | null;
|
|
77
|
+
profit_factor?: number | null;
|
|
78
|
+
total_trades?: number | null;
|
|
79
|
+
final_equity?: number | null;
|
|
80
|
+
initial_capital?: number | null;
|
|
81
|
+
equity_curve?: string | null;
|
|
82
|
+
trade_journal?: string | null;
|
|
83
|
+
monthly_returns?: string | null;
|
|
84
|
+
tearsheet_html?: string | null;
|
|
85
|
+
submitted_at?: string | null;
|
|
86
|
+
completed_at?: string | null;
|
|
87
|
+
created_at: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Writes ────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** Insert one row into agent_activity_log. Silently swallows errors to never interrupt tools. */
|
|
93
|
+
export function insertActivityLog(db: DatabaseSync, entry: ActivityLogEntry): void {
|
|
94
|
+
try {
|
|
95
|
+
db.prepare(`
|
|
96
|
+
INSERT INTO agent_activity_log
|
|
97
|
+
(id, timestamp, category, action, strategy_id, detail, metadata_json)
|
|
98
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
99
|
+
`).run(
|
|
100
|
+
entry.id,
|
|
101
|
+
entry.timestamp,
|
|
102
|
+
entry.category,
|
|
103
|
+
entry.action,
|
|
104
|
+
entry.strategy_id ?? null,
|
|
105
|
+
entry.detail ?? null,
|
|
106
|
+
entry.metadata_json ?? null,
|
|
107
|
+
);
|
|
108
|
+
} catch {
|
|
109
|
+
// Logging must never crash the calling tool
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Insert one row into agent_events. Silently swallows errors. */
|
|
114
|
+
export function insertAgentEvent(db: DatabaseSync, event: AgentEventEntry): void {
|
|
115
|
+
try {
|
|
116
|
+
db.prepare(`
|
|
117
|
+
INSERT INTO agent_events
|
|
118
|
+
(id, type, title, detail, timestamp, status, action_params_json,
|
|
119
|
+
narration, feed_type, chips_json, sparkline_json, category, severity,
|
|
120
|
+
strategy_id, reasoning)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
122
|
+
`).run(
|
|
123
|
+
event.id,
|
|
124
|
+
event.type,
|
|
125
|
+
event.title,
|
|
126
|
+
event.detail ?? null,
|
|
127
|
+
event.timestamp,
|
|
128
|
+
event.status ?? null,
|
|
129
|
+
event.action_params_json ?? null,
|
|
130
|
+
event.narration ?? null,
|
|
131
|
+
event.feed_type ?? null,
|
|
132
|
+
event.chips_json ?? null,
|
|
133
|
+
event.sparkline_json ?? null,
|
|
134
|
+
event.category ?? null,
|
|
135
|
+
event.severity ?? null,
|
|
136
|
+
event.strategy_id ?? null,
|
|
137
|
+
event.reasoning ?? null,
|
|
138
|
+
);
|
|
139
|
+
} catch {
|
|
140
|
+
// Logging must never crash the calling tool
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Insert or replace a strategy row. */
|
|
145
|
+
export function upsertStrategy(db: DatabaseSync, row: StrategyRow): void {
|
|
146
|
+
try {
|
|
147
|
+
db.prepare(`
|
|
148
|
+
INSERT INTO strategies
|
|
149
|
+
(id, name, template_id, level, status, symbols, timeframes, markets,
|
|
150
|
+
exchange_id, parameters, definition, version, created_at, updated_at,
|
|
151
|
+
promoted_at, last_backtest_id, last_paper_session_id, tags)
|
|
152
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
153
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
154
|
+
name = excluded.name,
|
|
155
|
+
template_id = excluded.template_id,
|
|
156
|
+
level = excluded.level,
|
|
157
|
+
status = excluded.status,
|
|
158
|
+
symbols = excluded.symbols,
|
|
159
|
+
timeframes = excluded.timeframes,
|
|
160
|
+
markets = excluded.markets,
|
|
161
|
+
exchange_id = excluded.exchange_id,
|
|
162
|
+
parameters = excluded.parameters,
|
|
163
|
+
definition = excluded.definition,
|
|
164
|
+
version = excluded.version,
|
|
165
|
+
updated_at = excluded.updated_at,
|
|
166
|
+
promoted_at = excluded.promoted_at,
|
|
167
|
+
last_backtest_id = excluded.last_backtest_id,
|
|
168
|
+
last_paper_session_id = excluded.last_paper_session_id,
|
|
169
|
+
tags = excluded.tags
|
|
170
|
+
`).run(
|
|
171
|
+
row.id,
|
|
172
|
+
row.name,
|
|
173
|
+
row.template_id ?? null,
|
|
174
|
+
row.level ?? null,
|
|
175
|
+
row.status,
|
|
176
|
+
row.symbols ?? null,
|
|
177
|
+
row.timeframes ?? null,
|
|
178
|
+
row.markets ?? null,
|
|
179
|
+
row.exchange_id ?? null,
|
|
180
|
+
row.parameters ?? null,
|
|
181
|
+
row.definition ?? null,
|
|
182
|
+
row.version,
|
|
183
|
+
row.created_at,
|
|
184
|
+
row.updated_at,
|
|
185
|
+
row.promoted_at ?? null,
|
|
186
|
+
row.last_backtest_id ?? null,
|
|
187
|
+
row.last_paper_session_id ?? null,
|
|
188
|
+
row.tags ?? null,
|
|
189
|
+
);
|
|
190
|
+
} catch {
|
|
191
|
+
// Logging must never crash the calling tool
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Insert a backtest result row. */
|
|
196
|
+
export function insertBacktestResult(db: DatabaseSync, row: BacktestRow): void {
|
|
197
|
+
try {
|
|
198
|
+
db.prepare(`
|
|
199
|
+
INSERT INTO backtest_results
|
|
200
|
+
(id, strategy_id, remote_task_id, status, total_return, sharpe, sortino,
|
|
201
|
+
max_drawdown, win_rate, profit_factor, total_trades, final_equity,
|
|
202
|
+
initial_capital, equity_curve, trade_journal, monthly_returns,
|
|
203
|
+
tearsheet_html, submitted_at, completed_at, created_at)
|
|
204
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
205
|
+
`).run(
|
|
206
|
+
row.id,
|
|
207
|
+
row.strategy_id,
|
|
208
|
+
row.remote_task_id ?? null,
|
|
209
|
+
row.status,
|
|
210
|
+
row.total_return ?? null,
|
|
211
|
+
row.sharpe ?? null,
|
|
212
|
+
row.sortino ?? null,
|
|
213
|
+
row.max_drawdown ?? null,
|
|
214
|
+
row.win_rate ?? null,
|
|
215
|
+
row.profit_factor ?? null,
|
|
216
|
+
row.total_trades ?? null,
|
|
217
|
+
row.final_equity ?? null,
|
|
218
|
+
row.initial_capital ?? null,
|
|
219
|
+
row.equity_curve ?? null,
|
|
220
|
+
row.trade_journal ?? null,
|
|
221
|
+
row.monthly_returns ?? null,
|
|
222
|
+
row.tearsheet_html ?? null,
|
|
223
|
+
row.submitted_at ?? null,
|
|
224
|
+
row.completed_at ?? null,
|
|
225
|
+
row.created_at,
|
|
226
|
+
);
|
|
227
|
+
} catch {
|
|
228
|
+
// Logging must never crash the calling tool
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Update backtest_results metrics after publish_verify completes. */
|
|
233
|
+
export function updateBacktestResult(
|
|
234
|
+
db: DatabaseSync,
|
|
235
|
+
id: string,
|
|
236
|
+
patch: Partial<
|
|
237
|
+
Pick<
|
|
238
|
+
BacktestRow,
|
|
239
|
+
| "status"
|
|
240
|
+
| "total_return"
|
|
241
|
+
| "sharpe"
|
|
242
|
+
| "sortino"
|
|
243
|
+
| "max_drawdown"
|
|
244
|
+
| "win_rate"
|
|
245
|
+
| "profit_factor"
|
|
246
|
+
| "total_trades"
|
|
247
|
+
| "final_equity"
|
|
248
|
+
| "equity_curve"
|
|
249
|
+
| "trade_journal"
|
|
250
|
+
| "monthly_returns"
|
|
251
|
+
| "tearsheet_html"
|
|
252
|
+
| "completed_at"
|
|
253
|
+
>
|
|
254
|
+
>,
|
|
255
|
+
): void {
|
|
256
|
+
try {
|
|
257
|
+
const sets: string[] = [];
|
|
258
|
+
const values: unknown[] = [];
|
|
259
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
260
|
+
sets.push(`${k} = ?`);
|
|
261
|
+
values.push(v ?? null);
|
|
262
|
+
}
|
|
263
|
+
if (sets.length === 0) return;
|
|
264
|
+
values.push(id);
|
|
265
|
+
db.prepare(`UPDATE backtest_results SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
266
|
+
} catch {
|
|
267
|
+
// Logging must never crash the calling tool
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Update strategy level by id. */
|
|
272
|
+
export function updateStrategyLevel(db: DatabaseSync, id: string, level: StrategyLevel): void {
|
|
273
|
+
try {
|
|
274
|
+
db.prepare(`UPDATE strategies SET level = ?, updated_at = ? WHERE id = ?`).run(
|
|
275
|
+
level,
|
|
276
|
+
new Date().toISOString(),
|
|
277
|
+
id,
|
|
278
|
+
);
|
|
279
|
+
} catch {
|
|
280
|
+
// Logging must never crash the calling tool
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Reads ─────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/** Query agent_activity_log, newest first. */
|
|
287
|
+
export function queryActivityLog(db: DatabaseSync, limit = 50, offset = 0): ActivityLogEntry[] {
|
|
288
|
+
return db
|
|
289
|
+
.prepare(`SELECT * FROM agent_activity_log ORDER BY timestamp DESC LIMIT ? OFFSET ?`)
|
|
290
|
+
.all(limit, offset) as ActivityLogEntry[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Query agent_events, newest first. */
|
|
294
|
+
export function queryAgentEvents(db: DatabaseSync, limit = 50, offset = 0): AgentEventEntry[] {
|
|
295
|
+
return db
|
|
296
|
+
.prepare(`SELECT * FROM agent_events ORDER BY timestamp DESC LIMIT ? OFFSET ?`)
|
|
297
|
+
.all(limit, offset) as AgentEventEntry[];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Query all strategies, newest first. */
|
|
301
|
+
export function queryStrategies(db: DatabaseSync): StrategyRow[] {
|
|
302
|
+
return db.prepare(`SELECT * FROM strategies ORDER BY updated_at DESC`).all() as StrategyRow[];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Query backtest results, optionally filtered by strategy_id. */
|
|
306
|
+
export function queryBacktestResults(db: DatabaseSync, strategyId?: string): BacktestRow[] {
|
|
307
|
+
if (strategyId) {
|
|
308
|
+
return db
|
|
309
|
+
.prepare(`SELECT * FROM backtest_results WHERE strategy_id = ? ORDER BY created_at DESC`)
|
|
310
|
+
.all(strategyId) as BacktestRow[];
|
|
311
|
+
}
|
|
312
|
+
return db
|
|
313
|
+
.prepare(`SELECT * FROM backtest_results ORDER BY created_at DESC`)
|
|
314
|
+
.all() as BacktestRow[];
|
|
315
|
+
}
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite schema for OpenFinClaw plugin (MVP 4 tables).
|
|
3
|
+
* Based on ER diagram v0.3 — openfinclaw-opc-fund-plugin.
|
|
4
|
+
*/
|
|
5
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create (or migrate) all MVP tables.
|
|
9
|
+
* Safe to call multiple times — uses CREATE TABLE IF NOT EXISTS.
|
|
10
|
+
*/
|
|
11
|
+
export function ensureSchema(db: DatabaseSync): void {
|
|
12
|
+
// ── strategies ────────────────────────────────────────────────────────────
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS strategies (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
name TEXT NOT NULL,
|
|
17
|
+
template_id TEXT,
|
|
18
|
+
level TEXT DEFAULT 'L0',
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
20
|
+
symbols TEXT,
|
|
21
|
+
timeframes TEXT,
|
|
22
|
+
markets TEXT,
|
|
23
|
+
exchange_id TEXT,
|
|
24
|
+
parameters TEXT,
|
|
25
|
+
definition TEXT,
|
|
26
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
27
|
+
created_at TEXT NOT NULL,
|
|
28
|
+
updated_at TEXT NOT NULL,
|
|
29
|
+
promoted_at TEXT,
|
|
30
|
+
last_backtest_id TEXT,
|
|
31
|
+
last_paper_session_id TEXT,
|
|
32
|
+
tags TEXT
|
|
33
|
+
);
|
|
34
|
+
`);
|
|
35
|
+
|
|
36
|
+
// ── backtest_results ──────────────────────────────────────────────────────
|
|
37
|
+
db.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS backtest_results (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
strategy_id TEXT NOT NULL,
|
|
41
|
+
remote_task_id TEXT,
|
|
42
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
43
|
+
total_return REAL,
|
|
44
|
+
sharpe REAL,
|
|
45
|
+
sortino REAL,
|
|
46
|
+
max_drawdown REAL,
|
|
47
|
+
win_rate REAL,
|
|
48
|
+
profit_factor REAL,
|
|
49
|
+
total_trades INTEGER,
|
|
50
|
+
final_equity REAL,
|
|
51
|
+
initial_capital REAL,
|
|
52
|
+
equity_curve TEXT,
|
|
53
|
+
trade_journal TEXT,
|
|
54
|
+
monthly_returns TEXT,
|
|
55
|
+
tearsheet_html TEXT,
|
|
56
|
+
submitted_at TEXT,
|
|
57
|
+
completed_at TEXT,
|
|
58
|
+
created_at TEXT NOT NULL,
|
|
59
|
+
FOREIGN KEY (strategy_id) REFERENCES strategies(id)
|
|
60
|
+
);
|
|
61
|
+
`);
|
|
62
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_br_strategy_id ON backtest_results(strategy_id);`);
|
|
63
|
+
|
|
64
|
+
// ── agent_activity_log ────────────────────────────────────────────────────
|
|
65
|
+
db.exec(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS agent_activity_log (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
timestamp TEXT NOT NULL,
|
|
69
|
+
category TEXT NOT NULL,
|
|
70
|
+
action TEXT NOT NULL,
|
|
71
|
+
strategy_id TEXT,
|
|
72
|
+
detail TEXT,
|
|
73
|
+
metadata_json TEXT
|
|
74
|
+
);
|
|
75
|
+
`);
|
|
76
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_aal_timestamp ON agent_activity_log(timestamp);`);
|
|
77
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_aal_category ON agent_activity_log(category);`);
|
|
78
|
+
|
|
79
|
+
// ── agent_events ──────────────────────────────────────────────────────────
|
|
80
|
+
db.exec(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS agent_events (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
type TEXT NOT NULL,
|
|
84
|
+
title TEXT NOT NULL,
|
|
85
|
+
detail TEXT,
|
|
86
|
+
timestamp TEXT NOT NULL,
|
|
87
|
+
status TEXT,
|
|
88
|
+
action_params_json TEXT,
|
|
89
|
+
narration TEXT,
|
|
90
|
+
feed_type TEXT,
|
|
91
|
+
chips_json TEXT,
|
|
92
|
+
sparkline_json TEXT,
|
|
93
|
+
category TEXT,
|
|
94
|
+
severity TEXT,
|
|
95
|
+
strategy_id TEXT,
|
|
96
|
+
reasoning TEXT
|
|
97
|
+
);
|
|
98
|
+
`);
|
|
99
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_ae_timestamp ON agent_events(timestamp);`);
|
|
100
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_ae_strategy_id ON agent_events(strategy_id);`);
|
|
101
|
+
|
|
102
|
+
// ── migrations (add columns to existing databases) ──────────────────────
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Add a column if it doesn't already exist (safe for repeated calls). */
|
|
106
|
+
function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void {
|
|
107
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
108
|
+
if (rows.some((r) => r.name === column)) return;
|
|
109
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
110
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST route handlers for the OpenFinClaw dashboard HTTP server.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import {
|
|
10
|
+
queryActivityLog,
|
|
11
|
+
queryAgentEvents,
|
|
12
|
+
queryStrategies,
|
|
13
|
+
queryBacktestResults,
|
|
14
|
+
} from "../db/repositories.js";
|
|
15
|
+
|
|
16
|
+
// Resolve path to web/index.html relative to this file's location
|
|
17
|
+
const WEB_DIR = join(fileURLToPath(import.meta.url), "..", "..", "..", "web");
|
|
18
|
+
|
|
19
|
+
function getIndexHtml(): string {
|
|
20
|
+
return readFileSync(join(WEB_DIR, "index.html"), "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseQueryParam(url: string, name: string, fallback: number): number {
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(url, "http://localhost");
|
|
26
|
+
const v = u.searchParams.get(name);
|
|
27
|
+
const n = v != null ? parseInt(v, 10) : NaN;
|
|
28
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
29
|
+
} catch {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseStringParam(url: string, name: string): string | undefined {
|
|
35
|
+
try {
|
|
36
|
+
const u = new URL(url, "http://localhost");
|
|
37
|
+
return u.searchParams.get(name) ?? undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sendJson(res: ServerResponse, data: unknown): void {
|
|
44
|
+
const body = JSON.stringify(data);
|
|
45
|
+
res.writeHead(200, {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"Content-Length": Buffer.byteLength(body),
|
|
48
|
+
"Access-Control-Allow-Origin": "*",
|
|
49
|
+
});
|
|
50
|
+
res.end(body);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendHtml(res: ServerResponse, html: string): void {
|
|
54
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
55
|
+
res.end(html);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function send404(res: ServerResponse): void {
|
|
59
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
60
|
+
res.end("Not found");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Dispatch an incoming HTTP request to the appropriate handler.
|
|
65
|
+
*/
|
|
66
|
+
export function handleRoute(db: DatabaseSync, req: IncomingMessage, res: ServerResponse): void {
|
|
67
|
+
const url = req.url ?? "/";
|
|
68
|
+
const pathname = url.split("?")[0];
|
|
69
|
+
|
|
70
|
+
// Dashboard HTML
|
|
71
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
72
|
+
try {
|
|
73
|
+
sendHtml(res, getIndexHtml());
|
|
74
|
+
} catch {
|
|
75
|
+
res.writeHead(500);
|
|
76
|
+
res.end("Failed to load dashboard HTML");
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (pathname === "/api/activity-log") {
|
|
82
|
+
const limit = parseQueryParam(url, "limit", 50);
|
|
83
|
+
const offset = parseQueryParam(url, "offset", 0);
|
|
84
|
+
sendJson(res, queryActivityLog(db, limit, offset));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (pathname === "/api/agent-events") {
|
|
89
|
+
const limit = parseQueryParam(url, "limit", 50);
|
|
90
|
+
const offset = parseQueryParam(url, "offset", 0);
|
|
91
|
+
sendJson(res, queryAgentEvents(db, limit, offset));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (pathname === "/api/strategies") {
|
|
96
|
+
sendJson(res, queryStrategies(db));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (pathname === "/api/backtest-results") {
|
|
101
|
+
const strategyId = parseStringParam(url, "strategy_id");
|
|
102
|
+
sendJson(res, queryBacktestResults(db, strategyId));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
send404(res);
|
|
107
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded HTTP server for the OpenFinClaw dashboard.
|
|
3
|
+
* Binds to 127.0.0.1 only (loopback) for local access.
|
|
4
|
+
*/
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { handleRoute } from "./routes.js";
|
|
8
|
+
|
|
9
|
+
export interface DashboardLogger {
|
|
10
|
+
info(msg: string): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Start the dashboard HTTP server.
|
|
15
|
+
* Errors during individual requests are caught and returned as 500s.
|
|
16
|
+
*/
|
|
17
|
+
export function startHttpServer(db: DatabaseSync, port: number, logger: DashboardLogger): void {
|
|
18
|
+
const server = createServer((req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
handleRoute(db, req, res);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (!res.headersSent) {
|
|
23
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
24
|
+
}
|
|
25
|
+
res.end(`Internal Server Error: ${String(err)}`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
server.on("error", (err) => {
|
|
30
|
+
logger.info(`[OpenFinClaw] Dashboard server error: ${String(err)}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
server.listen(port, "127.0.0.1", () => {
|
|
34
|
+
logger.info(`[OpenFinClaw] Dashboard available at http://127.0.0.1:${port}`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Higher-order function that wraps a tool execute() to automatically log
|
|
3
|
+
* every invocation (success or failure) into agent_activity_log.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { insertActivityLog } from "../db/repositories.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generic tool execute function signature.
|
|
11
|
+
* Uses `(...args: never[])` rest params so the wrapper is assignable to any
|
|
12
|
+
* concrete execute overload the SDK defines (extra optional params like
|
|
13
|
+
* `signal` and `onUpdate` are passed through transparently).
|
|
14
|
+
*/
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
type AnyExecuteFn = (
|
|
17
|
+
toolCallId: string,
|
|
18
|
+
params: Record<string, unknown>,
|
|
19
|
+
...rest: any[]
|
|
20
|
+
) => Promise<any>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wrap a tool execute function with automatic activity logging.
|
|
24
|
+
*
|
|
25
|
+
* @param db - The SQLite database instance.
|
|
26
|
+
* @param toolName - Name used as the `action` column value.
|
|
27
|
+
* @param category - Broad category, e.g. "market-data" or "strategy".
|
|
28
|
+
* @param fn - The original execute function to wrap.
|
|
29
|
+
* @param opts - Optional: extract strategy_id from params for context.
|
|
30
|
+
*/
|
|
31
|
+
export function withLogging<T extends AnyExecuteFn>(
|
|
32
|
+
db: DatabaseSync,
|
|
33
|
+
toolName: string,
|
|
34
|
+
category: string,
|
|
35
|
+
fn: T,
|
|
36
|
+
opts?: { strategyIdParam?: string },
|
|
37
|
+
): T {
|
|
38
|
+
const wrapped: AnyExecuteFn = async (toolCallId, params, ...rest) => {
|
|
39
|
+
const logId = randomUUID();
|
|
40
|
+
const startMs = Date.now();
|
|
41
|
+
const strategyId =
|
|
42
|
+
opts?.strategyIdParam != null
|
|
43
|
+
? ((params[opts.strategyIdParam] as string | undefined) ?? null)
|
|
44
|
+
: null;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const result = await fn(toolCallId, params, ...rest);
|
|
48
|
+
insertActivityLog(db, {
|
|
49
|
+
id: logId,
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
category,
|
|
52
|
+
action: toolName,
|
|
53
|
+
strategy_id: strategyId,
|
|
54
|
+
detail: `OK (${Date.now() - startMs}ms)`,
|
|
55
|
+
metadata_json: JSON.stringify({ toolCallId, params }),
|
|
56
|
+
});
|
|
57
|
+
return result;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
insertActivityLog(db, {
|
|
60
|
+
id: logId,
|
|
61
|
+
timestamp: new Date().toISOString(),
|
|
62
|
+
category,
|
|
63
|
+
action: toolName,
|
|
64
|
+
strategy_id: strategyId,
|
|
65
|
+
detail: `ERROR (${Date.now() - startMs}ms): ${String(err)}`,
|
|
66
|
+
metadata_json: JSON.stringify({ toolCallId, params }),
|
|
67
|
+
});
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
return wrapped as T;
|
|
72
|
+
}
|
package/src/strategy/tools.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
/**
|
|
2
3
|
* Strategy tools registration.
|
|
3
4
|
* Tools: skill_publish, skill_publish_verify, skill_validate, skill_leaderboard, skill_fork, skill_list_local, skill_get_info
|
|
4
5
|
*/
|
|
5
6
|
import { readFile } from "node:fs/promises";
|
|
7
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
6
8
|
import { Type } from "@sinclair/typebox";
|
|
7
9
|
import type { OpenClawPluginApi } from "openfinclaw/plugin-sdk";
|
|
10
|
+
import {
|
|
11
|
+
insertAgentEvent,
|
|
12
|
+
insertBacktestResult,
|
|
13
|
+
updateBacktestResult,
|
|
14
|
+
updateStrategyLevel,
|
|
15
|
+
upsertStrategy,
|
|
16
|
+
} from "../db/repositories.js";
|
|
17
|
+
import { withLogging } from "../middleware/with-logging.js";
|
|
8
18
|
import type { UnifiedPluginConfig, BoardType, LeaderboardResponse } from "../types.js";
|
|
9
19
|
import { hubApiRequest } from "./client.js";
|
|
10
20
|
import { forkStrategy, fetchStrategyInfo } from "./fork.js";
|
|
@@ -24,8 +34,13 @@ const NO_API_KEY =
|
|
|
24
34
|
|
|
25
35
|
/**
|
|
26
36
|
* Register strategy tools.
|
|
37
|
+
* @param db - SQLite database for activity logging and domain table writes.
|
|
27
38
|
*/
|
|
28
|
-
export function registerStrategyTools(
|
|
39
|
+
export function registerStrategyTools(
|
|
40
|
+
api: OpenClawPluginApi,
|
|
41
|
+
config: UnifiedPluginConfig,
|
|
42
|
+
db: DatabaseSync,
|
|
43
|
+
): void {
|
|
29
44
|
// ── skill_publish ──
|
|
30
45
|
api.registerTool(
|
|
31
46
|
{
|
|
@@ -45,7 +60,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
45
60
|
}),
|
|
46
61
|
),
|
|
47
62
|
}),
|
|
48
|
-
|
|
63
|
+
execute: withLogging(db, "skill_publish", "strategy", async (_toolCallId, params) => {
|
|
49
64
|
try {
|
|
50
65
|
const filePath = String(params.filePath ?? "").trim();
|
|
51
66
|
if (!filePath) return json({ success: false, error: "filePath is required" });
|
|
@@ -87,6 +102,57 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
87
102
|
creditsEarned?: { action?: string; amount?: number; message?: string };
|
|
88
103
|
};
|
|
89
104
|
|
|
105
|
+
// Persist strategy + pending backtest result
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
const strategyId = resp.entryId ?? resp.slug ?? randomUUID();
|
|
108
|
+
const hubVersion = Number(resp.version) || 1;
|
|
109
|
+
upsertStrategy(db, {
|
|
110
|
+
id: strategyId,
|
|
111
|
+
name: resp.slug ?? filePath,
|
|
112
|
+
status: "published",
|
|
113
|
+
level: resp.backtestTaskId ? "L1" : "L0",
|
|
114
|
+
version: hubVersion,
|
|
115
|
+
created_at: now,
|
|
116
|
+
updated_at: now,
|
|
117
|
+
promoted_at: now,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (resp.backtestTaskId) {
|
|
121
|
+
const backtestId = randomUUID();
|
|
122
|
+
insertBacktestResult(db, {
|
|
123
|
+
id: backtestId,
|
|
124
|
+
strategy_id: strategyId,
|
|
125
|
+
remote_task_id: resp.backtestTaskId,
|
|
126
|
+
status: "pending",
|
|
127
|
+
submitted_at: now,
|
|
128
|
+
created_at: now,
|
|
129
|
+
});
|
|
130
|
+
upsertStrategy(db, {
|
|
131
|
+
id: strategyId,
|
|
132
|
+
name: resp.slug ?? filePath,
|
|
133
|
+
status: "published",
|
|
134
|
+
level: "L1",
|
|
135
|
+
version: hubVersion,
|
|
136
|
+
created_at: now,
|
|
137
|
+
updated_at: now,
|
|
138
|
+
promoted_at: now,
|
|
139
|
+
last_backtest_id: backtestId,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
insertAgentEvent(db, {
|
|
144
|
+
id: randomUUID(),
|
|
145
|
+
type: "publish",
|
|
146
|
+
title: `策略发布: ${resp.slug ?? "(未知)"}`,
|
|
147
|
+
detail: `Entry ID: ${resp.entryId ?? "—"} | Backtest: ${resp.backtestTaskId ?? "—"}`,
|
|
148
|
+
timestamp: now,
|
|
149
|
+
status: "info",
|
|
150
|
+
category: "strategy",
|
|
151
|
+
severity: "low",
|
|
152
|
+
strategy_id: strategyId,
|
|
153
|
+
narration: `策略 ${resp.slug ?? filePath} 已成功发布到 Hub`,
|
|
154
|
+
});
|
|
155
|
+
|
|
90
156
|
const lines: string[] = [];
|
|
91
157
|
lines.push("Skill 发布成功!");
|
|
92
158
|
lines.push("");
|
|
@@ -118,7 +184,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
118
184
|
error: err instanceof Error ? err.message : String(err),
|
|
119
185
|
});
|
|
120
186
|
}
|
|
121
|
-
},
|
|
187
|
+
}),
|
|
122
188
|
},
|
|
123
189
|
{ names: ["skill_publish"] },
|
|
124
190
|
);
|
|
@@ -138,7 +204,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
138
204
|
Type.String({ description: "Backtest task ID from skill_publish response" }),
|
|
139
205
|
),
|
|
140
206
|
}),
|
|
141
|
-
|
|
207
|
+
execute: withLogging(db, "skill_publish_verify", "strategy", async (_toolCallId, params) => {
|
|
142
208
|
try {
|
|
143
209
|
const submissionId = String(params.submissionId ?? "").trim() || undefined;
|
|
144
210
|
const backtestTaskId = String(params.backtestTaskId ?? "").trim() || undefined;
|
|
@@ -167,6 +233,31 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
167
233
|
|
|
168
234
|
if (status >= 200 && status < 300) {
|
|
169
235
|
const resp = data as Record<string, unknown>;
|
|
236
|
+
|
|
237
|
+
// Update backtest result metrics and strategy level
|
|
238
|
+
const verifyStrategyId = resp.entryId as string | undefined;
|
|
239
|
+
if (resp.backtestStatus === "completed" && backtestTaskId) {
|
|
240
|
+
const perf = (resp.backtestReport as Record<string, unknown> | undefined)
|
|
241
|
+
?.performance as Record<string, unknown> | undefined;
|
|
242
|
+
updateBacktestResult(db, backtestTaskId, {
|
|
243
|
+
status: "completed",
|
|
244
|
+
total_return: typeof perf?.totalReturn === "number" ? perf.totalReturn : undefined,
|
|
245
|
+
sharpe: typeof perf?.sharpe === "number" ? perf.sharpe : undefined,
|
|
246
|
+
max_drawdown: typeof perf?.maxDrawdown === "number" ? perf.maxDrawdown : undefined,
|
|
247
|
+
win_rate: typeof perf?.winRate === "number" ? perf.winRate : undefined,
|
|
248
|
+
completed_at: new Date().toISOString(),
|
|
249
|
+
});
|
|
250
|
+
// Backtest completed → strategy stays at L1
|
|
251
|
+
if (verifyStrategyId) updateStrategyLevel(db, verifyStrategyId, "L1");
|
|
252
|
+
} else if (resp.backtestStatus === "failed" && backtestTaskId) {
|
|
253
|
+
updateBacktestResult(db, backtestTaskId, {
|
|
254
|
+
status: "failed",
|
|
255
|
+
completed_at: new Date().toISOString(),
|
|
256
|
+
});
|
|
257
|
+
// Backtest failed → revert to L0
|
|
258
|
+
if (verifyStrategyId) updateStrategyLevel(db, verifyStrategyId, "L0");
|
|
259
|
+
}
|
|
260
|
+
|
|
170
261
|
const lines: string[] = [];
|
|
171
262
|
lines.push("发布验证结果:");
|
|
172
263
|
lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
|
|
@@ -211,7 +302,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
211
302
|
error: err instanceof Error ? err.message : String(err),
|
|
212
303
|
});
|
|
213
304
|
}
|
|
214
|
-
},
|
|
305
|
+
}),
|
|
215
306
|
},
|
|
216
307
|
{ names: ["skill_publish_verify"] },
|
|
217
308
|
);
|
|
@@ -228,7 +319,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
228
319
|
description: "Path to strategy package directory (must contain fep.yaml)",
|
|
229
320
|
}),
|
|
230
321
|
}),
|
|
231
|
-
|
|
322
|
+
execute: withLogging(db, "skill_validate", "strategy", async (_toolCallId, params) => {
|
|
232
323
|
try {
|
|
233
324
|
const dirPath = String(params.dirPath ?? "").trim();
|
|
234
325
|
if (!dirPath)
|
|
@@ -248,7 +339,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
248
339
|
errors: [err instanceof Error ? err.message : String(err)],
|
|
249
340
|
});
|
|
250
341
|
}
|
|
251
|
-
},
|
|
342
|
+
}),
|
|
252
343
|
},
|
|
253
344
|
{ names: ["skill_validate"] },
|
|
254
345
|
);
|
|
@@ -272,7 +363,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
272
363
|
),
|
|
273
364
|
offset: Type.Optional(Type.Number({ description: "Offset for pagination" })),
|
|
274
365
|
}),
|
|
275
|
-
|
|
366
|
+
execute: withLogging(db, "skill_leaderboard", "strategy", async (_toolCallId, params) => {
|
|
276
367
|
try {
|
|
277
368
|
const boardType = (params.boardType as BoardType) || "composite";
|
|
278
369
|
const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 100);
|
|
@@ -352,7 +443,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
352
443
|
error: err instanceof Error ? err.message : String(err),
|
|
353
444
|
});
|
|
354
445
|
}
|
|
355
|
-
},
|
|
446
|
+
}),
|
|
356
447
|
},
|
|
357
448
|
{ names: ["skill_leaderboard"] },
|
|
358
449
|
);
|
|
@@ -371,46 +462,79 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
371
462
|
name: Type.Optional(Type.String({ description: "Name for the forked strategy" })),
|
|
372
463
|
targetDir: Type.Optional(Type.String({ description: "Custom target directory" })),
|
|
373
464
|
}),
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
465
|
+
execute: withLogging(
|
|
466
|
+
db,
|
|
467
|
+
"skill_fork",
|
|
468
|
+
"strategy",
|
|
469
|
+
async (_toolCallId, params) => {
|
|
470
|
+
try {
|
|
471
|
+
const strategyId = String(params.strategyId ?? "").trim();
|
|
472
|
+
if (!strategyId) return json({ success: false, error: "strategyId is required" });
|
|
473
|
+
|
|
474
|
+
if (!config.apiKey) {
|
|
475
|
+
return json({
|
|
476
|
+
success: false,
|
|
477
|
+
error: "API key is required for fork operation.",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
378
480
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
error: "API key is required for fork operation.",
|
|
481
|
+
const result = await forkStrategy(config, strategyId, {
|
|
482
|
+
name: params.name ? String(params.name) : undefined,
|
|
483
|
+
targetDir: params.targetDir ? String(params.targetDir) : undefined,
|
|
383
484
|
});
|
|
384
|
-
}
|
|
385
485
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
486
|
+
if (result.success) {
|
|
487
|
+
// Persist forked strategy to DB
|
|
488
|
+
const now = new Date().toISOString();
|
|
489
|
+
const localId = result.forkEntryId ?? strategyId;
|
|
490
|
+
upsertStrategy(db, {
|
|
491
|
+
id: localId,
|
|
492
|
+
name: result.sourceName ?? (params.name ? String(params.name) : strategyId),
|
|
493
|
+
template_id: result.sourceId,
|
|
494
|
+
status: "forked",
|
|
495
|
+
level: "L0",
|
|
496
|
+
version: Number(result.sourceVersion) || 1,
|
|
497
|
+
created_at: now,
|
|
498
|
+
updated_at: now,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
insertAgentEvent(db, {
|
|
502
|
+
id: randomUUID(),
|
|
503
|
+
type: "fork",
|
|
504
|
+
title: `Fork 策略: ${result.sourceName ?? strategyId}`,
|
|
505
|
+
detail: `本地路径: ${result.localPath}`,
|
|
506
|
+
timestamp: now,
|
|
507
|
+
status: "info",
|
|
508
|
+
category: "strategy",
|
|
509
|
+
severity: "low",
|
|
510
|
+
strategy_id: localId,
|
|
511
|
+
narration: `已将策略 ${result.sourceName ?? strategyId} Fork 到本地`,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const lines: string[] = [];
|
|
515
|
+
lines.push("策略 Fork 成功!");
|
|
516
|
+
lines.push(`- 原策略: ${result.sourceName} (${result.sourceId})`);
|
|
517
|
+
lines.push(`- 本地路径: ${result.localPath}`);
|
|
518
|
+
lines.push("");
|
|
519
|
+
lines.push("下一步:");
|
|
520
|
+
lines.push(`- 编辑策略: code ${result.localPath}/scripts/strategy.py`);
|
|
390
521
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
lines.push("");
|
|
397
|
-
lines.push("下一步:");
|
|
398
|
-
lines.push(`- 编辑策略: code ${result.localPath}/scripts/strategy.py`);
|
|
522
|
+
return {
|
|
523
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
524
|
+
details: result,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
399
527
|
|
|
400
|
-
return {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
528
|
+
return json({ success: false, error: result.error ?? "Failed to fork strategy" });
|
|
529
|
+
} catch (err) {
|
|
530
|
+
return json({
|
|
531
|
+
success: false,
|
|
532
|
+
error: err instanceof Error ? err.message : String(err),
|
|
533
|
+
});
|
|
404
534
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return json({
|
|
409
|
-
success: false,
|
|
410
|
-
error: err instanceof Error ? err.message : String(err),
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
},
|
|
535
|
+
},
|
|
536
|
+
{ strategyIdParam: "strategyId" },
|
|
537
|
+
),
|
|
414
538
|
},
|
|
415
539
|
{ names: ["skill_fork"] },
|
|
416
540
|
);
|
|
@@ -422,7 +546,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
422
546
|
label: "List local strategies",
|
|
423
547
|
description: "List all strategies downloaded or created locally, organized by date.",
|
|
424
548
|
parameters: Type.Object({}),
|
|
425
|
-
async
|
|
549
|
+
execute: withLogging(db, "skill_list_local", "strategy", async () => {
|
|
426
550
|
try {
|
|
427
551
|
const strategies = await listLocalStrategies();
|
|
428
552
|
|
|
@@ -461,7 +585,7 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
461
585
|
error: err instanceof Error ? err.message : String(err),
|
|
462
586
|
});
|
|
463
587
|
}
|
|
464
|
-
},
|
|
588
|
+
}),
|
|
465
589
|
},
|
|
466
590
|
{ names: ["skill_list_local"] },
|
|
467
591
|
);
|
|
@@ -476,45 +600,54 @@ export function registerStrategyTools(api: OpenClawPluginApi, config: UnifiedPlu
|
|
|
476
600
|
parameters: Type.Object({
|
|
477
601
|
strategyId: Type.String({ description: "Strategy ID from Hub (UUID or Hub URL)" }),
|
|
478
602
|
}),
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
603
|
+
execute: withLogging(
|
|
604
|
+
db,
|
|
605
|
+
"skill_get_info",
|
|
606
|
+
"strategy",
|
|
607
|
+
async (_toolCallId, params) => {
|
|
608
|
+
try {
|
|
609
|
+
const strategyId = String(params.strategyId ?? "").trim();
|
|
610
|
+
if (!strategyId) return json({ success: false, error: "strategyId is required" });
|
|
611
|
+
|
|
612
|
+
const result = await fetchStrategyInfo(config, strategyId);
|
|
613
|
+
|
|
614
|
+
if (result.success && result.data) {
|
|
615
|
+
const info = result.data;
|
|
616
|
+
const lines: string[] = [];
|
|
617
|
+
lines.push("策略信息:");
|
|
618
|
+
lines.push(`- ID: ${info.id}`);
|
|
619
|
+
lines.push(`- 名称: ${info.name}`);
|
|
620
|
+
if (info.author?.displayName) lines.push(`- 作者: ${info.author.displayName}`);
|
|
621
|
+
if (info.backtestResult) {
|
|
622
|
+
lines.push("");
|
|
623
|
+
lines.push("绩效指标:");
|
|
624
|
+
if (typeof info.backtestResult.totalReturn === "number")
|
|
625
|
+
lines.push(`- 总收益率: ${(info.backtestResult.totalReturn * 100).toFixed(2)}%`);
|
|
626
|
+
if (typeof info.backtestResult.sharpe === "number")
|
|
627
|
+
lines.push(`- 夏普比率: ${info.backtestResult.sharpe.toFixed(3)}`);
|
|
628
|
+
}
|
|
494
629
|
lines.push("");
|
|
495
|
-
lines.push(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
630
|
+
lines.push(`Hub URL: https://hub.openfinclaw.ai/strategy/${info.id}`);
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
634
|
+
details: { success: true, ...info },
|
|
635
|
+
};
|
|
500
636
|
}
|
|
501
|
-
lines.push("");
|
|
502
|
-
lines.push(`Hub URL: https://hub.openfinclaw.ai/strategy/${info.id}`);
|
|
503
637
|
|
|
504
|
-
return {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
};
|
|
638
|
+
return json({
|
|
639
|
+
success: false,
|
|
640
|
+
error: result.error ?? "Failed to fetch strategy info",
|
|
641
|
+
});
|
|
642
|
+
} catch (err) {
|
|
643
|
+
return json({
|
|
644
|
+
success: false,
|
|
645
|
+
error: err instanceof Error ? err.message : String(err),
|
|
646
|
+
});
|
|
508
647
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
return json({
|
|
513
|
-
success: false,
|
|
514
|
-
error: err instanceof Error ? err.message : String(err),
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
},
|
|
648
|
+
},
|
|
649
|
+
{ strategyIdParam: "strategyId" },
|
|
650
|
+
),
|
|
518
651
|
},
|
|
519
652
|
{ names: ["skill_get_info"] },
|
|
520
653
|
);
|