@openfinclaw/openfinclaw-strategy 2026.4.10 → 2026.4.11

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/README.md CHANGED
@@ -14,7 +14,7 @@ openclaw plugins install @openfinclaw/openfinclaw-strategy
14
14
  ## 快速开始
15
15
 
16
16
  ```bash
17
- # 查看策略排行榜(无需 API Key)
17
+ # 查看策略排行榜
18
18
  openclaw strategy leaderboard
19
19
 
20
20
  # 查看收益榜 Top 10
@@ -45,24 +45,24 @@ openclaw strategy show <strategy-id> --remote
45
45
 
46
46
  ### 策略工具
47
47
 
48
- | 工具名 | 说明 | API Key |
49
- | ---------------------- | -------------------------- | -------- |
50
- | `skill_leaderboard` | 查询排行榜 | 不需要 |
51
- | `skill_get_info` | 获取 Hub 策略公开详情 | 不需要 |
52
- | `skill_validate` | 本地验证策略包(FEP v2.0) | 不需要 |
53
- | `skill_list_local` | 列出本地策略 | 不需要 |
54
- | `skill_fork` | 从 Hub 下载策略到本地 | **需要** |
55
- | `skill_publish` | 发布策略 ZIP 到 Hub | **需要** |
56
- | `skill_publish_verify` | 查询发布状态和回测报告 | **需要** |
48
+ | 工具名 | 说明 | API Key |
49
+ | ---------------------- | -------------------------- | ------- |
50
+ | `skill_leaderboard` | 查询排行榜 | 需要 |
51
+ | `skill_get_info` | 获取 Hub 策略公开详情 | 需要 |
52
+ | `skill_validate` | 本地验证策略包(FEP v2.0) | 需要 |
53
+ | `skill_list_local` | 列出本地策略 | 需要 |
54
+ | `skill_fork` | 从 Hub 下载策略到本地 | 需要 |
55
+ | `skill_publish` | 发布策略 ZIP 到 Hub | 需要 |
56
+ | `skill_publish_verify` | 查询发布状态和回测报告 | 需要 |
57
57
 
58
58
  ## CLI 命令
59
59
 
60
60
  ```bash
61
- # 查看排行榜(无需 API Key)
61
+ # 查看排行榜
62
62
  openclaw strategy leaderboard
63
63
  openclaw strategy leaderboard returns --limit 10
64
64
 
65
- # 从 Hub Fork 策略(需要 API Key)
65
+ # 从 Hub Fork 策略
66
66
  openclaw strategy fork <strategy-id>
67
67
 
68
68
  # 列出本地策略
package/SKILL.md CHANGED
@@ -54,13 +54,13 @@ Agent: 使用 fin_compare 对比收益
54
54
  ### 策略管理
55
55
 
56
56
  ```bash
57
- # 查看排行榜(无需 API Key)
57
+ # 查看排行榜
58
58
  openclaw strategy leaderboard
59
59
 
60
60
  # 查看收益榜 Top 10
61
61
  openclaw strategy leaderboard returns --limit 10
62
62
 
63
- # 查看策略详情(无需 API Key)
63
+ # 查看策略详情
64
64
  openclaw strategy show 550e8400-e29b-41d4-a716-446655440001 --remote
65
65
  ```
66
66
 
@@ -134,10 +134,10 @@ export OPENFINCLAW_API_KEY=YOUR_API_KEY
134
134
 
135
135
  | 工具名 | 用途 | 需要 API Key |
136
136
  | ---------------------- | -------------------------------------- | ------------ |
137
- | `skill_leaderboard` | 查询排行榜(综合/收益/风控/人气/新星) | |
138
- | `skill_get_info` | 获取 Hub 策略公开详情 | |
139
- | `skill_validate` | 本地验证策略包格式(FEP v2.0) | |
140
- | `skill_list_local` | 列出本地已下载的策略 | |
137
+ | `skill_leaderboard` | 查询排行榜(综合/收益/风控/人气/新星) | **是** |
138
+ | `skill_get_info` | 获取 Hub 策略公开详情 | **是** |
139
+ | `skill_validate` | 本地验证策略包格式(FEP v2.0) | **是** |
140
+ | `skill_list_local` | 列出本地已下载的策略 | **是** |
141
141
  | `skill_fork` | 从 Hub 下载公开策略到本地 | **是** |
142
142
  | `skill_publish` | 发布策略 ZIP 到 Hub,自动触发回测 | **是** |
143
143
  | `skill_publish_verify` | 查询发布状态和回测报告 | **是** |
@@ -172,7 +172,7 @@ export OPENFINCLAW_API_KEY=YOUR_API_KEY
172
172
 
173
173
  ### strategy leaderboard
174
174
 
175
- 查看 Hub 排行榜(无需 API Key):
175
+ 查看 Hub 排行榜:
176
176
 
177
177
  ```bash
178
178
  # 综合榜 Top 20(默认)
@@ -223,7 +223,7 @@ openclaw strategy list
223
223
  # 查看本地策略
224
224
  openclaw strategy show btc-adaptive-dca-34a5792f
225
225
 
226
- # 从 Hub 获取最新信息(无需 API Key)
226
+ # 从 Hub 获取最新信息
227
227
  openclaw strategy show 550e8400-e29b-41d4-a716-446655440001 --remote
228
228
  ```
229
229
 
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.10",
3
+ "version": "2026.4.11",
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/cli.ts CHANGED
@@ -50,7 +50,7 @@ export function registerStrategyCli(params: {
50
50
  // ── strategy leaderboard ──
51
51
  root
52
52
  .command("leaderboard [boardType]")
53
- .description("Query strategy leaderboard from Hub (no API key required)")
53
+ .description("Query strategy leaderboard from Hub")
54
54
  .option("-l, --limit <number>", "Number of results (max 100)", "20")
55
55
  .option("-o, --offset <number>", "Offset for pagination", "0")
56
56
  .action(
@@ -344,10 +344,18 @@ function printStrategyInfo(
344
344
  console.log("Hub:");
345
345
  console.log(` ID: ${hub.id}`);
346
346
  console.log(` 名称: ${hub.name}`);
347
- if (hub.version) console.log(` 版本: ${hub.version}`);
348
- if (hub.author?.displayName) console.log(` 作者: ${hub.author.displayName}`);
349
- if (hub.market) console.log(` 市场: ${hub.market}`);
350
- if (hub.description) console.log(` 描述: ${hub.description}`);
347
+ if (hub.version) {
348
+ console.log(` 版本: ${hub.version}`);
349
+ }
350
+ if (hub.author?.displayName) {
351
+ console.log(` 作者: ${hub.author.displayName}`);
352
+ }
353
+ if (hub.market) {
354
+ console.log(` 市场: ${hub.market}`);
355
+ }
356
+ if (hub.description) {
357
+ console.log(` 描述: ${hub.description}`);
358
+ }
351
359
 
352
360
  if (hub.backtestResult) {
353
361
  console.log("");
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
 
@@ -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
+ });