@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 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.2",
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
 
@@ -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
+ });