@openfinclaw/openfinclaw-strategy 2026.4.9 → 2026.4.10
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 +2 -29
- package/package.json +1 -1
- package/src/db/db.ts +1 -3
- package/src/db/repositories.ts +10 -15
- package/src/db/schema.ts +0 -21
- package/src/scheduler/tools.ts +5 -0
- package/src/strategy/client.ts +3 -1
- package/src/strategy/tools.ts +7 -3
- package/src/tournament/cron-setup.ts +0 -102
- package/src/tournament/db.test.ts +0 -222
- package/src/tournament/db.ts +0 -286
- package/src/tournament/orchestrator.test.ts +0 -232
- package/src/tournament/orchestrator.ts +0 -238
- package/src/tournament/prompts.ts +0 -65
- package/src/tournament/tools.test.ts +0 -221
- package/src/tournament/tools.ts +0 -192
package/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ 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";
|
|
15
|
-
import {
|
|
15
|
+
import { getDb } from "./src/db/db.js";
|
|
16
16
|
import { createOpenFinclawGatewayProxy } from "./src/http/gateway-proxy.js";
|
|
17
17
|
import { startHttpServer } from "./src/http/server.js";
|
|
18
18
|
import { OPENFINCLAW_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
|
|
@@ -20,10 +20,6 @@ import { setupOpenfinclawCronJobs } from "./src/scheduler/cron-setup.js";
|
|
|
20
20
|
import { AggregatedNewsProvider, createNewsProviders } from "./src/scheduler/news-provider.js";
|
|
21
21
|
import { registerSchedulerTools } from "./src/scheduler/tools.js";
|
|
22
22
|
import { registerStrategyTools } from "./src/strategy/tools.js";
|
|
23
|
-
import { setupTournamentCronJob } from "./src/tournament/cron-setup.js";
|
|
24
|
-
import { TournamentDb } from "./src/tournament/db.js";
|
|
25
|
-
import { buildOrchestratorPrompt } from "./src/tournament/prompts.js";
|
|
26
|
-
import { registerTournamentTools } from "./src/tournament/tools.js";
|
|
27
23
|
|
|
28
24
|
export default definePluginEntry({
|
|
29
25
|
id: "openfinclaw-strategy",
|
|
@@ -72,21 +68,11 @@ export default definePluginEntry({
|
|
|
72
68
|
handler: createOpenFinclawGatewayProxy({ port: config.httpPort, logger: api.logger }),
|
|
73
69
|
});
|
|
74
70
|
|
|
75
|
-
// Register tournament tools (tournament_pick, tournament_leaderboard, tournament_result)
|
|
76
|
-
const getTournamentDb = () => new TournamentDb(getDb());
|
|
77
|
-
registerTournamentTools(api.registerTool.bind(api), getTournamentDb);
|
|
78
|
-
|
|
79
71
|
// Inject agent system prompt: prioritise tool calls so data lands in SQLite
|
|
80
|
-
const tournamentPrompt = buildOrchestratorPrompt();
|
|
81
72
|
api.on("before_prompt_build", async () => ({
|
|
82
|
-
prependSystemContext:
|
|
73
|
+
prependSystemContext: OPENFINCLAW_AGENT_GUIDANCE,
|
|
83
74
|
}));
|
|
84
75
|
|
|
85
|
-
// Graceful shutdown: close SQLite connection
|
|
86
|
-
process.once("beforeExit", () => {
|
|
87
|
-
closeDb();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
76
|
// ── Gateway Cron registration ──
|
|
91
77
|
// Write cron jobs directly to ~/.openclaw/cron/jobs.json during register().
|
|
92
78
|
// This ensures jobs are available immediately on both gateway startup AND
|
|
@@ -106,19 +92,6 @@ export default definePluginEntry({
|
|
|
106
92
|
`[OpenFinClaw] Cron setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
107
93
|
);
|
|
108
94
|
});
|
|
109
|
-
|
|
110
|
-
// Register tournament cron job
|
|
111
|
-
setupTournamentCronJob()
|
|
112
|
-
.then((result) => {
|
|
113
|
-
if (result.created) {
|
|
114
|
-
api.logger.info("[OpenFinClaw] Tournament cron job registered");
|
|
115
|
-
}
|
|
116
|
-
})
|
|
117
|
-
.catch((err) => {
|
|
118
|
-
api.logger.info(
|
|
119
|
-
`[OpenFinClaw] Tournament cron setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
120
|
-
);
|
|
121
|
-
});
|
|
122
95
|
}
|
|
123
96
|
},
|
|
124
97
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfinclaw/openfinclaw-strategy",
|
|
3
|
-
"version": "2026.4.
|
|
3
|
+
"version": "2026.4.10",
|
|
4
4
|
"description": "OpenFinClaw - Unified financial tools: market data (price/K-line/crypto/compare/search), strategy publishing, fork, and validation. Single API key for Hub and DataHub.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backtest",
|
package/src/db/db.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { mkdirSync, unlinkSync } from "node:fs";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { DatabaseSync } from "node:sqlite";
|
|
15
|
-
import {
|
|
15
|
+
import { ensureSchema } from "./schema.js";
|
|
16
16
|
|
|
17
17
|
/** globalThis key — survives module hot-reloads within the same process. */
|
|
18
18
|
const GLOBAL_DB_KEY = "__openfinclaw_db__" as const;
|
|
@@ -52,8 +52,6 @@ export function getDb(): DatabaseSync {
|
|
|
52
52
|
ensureSchema(db);
|
|
53
53
|
}
|
|
54
54
|
(globalThis as Record<string, unknown>)[GLOBAL_DB_KEY] = db;
|
|
55
|
-
// Best-effort cleanup of old rows on first access each process lifecycle
|
|
56
|
-
cleanupOldRows(db);
|
|
57
55
|
return db;
|
|
58
56
|
}
|
|
59
57
|
|
package/src/db/repositories.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Data access helpers for OpenFinClaw plugin SQLite tables.
|
|
3
3
|
*/
|
|
4
|
-
import type { DatabaseSync
|
|
5
|
-
|
|
6
|
-
/** Cast unknown[] to SQLInputValue[] for db.prepare().run() spread. */
|
|
7
|
-
function sqlParams(values: unknown[]): SQLInputValue[] {
|
|
8
|
-
return values as SQLInputValue[];
|
|
9
|
-
}
|
|
4
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
10
5
|
|
|
11
6
|
// ── Row types ─────────────────────────────────────────────────────────────
|
|
12
7
|
|
|
@@ -267,7 +262,7 @@ export function updateBacktestResult(
|
|
|
267
262
|
}
|
|
268
263
|
if (sets.length === 0) return;
|
|
269
264
|
values.push(id);
|
|
270
|
-
db.prepare(`UPDATE backtest_results SET ${sets.join(", ")} WHERE id = ?`).run(...
|
|
265
|
+
db.prepare(`UPDATE backtest_results SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
271
266
|
} catch {
|
|
272
267
|
// Logging must never crash the calling tool
|
|
273
268
|
}
|
|
@@ -352,7 +347,7 @@ export function updateScanHistory(
|
|
|
352
347
|
}
|
|
353
348
|
if (sets.length === 0) return;
|
|
354
349
|
values.push(id);
|
|
355
|
-
db.prepare(`UPDATE scan_history SET ${sets.join(", ")} WHERE id = ?`).run(...
|
|
350
|
+
db.prepare(`UPDATE scan_history SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
356
351
|
} catch {
|
|
357
352
|
// Logging must never crash the calling tool
|
|
358
353
|
}
|
|
@@ -383,7 +378,7 @@ export function queryScanHistory(
|
|
|
383
378
|
params.push(limit, offset);
|
|
384
379
|
return db
|
|
385
380
|
.prepare(`SELECT * FROM scan_history ${where} ORDER BY started_at DESC LIMIT ? OFFSET ?`)
|
|
386
|
-
.all(...
|
|
381
|
+
.all(...params) as ScanHistoryEntry[];
|
|
387
382
|
}
|
|
388
383
|
|
|
389
384
|
/**
|
|
@@ -467,7 +462,7 @@ export function queryPriceAlerts(
|
|
|
467
462
|
params.push(limit, offset);
|
|
468
463
|
return db
|
|
469
464
|
.prepare(`SELECT * FROM price_alerts ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
470
|
-
.all(...
|
|
465
|
+
.all(...params) as PriceAlertEntry[];
|
|
471
466
|
}
|
|
472
467
|
|
|
473
468
|
/** Count price_alerts rows since an inclusive time bound (ISO 8601 created_at). */
|
|
@@ -493,19 +488,19 @@ export function acknowledgePriceAlert(db: DatabaseSync, id: string): void {
|
|
|
493
488
|
export function queryActivityLog(db: DatabaseSync, limit = 50, offset = 0): ActivityLogEntry[] {
|
|
494
489
|
return db
|
|
495
490
|
.prepare(`SELECT * FROM agent_activity_log ORDER BY timestamp DESC LIMIT ? OFFSET ?`)
|
|
496
|
-
.all(limit, offset) as
|
|
491
|
+
.all(limit, offset) as ActivityLogEntry[];
|
|
497
492
|
}
|
|
498
493
|
|
|
499
494
|
/** Query agent_events, newest first. */
|
|
500
495
|
export function queryAgentEvents(db: DatabaseSync, limit = 50, offset = 0): AgentEventEntry[] {
|
|
501
496
|
return db
|
|
502
497
|
.prepare(`SELECT * FROM agent_events ORDER BY timestamp DESC LIMIT ? OFFSET ?`)
|
|
503
|
-
.all(limit, offset) as
|
|
498
|
+
.all(limit, offset) as AgentEventEntry[];
|
|
504
499
|
}
|
|
505
500
|
|
|
506
501
|
/** Query all strategies, newest first. */
|
|
507
502
|
export function queryStrategies(db: DatabaseSync): StrategyRow[] {
|
|
508
|
-
return db.prepare(`SELECT * FROM strategies ORDER BY updated_at DESC`).all() as
|
|
503
|
+
return db.prepare(`SELECT * FROM strategies ORDER BY updated_at DESC`).all() as StrategyRow[];
|
|
509
504
|
}
|
|
510
505
|
|
|
511
506
|
/** Query backtest results, optionally filtered by strategy_id. */
|
|
@@ -513,9 +508,9 @@ export function queryBacktestResults(db: DatabaseSync, strategyId?: string): Bac
|
|
|
513
508
|
if (strategyId) {
|
|
514
509
|
return db
|
|
515
510
|
.prepare(`SELECT * FROM backtest_results WHERE strategy_id = ? ORDER BY created_at DESC`)
|
|
516
|
-
.all(strategyId) as
|
|
511
|
+
.all(strategyId) as BacktestRow[];
|
|
517
512
|
}
|
|
518
513
|
return db
|
|
519
514
|
.prepare(`SELECT * FROM backtest_results ORDER BY created_at DESC`)
|
|
520
|
-
.all() as
|
|
515
|
+
.all() as BacktestRow[];
|
|
521
516
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Based on ER diagram v0.3.
|
|
4
4
|
*/
|
|
5
5
|
import type { DatabaseSync } from "node:sqlite";
|
|
6
|
-
import { ensureTournamentSchema } from "../tournament/db.js";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Create (or migrate) all MVP tables.
|
|
@@ -135,29 +134,9 @@ export function ensureSchema(db: DatabaseSync): void {
|
|
|
135
134
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_price_alerts_created ON price_alerts(created_at);`);
|
|
136
135
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_price_alerts_strategy ON price_alerts(strategy_id);`);
|
|
137
136
|
|
|
138
|
-
// ── tournament tables ──────────────────────────────────────────────────
|
|
139
|
-
ensureTournamentSchema(db);
|
|
140
|
-
|
|
141
137
|
// ── migrations (add columns to existing databases) ──────────────────────
|
|
142
138
|
}
|
|
143
139
|
|
|
144
|
-
/**
|
|
145
|
-
* Purge old rows from accumulating tables.
|
|
146
|
-
* Safe to call periodically (e.g. on plugin register or via cron).
|
|
147
|
-
* Keeps 90 days of activity logs, scan history, and acknowledged price alerts.
|
|
148
|
-
*/
|
|
149
|
-
export function cleanupOldRows(db: DatabaseSync): void {
|
|
150
|
-
const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
|
|
151
|
-
try {
|
|
152
|
-
db.exec(`DELETE FROM agent_activity_log WHERE timestamp < '${cutoff}'`);
|
|
153
|
-
db.exec(`DELETE FROM scan_history WHERE started_at < '${cutoff}'`);
|
|
154
|
-
db.exec(`DELETE FROM price_alerts WHERE acknowledged = 1 AND created_at < '${cutoff}'`);
|
|
155
|
-
db.exec(`DELETE FROM agent_events WHERE timestamp < '${cutoff}'`);
|
|
156
|
-
} catch {
|
|
157
|
-
// Cleanup is best-effort — never crash the plugin
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
140
|
/** Add a column if it doesn't already exist (safe for repeated calls). */
|
|
162
141
|
function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void {
|
|
163
142
|
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
package/src/scheduler/tools.ts
CHANGED
|
@@ -23,6 +23,9 @@ import type { AggregatedNewsProvider } from "./news-provider.js";
|
|
|
23
23
|
import { formatPeriodicReportMarkdown } from "./periodic-report-builder.js";
|
|
24
24
|
import { buildScanReport, formatScanReportMarkdown } from "./scan-report-builder.js";
|
|
25
25
|
|
|
26
|
+
const NO_API_KEY =
|
|
27
|
+
"API key not configured. Set apiKey in plugin config or OPENFINCLAW_API_KEY env var.";
|
|
28
|
+
|
|
26
29
|
/** JSON tool result helper. */
|
|
27
30
|
function json(payload: unknown) {
|
|
28
31
|
return {
|
|
@@ -421,6 +424,7 @@ export function registerSchedulerTools(
|
|
|
421
424
|
"strategy_scan_history",
|
|
422
425
|
"scheduler",
|
|
423
426
|
async (_toolCallId, params) => {
|
|
427
|
+
if (!config.apiKey) return json({ success: false, error: NO_API_KEY });
|
|
424
428
|
try {
|
|
425
429
|
const db = getDb();
|
|
426
430
|
const entries = queryScanHistory(db, {
|
|
@@ -489,6 +493,7 @@ export function registerSchedulerTools(
|
|
|
489
493
|
"strategy_periodic_report",
|
|
490
494
|
"scheduler",
|
|
491
495
|
async (_toolCallId, params) => {
|
|
496
|
+
if (!config.apiKey) return json({ success: false, error: NO_API_KEY });
|
|
492
497
|
const period = params.period === "monthly" ? "monthly" : "weekly";
|
|
493
498
|
const scanId = randomUUID();
|
|
494
499
|
const now = new Date();
|
package/src/strategy/client.ts
CHANGED
|
@@ -21,7 +21,9 @@ export async function hubApiRequest(
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
24
|
-
if (config.apiKey)
|
|
24
|
+
if (config.apiKey) {
|
|
25
|
+
headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
26
|
+
}
|
|
25
27
|
|
|
26
28
|
const response = await fetch(url.toString(), {
|
|
27
29
|
method,
|
package/src/strategy/tools.ts
CHANGED
|
@@ -346,6 +346,7 @@ export function registerStrategyTools(
|
|
|
346
346
|
}),
|
|
347
347
|
}),
|
|
348
348
|
execute: withLogging(getDb, "skill_validate", "strategy", async (_toolCallId, params) => {
|
|
349
|
+
if (!config.apiKey) return json({ success: false, error: NO_API_KEY });
|
|
349
350
|
try {
|
|
350
351
|
const dirPath = String(params.dirPath ?? "").trim();
|
|
351
352
|
if (!dirPath)
|
|
@@ -375,7 +376,7 @@ export function registerStrategyTools(
|
|
|
375
376
|
{
|
|
376
377
|
name: "skill_leaderboard",
|
|
377
378
|
label: "Get Hub leaderboard",
|
|
378
|
-
description: "Query strategy leaderboard from hub.openfinclaw.ai.
|
|
379
|
+
description: "Query strategy leaderboard from hub.openfinclaw.ai.",
|
|
379
380
|
parameters: Type.Object({
|
|
380
381
|
boardType: Type.Optional(
|
|
381
382
|
Type.Unsafe<BoardType>({
|
|
@@ -390,6 +391,7 @@ export function registerStrategyTools(
|
|
|
390
391
|
offset: Type.Optional(Type.Number({ description: "Offset for pagination" })),
|
|
391
392
|
}),
|
|
392
393
|
execute: withLogging(getDb, "skill_leaderboard", "strategy", async (_toolCallId, params) => {
|
|
394
|
+
if (!config.apiKey) return json({ success: false, error: NO_API_KEY });
|
|
393
395
|
try {
|
|
394
396
|
const boardType = (params.boardType as BoardType) || "composite";
|
|
395
397
|
const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 100);
|
|
@@ -401,7 +403,7 @@ export function registerStrategyTools(
|
|
|
401
403
|
|
|
402
404
|
const response = await fetch(url.toString(), {
|
|
403
405
|
method: "GET",
|
|
404
|
-
headers: { Accept: "application/json" },
|
|
406
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${config.apiKey}` },
|
|
405
407
|
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
|
406
408
|
});
|
|
407
409
|
|
|
@@ -573,6 +575,7 @@ export function registerStrategyTools(
|
|
|
573
575
|
description: "List all strategies downloaded or created locally, organized by date.",
|
|
574
576
|
parameters: Type.Object({}),
|
|
575
577
|
execute: withLogging(getDb, "skill_list_local", "strategy", async () => {
|
|
578
|
+
if (!config.apiKey) return json({ success: false, error: NO_API_KEY });
|
|
576
579
|
try {
|
|
577
580
|
const strategies = await listLocalStrategies();
|
|
578
581
|
|
|
@@ -622,7 +625,7 @@ export function registerStrategyTools(
|
|
|
622
625
|
name: "skill_get_info",
|
|
623
626
|
label: "Get strategy info from Hub",
|
|
624
627
|
description:
|
|
625
|
-
"Fetch detailed information about a strategy from hub.openfinclaw.ai.
|
|
628
|
+
"Fetch detailed information about a strategy from hub.openfinclaw.ai.",
|
|
626
629
|
parameters: Type.Object({
|
|
627
630
|
strategyId: Type.String({ description: "Strategy ID from Hub (UUID or Hub URL)" }),
|
|
628
631
|
}),
|
|
@@ -631,6 +634,7 @@ export function registerStrategyTools(
|
|
|
631
634
|
"skill_get_info",
|
|
632
635
|
"strategy",
|
|
633
636
|
async (_toolCallId, params) => {
|
|
637
|
+
if (!config.apiKey) return json({ success: false, error: NO_API_KEY });
|
|
634
638
|
try {
|
|
635
639
|
const strategyId = String(params.strategyId ?? "").trim();
|
|
636
640
|
if (!strategyId) return json({ success: false, error: "strategyId is required" });
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tournament cron job registration.
|
|
3
|
-
* Follows the same file-based pattern as scheduler/cron-setup.ts.
|
|
4
|
-
* @module openfinclaw/tournament/cron-setup
|
|
5
|
-
*/
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
-
import fs from "node:fs";
|
|
8
|
-
import path from "node:path";
|
|
9
|
-
|
|
10
|
-
const DEFAULT_TOURNAMENT_CRON = "0 9 * * 1-5"; // 9 AM weekdays
|
|
11
|
-
const DEFAULT_TOURNAMENT_TZ = "Asia/Shanghai";
|
|
12
|
-
|
|
13
|
-
interface StoredCronJob {
|
|
14
|
-
id: string;
|
|
15
|
-
name: string;
|
|
16
|
-
enabled: boolean;
|
|
17
|
-
schedule: { kind: "cron"; expr: string; tz?: string };
|
|
18
|
-
payload: { kind: "systemEvent"; text: string };
|
|
19
|
-
sessionTarget: string;
|
|
20
|
-
wakeMode: string;
|
|
21
|
-
delivery: { mode: string };
|
|
22
|
-
createdAtMs: number;
|
|
23
|
-
updatedAtMs: number;
|
|
24
|
-
state: Record<string, unknown>;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface CronStoreFile {
|
|
28
|
-
version: 1;
|
|
29
|
-
jobs: StoredCronJob[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function defaultStorePath(): string {
|
|
33
|
-
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
34
|
-
return path.join(home, ".openclaw", "cron", "jobs.json");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function loadStore(storePath: string): Promise<CronStoreFile> {
|
|
38
|
-
try {
|
|
39
|
-
const raw = await fs.promises.readFile(storePath, "utf-8");
|
|
40
|
-
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
41
|
-
const jobs = Array.isArray(parsed.jobs) ? (parsed.jobs as StoredCronJob[]) : [];
|
|
42
|
-
return { version: 1, jobs };
|
|
43
|
-
} catch (err) {
|
|
44
|
-
if ((err as { code?: string }).code === "ENOENT") {
|
|
45
|
-
return { version: 1, jobs: [] };
|
|
46
|
-
}
|
|
47
|
-
throw err;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function saveStore(storePath: string, store: CronStoreFile): Promise<void> {
|
|
52
|
-
const dir = path.dirname(storePath);
|
|
53
|
-
await fs.promises.mkdir(dir, { recursive: true });
|
|
54
|
-
const json = JSON.stringify(store, null, 2);
|
|
55
|
-
const tmp = `${storePath}.${process.pid}.${Date.now()}.tmp`;
|
|
56
|
-
await fs.promises.writeFile(tmp, json, "utf-8");
|
|
57
|
-
await fs.promises.rename(tmp, storePath);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const TOURNAMENT_JOB_NAME = "openfinclaw:tournament";
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Register the daily tournament cron job.
|
|
64
|
-
* Idempotent: skips if job already exists by name.
|
|
65
|
-
*/
|
|
66
|
-
export async function setupTournamentCronJob(config?: {
|
|
67
|
-
cronExpr?: string;
|
|
68
|
-
timezone?: string;
|
|
69
|
-
}): Promise<{ ok: boolean; created: boolean }> {
|
|
70
|
-
const storePath = defaultStorePath();
|
|
71
|
-
const store = await loadStore(storePath);
|
|
72
|
-
|
|
73
|
-
const existingNames = new Set(store.jobs.map((j) => j.name));
|
|
74
|
-
if (existingNames.has(TOURNAMENT_JOB_NAME)) {
|
|
75
|
-
return { ok: true, created: false };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const cronExpr =
|
|
79
|
-
config?.cronExpr ?? process.env.OPENFINCLAW_TOURNAMENT_CRON ?? DEFAULT_TOURNAMENT_CRON;
|
|
80
|
-
const timezone = config?.timezone ?? DEFAULT_TOURNAMENT_TZ;
|
|
81
|
-
|
|
82
|
-
const now = Date.now();
|
|
83
|
-
store.jobs.push({
|
|
84
|
-
id: randomUUID(),
|
|
85
|
-
name: TOURNAMENT_JOB_NAME,
|
|
86
|
-
enabled: true,
|
|
87
|
-
schedule: { kind: "cron", expr: cronExpr, tz: timezone },
|
|
88
|
-
payload: {
|
|
89
|
-
kind: "systemEvent",
|
|
90
|
-
text: "[openfinclaw:tournament] 每日策略锦标赛触发。请执行今日锦标赛流程。",
|
|
91
|
-
},
|
|
92
|
-
sessionTarget: "main",
|
|
93
|
-
wakeMode: "now",
|
|
94
|
-
delivery: { mode: "none" },
|
|
95
|
-
createdAtMs: now,
|
|
96
|
-
updatedAtMs: now,
|
|
97
|
-
state: {},
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
await saveStore(storePath, store);
|
|
101
|
-
return { ok: true, created: true };
|
|
102
|
-
}
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for tournament DB operations.
|
|
3
|
-
* @module openfinclaw/tournament/db.test
|
|
4
|
-
*/
|
|
5
|
-
import { DatabaseSync } from "node:sqlite";
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
-
import { TournamentDb, ensureTournamentSchema } from "./db.js";
|
|
8
|
-
|
|
9
|
-
describe("TournamentDb", () => {
|
|
10
|
-
let db: DatabaseSync;
|
|
11
|
-
let tdb: TournamentDb;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
db = new DatabaseSync(":memory:");
|
|
15
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
16
|
-
ensureTournamentSchema(db);
|
|
17
|
-
tdb = new TournamentDb(db);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
db.close();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe("schema", () => {
|
|
25
|
-
it("creates tables idempotently", () => {
|
|
26
|
-
ensureTournamentSchema(db);
|
|
27
|
-
ensureTournamentSchema(db);
|
|
28
|
-
const tables = db
|
|
29
|
-
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'tournament_%'")
|
|
30
|
-
.all() as Array<{ name: string }>;
|
|
31
|
-
const names = tables.map((t) => t.name).sort();
|
|
32
|
-
expect(names).toEqual([
|
|
33
|
-
"tournament_agents",
|
|
34
|
-
"tournament_picks",
|
|
35
|
-
"tournament_rounds",
|
|
36
|
-
"tournament_strategies",
|
|
37
|
-
]);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("rounds", () => {
|
|
42
|
-
it("creates a round", () => {
|
|
43
|
-
const created = tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
44
|
-
expect(created).toBe(true);
|
|
45
|
-
const round = tdb.getRound("round-20260331");
|
|
46
|
-
expect(round).toBeDefined();
|
|
47
|
-
expect(round!.ticker).toBe("AAPL");
|
|
48
|
-
expect(round!.status).toBe("running");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("returns false for duplicate round", () => {
|
|
52
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
53
|
-
const dup = tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "TSLA" });
|
|
54
|
-
expect(dup).toBe(false);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("updates round status to completed", () => {
|
|
58
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
59
|
-
tdb.updateRoundStatus("round-20260331", "completed");
|
|
60
|
-
const round = tdb.getRound("round-20260331")!;
|
|
61
|
-
expect(round.status).toBe("completed");
|
|
62
|
-
expect(round.completed_at).toBeTruthy();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("updates round status to skipped with reason", () => {
|
|
66
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
67
|
-
tdb.updateRoundStatus("round-20260331", "skipped", "Less than 2 agents succeeded");
|
|
68
|
-
const round = tdb.getRound("round-20260331")!;
|
|
69
|
-
expect(round.status).toBe("skipped");
|
|
70
|
-
expect(round.skip_reason).toBe("Less than 2 agents succeeded");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("counts consecutive skips", () => {
|
|
74
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "A" });
|
|
75
|
-
tdb.updateRoundStatus("round-20260331", "skipped");
|
|
76
|
-
tdb.createRound({ id: "round-20260330", date: "2026-03-30", ticker: "B" });
|
|
77
|
-
tdb.updateRoundStatus("round-20260330", "skipped");
|
|
78
|
-
tdb.createRound({ id: "round-20260329", date: "2026-03-29", ticker: "C" });
|
|
79
|
-
tdb.updateRoundStatus("round-20260329", "completed");
|
|
80
|
-
expect(tdb.countConsecutiveSkips()).toBe(2);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe("strategies", () => {
|
|
85
|
-
it("saves and retrieves strategies", () => {
|
|
86
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
87
|
-
tdb.saveStrategy({
|
|
88
|
-
round_id: "round-20260331",
|
|
89
|
-
agent_name: "bull",
|
|
90
|
-
thesis: "EMA crossover bullish",
|
|
91
|
-
entry_price: 150.0,
|
|
92
|
-
exit_price: 165.0,
|
|
93
|
-
stop_loss: 145.0,
|
|
94
|
-
position_pct: 0.25,
|
|
95
|
-
confidence: 85,
|
|
96
|
-
sharpe: 1.5,
|
|
97
|
-
max_drawdown: -0.08,
|
|
98
|
-
total_return: 0.12,
|
|
99
|
-
raw_result: '{"detail":"full analysis"}',
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
const strategies = tdb.getStrategies("round-20260331");
|
|
103
|
-
expect(strategies).toHaveLength(1);
|
|
104
|
-
expect(strategies[0].agent_name).toBe("bull");
|
|
105
|
-
expect(strategies[0].confidence).toBe(85);
|
|
106
|
-
expect(strategies[0].sharpe).toBe(1.5);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("upserts strategy on conflict", () => {
|
|
110
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
111
|
-
tdb.saveStrategy({
|
|
112
|
-
round_id: "round-20260331",
|
|
113
|
-
agent_name: "bull",
|
|
114
|
-
thesis: "First",
|
|
115
|
-
confidence: 50,
|
|
116
|
-
entry_price: null,
|
|
117
|
-
exit_price: null,
|
|
118
|
-
stop_loss: null,
|
|
119
|
-
position_pct: null,
|
|
120
|
-
sharpe: null,
|
|
121
|
-
max_drawdown: null,
|
|
122
|
-
total_return: null,
|
|
123
|
-
raw_result: null,
|
|
124
|
-
});
|
|
125
|
-
tdb.saveStrategy({
|
|
126
|
-
round_id: "round-20260331",
|
|
127
|
-
agent_name: "bull",
|
|
128
|
-
thesis: "Updated",
|
|
129
|
-
confidence: 90,
|
|
130
|
-
entry_price: 100,
|
|
131
|
-
exit_price: 110,
|
|
132
|
-
stop_loss: 95,
|
|
133
|
-
position_pct: 0.3,
|
|
134
|
-
sharpe: 2.0,
|
|
135
|
-
max_drawdown: -0.05,
|
|
136
|
-
total_return: 0.2,
|
|
137
|
-
raw_result: null,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const strategies = tdb.getStrategies("round-20260331");
|
|
141
|
-
expect(strategies).toHaveLength(1);
|
|
142
|
-
expect(strategies[0].thesis).toBe("Updated");
|
|
143
|
-
expect(strategies[0].confidence).toBe(90);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe("agents", () => {
|
|
148
|
-
it("records win and updates avg sharpe", () => {
|
|
149
|
-
tdb.recordWin("bull", 1.5);
|
|
150
|
-
tdb.recordWin("bull", 2.5);
|
|
151
|
-
const agent = tdb.getAgent("bull")!;
|
|
152
|
-
expect(agent.wins).toBe(2);
|
|
153
|
-
expect(agent.rounds_played).toBe(2);
|
|
154
|
-
expect(agent.avg_sharpe).toBe(2.0);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("records loss", () => {
|
|
158
|
-
tdb.recordLoss("bear", 0.5);
|
|
159
|
-
const agent = tdb.getAgent("bear")!;
|
|
160
|
-
expect(agent.losses).toBe(1);
|
|
161
|
-
expect(agent.rounds_played).toBe(1);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("returns leaderboard sorted by avg_sharpe", () => {
|
|
165
|
-
tdb.recordWin("bull", 2.0);
|
|
166
|
-
tdb.recordWin("bear", 1.0);
|
|
167
|
-
tdb.recordWin("contrarian", 3.0);
|
|
168
|
-
const lb = tdb.getLeaderboard();
|
|
169
|
-
expect(lb[0].name).toBe("contrarian");
|
|
170
|
-
expect(lb[1].name).toBe("bull");
|
|
171
|
-
expect(lb[2].name).toBe("bear");
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
describe("picks", () => {
|
|
176
|
-
it("records a pick", () => {
|
|
177
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
178
|
-
const recorded = tdb.recordPick({
|
|
179
|
-
round_id: "round-20260331",
|
|
180
|
-
user_id: "telegram:12345",
|
|
181
|
-
session_key: "agent:main:telegram",
|
|
182
|
-
agent_name: "bull",
|
|
183
|
-
});
|
|
184
|
-
expect(recorded).toBe(true);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("returns false for duplicate pick by same user", () => {
|
|
188
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
189
|
-
tdb.recordPick({
|
|
190
|
-
round_id: "round-20260331",
|
|
191
|
-
user_id: "telegram:12345",
|
|
192
|
-
session_key: "agent:main:telegram",
|
|
193
|
-
agent_name: "bull",
|
|
194
|
-
});
|
|
195
|
-
const dup = tdb.recordPick({
|
|
196
|
-
round_id: "round-20260331",
|
|
197
|
-
user_id: "telegram:12345",
|
|
198
|
-
session_key: "agent:main:telegram",
|
|
199
|
-
agent_name: "bear",
|
|
200
|
-
});
|
|
201
|
-
expect(dup).toBe(false);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("allows different users to pick same round", () => {
|
|
205
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
206
|
-
const pick1 = tdb.recordPick({
|
|
207
|
-
round_id: "round-20260331",
|
|
208
|
-
user_id: "telegram:12345",
|
|
209
|
-
session_key: "agent:main:telegram",
|
|
210
|
-
agent_name: "bull",
|
|
211
|
-
});
|
|
212
|
-
const pick2 = tdb.recordPick({
|
|
213
|
-
round_id: "round-20260331",
|
|
214
|
-
user_id: "telegram:67890",
|
|
215
|
-
session_key: "agent:main:telegram",
|
|
216
|
-
agent_name: "bear",
|
|
217
|
-
});
|
|
218
|
-
expect(pick1).toBe(true);
|
|
219
|
-
expect(pick2).toBe(true);
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
});
|