@openfinclaw/openfinclaw-strategy 2026.3.275 → 2026.3.310

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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Tournament SQLite persistence layer.
3
+ * Manages rounds, strategies, agent records, and user picks.
4
+ * @module openfinclaw/tournament/db
5
+ */
6
+ import type { DatabaseSync } from "node:sqlite";
7
+
8
+ // ── Types ─────────────────────────────────────────────────────────────────
9
+
10
+ export interface TournamentRound {
11
+ id: string;
12
+ date: string;
13
+ ticker: string;
14
+ status: "pending" | "running" | "completed" | "skipped";
15
+ started_at: string | null;
16
+ completed_at: string | null;
17
+ skip_reason: string | null;
18
+ }
19
+
20
+ export interface TournamentStrategy {
21
+ round_id: string;
22
+ agent_name: string;
23
+ thesis: string;
24
+ entry_price: number | null;
25
+ exit_price: number | null;
26
+ stop_loss: number | null;
27
+ position_pct: number | null;
28
+ confidence: number;
29
+ sharpe: number | null;
30
+ max_drawdown: number | null;
31
+ total_return: number | null;
32
+ raw_result: string | null;
33
+ created_at: string;
34
+ }
35
+
36
+ export interface TournamentAgent {
37
+ name: string;
38
+ wins: number;
39
+ losses: number;
40
+ avg_sharpe: number;
41
+ rounds_played: number;
42
+ }
43
+
44
+ export interface TournamentPick {
45
+ round_id: string;
46
+ user_id: string;
47
+ session_key: string;
48
+ agent_name: string;
49
+ picked_at: string;
50
+ }
51
+
52
+ // ── Schema ────────────────────────────────────────────────────────────────
53
+
54
+ /** Create tournament tables. Safe to call multiple times. */
55
+ export function ensureTournamentSchema(db: DatabaseSync): void {
56
+ db.exec(`
57
+ CREATE TABLE IF NOT EXISTS tournament_rounds (
58
+ id TEXT PRIMARY KEY,
59
+ date TEXT NOT NULL,
60
+ ticker TEXT NOT NULL,
61
+ status TEXT NOT NULL DEFAULT 'pending',
62
+ started_at TEXT,
63
+ completed_at TEXT,
64
+ skip_reason TEXT
65
+ );
66
+ `);
67
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tournament_rounds_date ON tournament_rounds(date);`);
68
+
69
+ db.exec(`
70
+ CREATE TABLE IF NOT EXISTS tournament_strategies (
71
+ round_id TEXT NOT NULL,
72
+ agent_name TEXT NOT NULL,
73
+ thesis TEXT NOT NULL,
74
+ entry_price REAL,
75
+ exit_price REAL,
76
+ stop_loss REAL,
77
+ position_pct REAL,
78
+ confidence INTEGER NOT NULL DEFAULT 50,
79
+ sharpe REAL,
80
+ max_drawdown REAL,
81
+ total_return REAL,
82
+ raw_result TEXT,
83
+ created_at TEXT NOT NULL,
84
+ PRIMARY KEY (round_id, agent_name)
85
+ );
86
+ `);
87
+
88
+ db.exec(`
89
+ CREATE TABLE IF NOT EXISTS tournament_agents (
90
+ name TEXT PRIMARY KEY,
91
+ wins INTEGER NOT NULL DEFAULT 0,
92
+ losses INTEGER NOT NULL DEFAULT 0,
93
+ avg_sharpe REAL NOT NULL DEFAULT 0,
94
+ rounds_played INTEGER NOT NULL DEFAULT 0
95
+ );
96
+ `);
97
+
98
+ db.exec(`
99
+ CREATE TABLE IF NOT EXISTS tournament_picks (
100
+ round_id TEXT NOT NULL,
101
+ user_id TEXT NOT NULL,
102
+ session_key TEXT NOT NULL,
103
+ agent_name TEXT NOT NULL,
104
+ picked_at TEXT NOT NULL,
105
+ PRIMARY KEY (round_id, user_id)
106
+ );
107
+ `);
108
+ }
109
+
110
+ // ── CRUD Operations ───────────────────────────────────────────────────────
111
+
112
+ export class TournamentDb {
113
+ constructor(private db: DatabaseSync) {}
114
+
115
+ // ── Rounds ──────────────────────────────────────────────────────────
116
+
117
+ /** Create a new tournament round. Returns false if already exists. */
118
+ createRound(round: { id: string; date: string; ticker: string }): boolean {
119
+ const existing = this.getRound(round.id);
120
+ if (existing) return false;
121
+
122
+ const now = new Date().toISOString();
123
+ this.db
124
+ .prepare(
125
+ "INSERT INTO tournament_rounds (id, date, ticker, status, started_at) VALUES (?, ?, ?, 'running', ?)",
126
+ )
127
+ .run(round.id, round.date, round.ticker, now);
128
+ return true;
129
+ }
130
+
131
+ /** Get a round by ID. */
132
+ getRound(id: string): TournamentRound | undefined {
133
+ return this.db.prepare("SELECT * FROM tournament_rounds WHERE id = ?").get(id) as
134
+ | TournamentRound
135
+ | undefined;
136
+ }
137
+
138
+ /** Update round status. */
139
+ updateRoundStatus(id: string, status: TournamentRound["status"], skipReason?: string): void {
140
+ const now = new Date().toISOString();
141
+ if (status === "completed" || status === "skipped") {
142
+ this.db
143
+ .prepare(
144
+ "UPDATE tournament_rounds SET status = ?, completed_at = ?, skip_reason = ? WHERE id = ?",
145
+ )
146
+ .run(status, now, skipReason ?? null, id);
147
+ } else {
148
+ this.db.prepare("UPDATE tournament_rounds SET status = ? WHERE id = ?").run(status, id);
149
+ }
150
+ }
151
+
152
+ /** Get the latest N rounds ordered by date desc. */
153
+ getRecentRounds(limit: number): TournamentRound[] {
154
+ return this.db
155
+ .prepare("SELECT * FROM tournament_rounds ORDER BY date DESC LIMIT ?")
156
+ .all(limit) as TournamentRound[];
157
+ }
158
+
159
+ /** Count consecutive skipped rounds from the most recent. */
160
+ countConsecutiveSkips(): number {
161
+ const recent = this.getRecentRounds(10);
162
+ let count = 0;
163
+ for (const r of recent) {
164
+ if (r.status === "skipped") count++;
165
+ else break;
166
+ }
167
+ return count;
168
+ }
169
+
170
+ // ── Strategies ──────────────────────────────────────────────────────
171
+
172
+ /** Save a strategy result from a subagent. Upserts by (round_id, agent_name). */
173
+ saveStrategy(strategy: Omit<TournamentStrategy, "created_at">): void {
174
+ const now = new Date().toISOString();
175
+ this.db
176
+ .prepare(
177
+ `INSERT OR REPLACE INTO tournament_strategies
178
+ (round_id, agent_name, thesis, entry_price, exit_price, stop_loss,
179
+ position_pct, confidence, sharpe, max_drawdown, total_return, raw_result, created_at)
180
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
181
+ )
182
+ .run(
183
+ strategy.round_id,
184
+ strategy.agent_name,
185
+ strategy.thesis,
186
+ strategy.entry_price ?? null,
187
+ strategy.exit_price ?? null,
188
+ strategy.stop_loss ?? null,
189
+ strategy.position_pct ?? null,
190
+ strategy.confidence,
191
+ strategy.sharpe ?? null,
192
+ strategy.max_drawdown ?? null,
193
+ strategy.total_return ?? null,
194
+ strategy.raw_result ?? null,
195
+ now,
196
+ );
197
+ }
198
+
199
+ /** Get all strategies for a round. */
200
+ getStrategies(roundId: string): TournamentStrategy[] {
201
+ return this.db
202
+ .prepare("SELECT * FROM tournament_strategies WHERE round_id = ? ORDER BY agent_name")
203
+ .all(roundId) as TournamentStrategy[];
204
+ }
205
+
206
+ // ── Agents ──────────────────────────────────────────────────────────
207
+
208
+ /** Ensure an agent record exists. */
209
+ ensureAgent(name: string): void {
210
+ this.db.prepare("INSERT OR IGNORE INTO tournament_agents (name) VALUES (?)").run(name);
211
+ }
212
+
213
+ /** Record a win for an agent. Updates avg_sharpe from the strategy's backtest. */
214
+ recordWin(name: string, sharpe: number | null): void {
215
+ this.ensureAgent(name);
216
+ const agent = this.getAgent(name)!;
217
+ const newRounds = agent.rounds_played + 1;
218
+ const newSharpe =
219
+ sharpe != null
220
+ ? (agent.avg_sharpe * agent.rounds_played + sharpe) / newRounds
221
+ : agent.avg_sharpe;
222
+ this.db
223
+ .prepare(
224
+ "UPDATE tournament_agents SET wins = wins + 1, rounds_played = ?, avg_sharpe = ? WHERE name = ?",
225
+ )
226
+ .run(newRounds, newSharpe, name);
227
+ }
228
+
229
+ /** Record a loss for an agent. */
230
+ recordLoss(name: string, sharpe: number | null): void {
231
+ this.ensureAgent(name);
232
+ const agent = this.getAgent(name)!;
233
+ const newRounds = agent.rounds_played + 1;
234
+ const newSharpe =
235
+ sharpe != null
236
+ ? (agent.avg_sharpe * agent.rounds_played + sharpe) / newRounds
237
+ : agent.avg_sharpe;
238
+ this.db
239
+ .prepare(
240
+ "UPDATE tournament_agents SET losses = losses + 1, rounds_played = ?, avg_sharpe = ? WHERE name = ?",
241
+ )
242
+ .run(newRounds, newSharpe, name);
243
+ }
244
+
245
+ /** Get agent stats. */
246
+ getAgent(name: string): TournamentAgent | undefined {
247
+ return this.db.prepare("SELECT * FROM tournament_agents WHERE name = ?").get(name) as
248
+ | TournamentAgent
249
+ | undefined;
250
+ }
251
+
252
+ /** Get all agents sorted by avg_sharpe desc. */
253
+ getLeaderboard(): TournamentAgent[] {
254
+ return this.db
255
+ .prepare("SELECT * FROM tournament_agents ORDER BY avg_sharpe DESC, wins DESC")
256
+ .all() as TournamentAgent[];
257
+ }
258
+
259
+ // ── Picks ───────────────────────────────────────────────────────────
260
+
261
+ /** Record a user's pick. Idempotent per (round_id, user_id). Returns true if new. */
262
+ recordPick(pick: Omit<TournamentPick, "picked_at">): boolean {
263
+ const existing = this.db
264
+ .prepare("SELECT * FROM tournament_picks WHERE round_id = ? AND user_id = ?")
265
+ .get(pick.round_id, pick.user_id) as TournamentPick | undefined;
266
+ if (existing) return false;
267
+
268
+ const now = new Date().toISOString();
269
+ this.db
270
+ .prepare(
271
+ "INSERT INTO tournament_picks (round_id, user_id, session_key, agent_name, picked_at) VALUES (?, ?, ?, ?, ?)",
272
+ )
273
+ .run(pick.round_id, pick.user_id, pick.session_key, pick.agent_name, now);
274
+ return true;
275
+ }
276
+
277
+ /** Get the most recent completed round ID. */
278
+ getLatestCompletedRoundId(): string | undefined {
279
+ const row = this.db
280
+ .prepare(
281
+ "SELECT id FROM tournament_rounds WHERE status = 'completed' ORDER BY date DESC LIMIT 1",
282
+ )
283
+ .get() as { id: string } | undefined;
284
+ return row?.id;
285
+ }
286
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Unit tests for tournament orchestrator.
3
+ * @module openfinclaw/tournament/orchestrator.test
4
+ */
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { TournamentDb, ensureTournamentSchema } from "./db.js";
8
+ import { type OrchestratorDeps, type SpawnResult, TournamentOrchestrator } from "./orchestrator.js";
9
+
10
+ function createMockDeps(db: TournamentDb, overrides?: Partial<OrchestratorDeps>): OrchestratorDeps {
11
+ return {
12
+ db,
13
+ selectTicker: vi.fn().mockResolvedValue("AAPL"),
14
+ spawnSubagent: vi.fn().mockResolvedValue({ status: "accepted", runId: "run-1" } as SpawnResult),
15
+ enqueueSystemEvent: vi.fn(),
16
+ requestHeartbeatNow: vi.fn(),
17
+ waitForResults: vi.fn().mockResolvedValue([
18
+ {
19
+ round_id: expect.any(String),
20
+ agent_name: "bull",
21
+ thesis: "Bullish EMA crossover",
22
+ entry_price: 150,
23
+ exit_price: 165,
24
+ stop_loss: 145,
25
+ position_pct: 0.25,
26
+ confidence: 85,
27
+ sharpe: 1.5,
28
+ max_drawdown: -0.08,
29
+ total_return: 0.12,
30
+ raw_result: null,
31
+ created_at: new Date().toISOString(),
32
+ },
33
+ {
34
+ round_id: expect.any(String),
35
+ agent_name: "bear",
36
+ thesis: "RSI overbought signals",
37
+ entry_price: 150,
38
+ exit_price: 135,
39
+ stop_loss: 155,
40
+ position_pct: 0.2,
41
+ confidence: 70,
42
+ sharpe: 0.8,
43
+ max_drawdown: -0.12,
44
+ total_return: -0.05,
45
+ raw_result: null,
46
+ created_at: new Date().toISOString(),
47
+ },
48
+ {
49
+ round_id: expect.any(String),
50
+ agent_name: "contrarian",
51
+ thesis: "Sentiment reversal",
52
+ entry_price: 148,
53
+ exit_price: 160,
54
+ stop_loss: 142,
55
+ position_pct: 0.15,
56
+ confidence: 60,
57
+ sharpe: 1.2,
58
+ max_drawdown: -0.1,
59
+ total_return: 0.08,
60
+ raw_result: null,
61
+ created_at: new Date().toISOString(),
62
+ },
63
+ ]),
64
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
65
+ config: {
66
+ maxAgents: 3,
67
+ subagentTimeoutSeconds: 600,
68
+ alertAfterSkips: 3,
69
+ sessionKey: "agent:main:test",
70
+ },
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ describe("TournamentOrchestrator", () => {
76
+ let sqliteDb: DatabaseSync;
77
+ let tdb: TournamentDb;
78
+
79
+ beforeEach(() => {
80
+ sqliteDb = new DatabaseSync(":memory:");
81
+ sqliteDb.exec("PRAGMA journal_mode = WAL");
82
+ ensureTournamentSchema(sqliteDb);
83
+ tdb = new TournamentDb(sqliteDb);
84
+ });
85
+
86
+ afterEach(() => {
87
+ sqliteDb.close();
88
+ vi.restoreAllMocks();
89
+ });
90
+
91
+ it("runs happy path: 3 agents succeed, round completed", async () => {
92
+ const deps = createMockDeps(tdb);
93
+ const orch = new TournamentOrchestrator(deps);
94
+
95
+ await orch.runDailyTournament();
96
+
97
+ expect(deps.selectTicker).toHaveBeenCalledOnce();
98
+ expect(deps.spawnSubagent).toHaveBeenCalledTimes(3);
99
+ expect(deps.enqueueSystemEvent).toHaveBeenCalledOnce();
100
+ expect(deps.requestHeartbeatNow).toHaveBeenCalledOnce();
101
+
102
+ const today = new Date().toISOString().slice(0, 10);
103
+ const roundId = `round-${today.replace(/-/g, "")}`;
104
+ const round = tdb.getRound(roundId);
105
+ expect(round?.status).toBe("completed");
106
+ });
107
+
108
+ it("skips idempotently when round already completed", async () => {
109
+ const today = new Date().toISOString().slice(0, 10);
110
+ const roundId = `round-${today.replace(/-/g, "")}`;
111
+ tdb.createRound({ id: roundId, date: today, ticker: "AAPL" });
112
+ tdb.updateRoundStatus(roundId, "completed");
113
+
114
+ const deps = createMockDeps(tdb);
115
+ const orch = new TournamentOrchestrator(deps);
116
+
117
+ await orch.runDailyTournament();
118
+
119
+ expect(deps.selectTicker).not.toHaveBeenCalled();
120
+ expect(deps.spawnSubagent).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("completes with 2/3 agents when one fails to spawn", async () => {
124
+ let callCount = 0;
125
+ const deps = createMockDeps(tdb, {
126
+ spawnSubagent: vi.fn().mockImplementation(() => {
127
+ callCount++;
128
+ if (callCount === 2)
129
+ return Promise.resolve({ status: "error", error: "slot full" } as SpawnResult);
130
+ return Promise.resolve({ status: "accepted", runId: `run-${callCount}` } as SpawnResult);
131
+ }),
132
+ waitForResults: vi.fn().mockResolvedValue([
133
+ {
134
+ round_id: "any",
135
+ agent_name: "bull",
136
+ thesis: "test",
137
+ confidence: 80,
138
+ entry_price: null,
139
+ exit_price: null,
140
+ stop_loss: null,
141
+ position_pct: null,
142
+ sharpe: 1.0,
143
+ max_drawdown: null,
144
+ total_return: null,
145
+ raw_result: null,
146
+ created_at: new Date().toISOString(),
147
+ },
148
+ {
149
+ round_id: "any",
150
+ agent_name: "contrarian",
151
+ thesis: "test",
152
+ confidence: 70,
153
+ entry_price: null,
154
+ exit_price: null,
155
+ stop_loss: null,
156
+ position_pct: null,
157
+ sharpe: 0.9,
158
+ max_drawdown: null,
159
+ total_return: null,
160
+ raw_result: null,
161
+ created_at: new Date().toISOString(),
162
+ },
163
+ ]),
164
+ });
165
+ const orch = new TournamentOrchestrator(deps);
166
+
167
+ await orch.runDailyTournament();
168
+
169
+ expect(deps.enqueueSystemEvent).toHaveBeenCalledOnce();
170
+ const today = new Date().toISOString().slice(0, 10);
171
+ const roundId = `round-${today.replace(/-/g, "")}`;
172
+ expect(tdb.getRound(roundId)?.status).toBe("completed");
173
+ });
174
+
175
+ it("marks round as skipped when <2 agents succeed", async () => {
176
+ const deps = createMockDeps(tdb, {
177
+ spawnSubagent: vi
178
+ .fn()
179
+ .mockResolvedValue({ status: "forbidden", error: "slots full" } as SpawnResult),
180
+ });
181
+ const orch = new TournamentOrchestrator(deps);
182
+
183
+ await orch.runDailyTournament();
184
+
185
+ const today = new Date().toISOString().slice(0, 10);
186
+ const roundId = `round-${today.replace(/-/g, "")}`;
187
+ const round = tdb.getRound(roundId);
188
+ expect(round?.status).toBe("skipped");
189
+ expect(round?.skip_reason).toContain("0/3");
190
+ });
191
+
192
+ it("sends alert after 3 consecutive skips", async () => {
193
+ // Create 2 prior skipped rounds
194
+ tdb.createRound({ id: "round-20260330", date: "2026-03-30", ticker: "A" });
195
+ tdb.updateRoundStatus("round-20260330", "skipped");
196
+ tdb.createRound({ id: "round-20260329", date: "2026-03-29", ticker: "B" });
197
+ tdb.updateRoundStatus("round-20260329", "skipped");
198
+
199
+ // This round will also be skipped (all spawns fail)
200
+ const deps = createMockDeps(tdb, {
201
+ spawnSubagent: vi
202
+ .fn()
203
+ .mockResolvedValue({ status: "forbidden", error: "no slots" } as SpawnResult),
204
+ });
205
+ const orch = new TournamentOrchestrator(deps);
206
+
207
+ await orch.runDailyTournament();
208
+
209
+ // Should have 2 calls: 1 for skip alert (consecutive failures)
210
+ // The round result message is NOT sent because it was skipped
211
+ const calls = (deps.enqueueSystemEvent as ReturnType<typeof vi.fn>).mock.calls;
212
+ expect(calls.length).toBeGreaterThanOrEqual(1);
213
+ const alertCall = calls.find((c: unknown[]) => String(c[0]).includes("连续"));
214
+ expect(alertCall).toBeTruthy();
215
+ });
216
+
217
+ it("skips when ticker selection fails", async () => {
218
+ const deps = createMockDeps(tdb, {
219
+ selectTicker: vi.fn().mockRejectedValue(new Error("DataHub offline")),
220
+ });
221
+ const orch = new TournamentOrchestrator(deps);
222
+
223
+ await orch.runDailyTournament();
224
+
225
+ expect(deps.spawnSubagent).not.toHaveBeenCalled();
226
+ const today = new Date().toISOString().slice(0, 10);
227
+ const roundId = `round-${today.replace(/-/g, "")}`;
228
+ const round = tdb.getRound(roundId);
229
+ expect(round?.status).toBe("skipped");
230
+ expect(round?.skip_reason).toContain("Ticker selection failed");
231
+ });
232
+ });