@openfinclaw/openfinclaw-strategy 2026.4.2 → 2026.4.9
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 +29 -2
- package/package.json +1 -1
- package/src/db/db.ts +3 -1
- package/src/db/repositories.ts +15 -10
- package/src/db/schema.ts +21 -0
- package/src/tournament/cron-setup.ts +102 -0
- package/src/tournament/db.test.ts +222 -0
- package/src/tournament/db.ts +286 -0
- package/src/tournament/orchestrator.test.ts +232 -0
- package/src/tournament/orchestrator.ts +238 -0
- package/src/tournament/prompts.ts +65 -0
- package/src/tournament/tools.test.ts +221 -0
- package/src/tournament/tools.ts +192 -0
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 { getDb } from "./src/db/db.js";
|
|
15
|
+
import { closeDb, 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,6 +20,10 @@ 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";
|
|
23
27
|
|
|
24
28
|
export default definePluginEntry({
|
|
25
29
|
id: "openfinclaw-strategy",
|
|
@@ -68,11 +72,21 @@ export default definePluginEntry({
|
|
|
68
72
|
handler: createOpenFinclawGatewayProxy({ port: config.httpPort, logger: api.logger }),
|
|
69
73
|
});
|
|
70
74
|
|
|
75
|
+
// Register tournament tools (tournament_pick, tournament_leaderboard, tournament_result)
|
|
76
|
+
const getTournamentDb = () => new TournamentDb(getDb());
|
|
77
|
+
registerTournamentTools(api.registerTool.bind(api), getTournamentDb);
|
|
78
|
+
|
|
71
79
|
// Inject agent system prompt: prioritise tool calls so data lands in SQLite
|
|
80
|
+
const tournamentPrompt = buildOrchestratorPrompt();
|
|
72
81
|
api.on("before_prompt_build", async () => ({
|
|
73
|
-
prependSystemContext: OPENFINCLAW_AGENT_GUIDANCE
|
|
82
|
+
prependSystemContext: `${OPENFINCLAW_AGENT_GUIDANCE}\n\n${tournamentPrompt}`,
|
|
74
83
|
}));
|
|
75
84
|
|
|
85
|
+
// Graceful shutdown: close SQLite connection
|
|
86
|
+
process.once("beforeExit", () => {
|
|
87
|
+
closeDb();
|
|
88
|
+
});
|
|
89
|
+
|
|
76
90
|
// ── Gateway Cron registration ──
|
|
77
91
|
// Write cron jobs directly to ~/.openclaw/cron/jobs.json during register().
|
|
78
92
|
// This ensures jobs are available immediately on both gateway startup AND
|
|
@@ -92,6 +106,19 @@ export default definePluginEntry({
|
|
|
92
106
|
`[OpenFinClaw] Cron setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
93
107
|
);
|
|
94
108
|
});
|
|
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
|
+
});
|
|
95
122
|
}
|
|
96
123
|
},
|
|
97
124
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfinclaw/openfinclaw-strategy",
|
|
3
|
-
"version": "2026.4.
|
|
3
|
+
"version": "2026.4.9",
|
|
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 { ensureSchema } from "./schema.js";
|
|
15
|
+
import { cleanupOldRows, 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,6 +52,8 @@ 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);
|
|
55
57
|
return db;
|
|
56
58
|
}
|
|
57
59
|
|
package/src/db/repositories.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Data access helpers for OpenFinClaw plugin SQLite tables.
|
|
3
3
|
*/
|
|
4
|
-
import type { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
|
5
|
+
|
|
6
|
+
/** Cast unknown[] to SQLInputValue[] for db.prepare().run() spread. */
|
|
7
|
+
function sqlParams(values: unknown[]): SQLInputValue[] {
|
|
8
|
+
return values as SQLInputValue[];
|
|
9
|
+
}
|
|
5
10
|
|
|
6
11
|
// ── Row types ─────────────────────────────────────────────────────────────
|
|
7
12
|
|
|
@@ -262,7 +267,7 @@ export function updateBacktestResult(
|
|
|
262
267
|
}
|
|
263
268
|
if (sets.length === 0) return;
|
|
264
269
|
values.push(id);
|
|
265
|
-
db.prepare(`UPDATE backtest_results SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
270
|
+
db.prepare(`UPDATE backtest_results SET ${sets.join(", ")} WHERE id = ?`).run(...sqlParams(values));
|
|
266
271
|
} catch {
|
|
267
272
|
// Logging must never crash the calling tool
|
|
268
273
|
}
|
|
@@ -347,7 +352,7 @@ export function updateScanHistory(
|
|
|
347
352
|
}
|
|
348
353
|
if (sets.length === 0) return;
|
|
349
354
|
values.push(id);
|
|
350
|
-
db.prepare(`UPDATE scan_history SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
355
|
+
db.prepare(`UPDATE scan_history SET ${sets.join(", ")} WHERE id = ?`).run(...sqlParams(values));
|
|
351
356
|
} catch {
|
|
352
357
|
// Logging must never crash the calling tool
|
|
353
358
|
}
|
|
@@ -378,7 +383,7 @@ export function queryScanHistory(
|
|
|
378
383
|
params.push(limit, offset);
|
|
379
384
|
return db
|
|
380
385
|
.prepare(`SELECT * FROM scan_history ${where} ORDER BY started_at DESC LIMIT ? OFFSET ?`)
|
|
381
|
-
.all(...params) as ScanHistoryEntry[];
|
|
386
|
+
.all(...sqlParams(params)) as unknown as ScanHistoryEntry[];
|
|
382
387
|
}
|
|
383
388
|
|
|
384
389
|
/**
|
|
@@ -462,7 +467,7 @@ export function queryPriceAlerts(
|
|
|
462
467
|
params.push(limit, offset);
|
|
463
468
|
return db
|
|
464
469
|
.prepare(`SELECT * FROM price_alerts ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
465
|
-
.all(...params) as PriceAlertEntry[];
|
|
470
|
+
.all(...sqlParams(params)) as unknown as PriceAlertEntry[];
|
|
466
471
|
}
|
|
467
472
|
|
|
468
473
|
/** Count price_alerts rows since an inclusive time bound (ISO 8601 created_at). */
|
|
@@ -488,19 +493,19 @@ export function acknowledgePriceAlert(db: DatabaseSync, id: string): void {
|
|
|
488
493
|
export function queryActivityLog(db: DatabaseSync, limit = 50, offset = 0): ActivityLogEntry[] {
|
|
489
494
|
return db
|
|
490
495
|
.prepare(`SELECT * FROM agent_activity_log ORDER BY timestamp DESC LIMIT ? OFFSET ?`)
|
|
491
|
-
.all(limit, offset) as ActivityLogEntry[];
|
|
496
|
+
.all(limit, offset) as unknown as ActivityLogEntry[];
|
|
492
497
|
}
|
|
493
498
|
|
|
494
499
|
/** Query agent_events, newest first. */
|
|
495
500
|
export function queryAgentEvents(db: DatabaseSync, limit = 50, offset = 0): AgentEventEntry[] {
|
|
496
501
|
return db
|
|
497
502
|
.prepare(`SELECT * FROM agent_events ORDER BY timestamp DESC LIMIT ? OFFSET ?`)
|
|
498
|
-
.all(limit, offset) as AgentEventEntry[];
|
|
503
|
+
.all(limit, offset) as unknown as AgentEventEntry[];
|
|
499
504
|
}
|
|
500
505
|
|
|
501
506
|
/** Query all strategies, newest first. */
|
|
502
507
|
export function queryStrategies(db: DatabaseSync): StrategyRow[] {
|
|
503
|
-
return db.prepare(`SELECT * FROM strategies ORDER BY updated_at DESC`).all() as StrategyRow[];
|
|
508
|
+
return db.prepare(`SELECT * FROM strategies ORDER BY updated_at DESC`).all() as unknown as StrategyRow[];
|
|
504
509
|
}
|
|
505
510
|
|
|
506
511
|
/** Query backtest results, optionally filtered by strategy_id. */
|
|
@@ -508,9 +513,9 @@ export function queryBacktestResults(db: DatabaseSync, strategyId?: string): Bac
|
|
|
508
513
|
if (strategyId) {
|
|
509
514
|
return db
|
|
510
515
|
.prepare(`SELECT * FROM backtest_results WHERE strategy_id = ? ORDER BY created_at DESC`)
|
|
511
|
-
.all(strategyId) as BacktestRow[];
|
|
516
|
+
.all(strategyId) as unknown as BacktestRow[];
|
|
512
517
|
}
|
|
513
518
|
return db
|
|
514
519
|
.prepare(`SELECT * FROM backtest_results ORDER BY created_at DESC`)
|
|
515
|
-
.all() as BacktestRow[];
|
|
520
|
+
.all() as unknown as BacktestRow[];
|
|
516
521
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Create (or migrate) all MVP tables.
|
|
@@ -134,9 +135,29 @@ export function ensureSchema(db: DatabaseSync): void {
|
|
|
134
135
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_price_alerts_created ON price_alerts(created_at);`);
|
|
135
136
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_price_alerts_strategy ON price_alerts(strategy_id);`);
|
|
136
137
|
|
|
138
|
+
// ── tournament tables ──────────────────────────────────────────────────
|
|
139
|
+
ensureTournamentSchema(db);
|
|
140
|
+
|
|
137
141
|
// ── migrations (add columns to existing databases) ──────────────────────
|
|
138
142
|
}
|
|
139
143
|
|
|
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
|
+
|
|
140
161
|
/** Add a column if it doesn't already exist (safe for repeated calls). */
|
|
141
162
|
function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void {
|
|
142
163
|
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
});
|