@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.
- package/index.test.ts +4 -2
- package/index.ts +37 -16
- package/package.json +1 -1
- package/src/config.ts +3 -1
- package/src/db/repositories.ts +79 -17
- package/src/db/schema.ts +5 -1
- package/src/scheduler/news-provider.ts +1 -1
- package/src/scheduler/periodic-report-builder.ts +71 -0
- package/src/scheduler/scan-report-builder.ts +6 -2
- package/src/scheduler/tools.ts +362 -42
- package/src/strategy/tools.ts +114 -105
- 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/src/strategy/tools.ts
CHANGED
|
@@ -204,122 +204,131 @@ export function registerStrategyTools(
|
|
|
204
204
|
Type.String({ description: "Backtest task ID from skill_publish response" }),
|
|
205
205
|
),
|
|
206
206
|
}),
|
|
207
|
-
execute: withLogging(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
execute: withLogging(
|
|
208
|
+
getDb,
|
|
209
|
+
"skill_publish_verify",
|
|
210
|
+
"strategy",
|
|
211
|
+
async (_toolCallId, params) => {
|
|
212
|
+
try {
|
|
213
|
+
const submissionId = String(params.submissionId ?? "").trim() || undefined;
|
|
214
|
+
const backtestTaskId = String(params.backtestTaskId ?? "").trim() || undefined;
|
|
211
215
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
if (!submissionId && !backtestTaskId) {
|
|
217
|
+
return json({
|
|
218
|
+
success: false,
|
|
219
|
+
error: "Either submissionId or backtestTaskId is required",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
if (!config.apiKey) {
|
|
224
|
+
return json({
|
|
225
|
+
success: false,
|
|
226
|
+
error: NO_API_KEY,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
225
229
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
230
|
+
const searchParams: Record<string, string> = {};
|
|
231
|
+
if (submissionId) searchParams.submissionId = submissionId;
|
|
232
|
+
if (backtestTaskId) searchParams.backtestTaskId = backtestTaskId;
|
|
229
233
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
const { status, data } = await hubApiRequest(config, "GET", "/skill/publish/verify", {
|
|
235
|
+
searchParams,
|
|
236
|
+
});
|
|
233
237
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
typeof perf?.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
monthly_returns:
|
|
260
|
-
report?.monthly_returns && typeof report.monthly_returns === "object"
|
|
261
|
-
? JSON.stringify(report.monthly_returns)
|
|
238
|
+
if (status >= 200 && status < 300) {
|
|
239
|
+
const resp = data as Record<string, unknown>;
|
|
240
|
+
|
|
241
|
+
// Update backtest result metrics and strategy level
|
|
242
|
+
const verifyStrategyId = resp.entryId as string | undefined;
|
|
243
|
+
if (resp.backtestStatus === "completed" && backtestTaskId) {
|
|
244
|
+
const report = resp.backtestReport as Record<string, unknown> | undefined;
|
|
245
|
+
const perf = report?.performance as Record<string, unknown> | undefined;
|
|
246
|
+
updateBacktestResult(getDb(), backtestTaskId, {
|
|
247
|
+
status: "completed",
|
|
248
|
+
total_return:
|
|
249
|
+
typeof perf?.totalReturn === "number" ? perf.totalReturn : undefined,
|
|
250
|
+
sharpe: typeof perf?.sharpe === "number" ? perf.sharpe : undefined,
|
|
251
|
+
sortino: typeof perf?.sortino === "number" ? perf.sortino : undefined,
|
|
252
|
+
max_drawdown:
|
|
253
|
+
typeof perf?.maxDrawdown === "number" ? perf.maxDrawdown : undefined,
|
|
254
|
+
win_rate: typeof perf?.winRate === "number" ? perf.winRate : undefined,
|
|
255
|
+
profit_factor:
|
|
256
|
+
typeof perf?.profitFactor === "number" ? perf.profitFactor : undefined,
|
|
257
|
+
total_trades:
|
|
258
|
+
typeof perf?.totalTrades === "number" ? perf.totalTrades : undefined,
|
|
259
|
+
final_equity:
|
|
260
|
+
typeof perf?.finalEquity === "number" ? perf.finalEquity : undefined,
|
|
261
|
+
equity_curve: Array.isArray(report?.equity_curve)
|
|
262
|
+
? JSON.stringify(report.equity_curve)
|
|
262
263
|
: undefined,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
264
|
+
trade_journal: Array.isArray(report?.trade_journal)
|
|
265
|
+
? JSON.stringify(report.trade_journal)
|
|
266
|
+
: undefined,
|
|
267
|
+
monthly_returns:
|
|
268
|
+
report?.monthly_returns && typeof report.monthly_returns === "object"
|
|
269
|
+
? JSON.stringify(report.monthly_returns)
|
|
270
|
+
: undefined,
|
|
271
|
+
tearsheet_html:
|
|
272
|
+
typeof report?.tearsheet_html === "string" ? report.tearsheet_html : undefined,
|
|
273
|
+
completed_at: new Date().toISOString(),
|
|
274
|
+
});
|
|
275
|
+
// Backtest completed → strategy stays at L1
|
|
276
|
+
if (verifyStrategyId) updateStrategyLevel(getDb(), verifyStrategyId, "L1");
|
|
277
|
+
} else if (resp.backtestStatus === "failed" && backtestTaskId) {
|
|
278
|
+
updateBacktestResult(getDb(), backtestTaskId, {
|
|
279
|
+
status: "failed",
|
|
280
|
+
completed_at: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
// Backtest failed → revert to L0
|
|
283
|
+
if (verifyStrategyId) updateStrategyLevel(getDb(), verifyStrategyId, "L0");
|
|
284
|
+
}
|
|
283
285
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
lines.push(
|
|
297
|
-
|
|
298
|
-
|
|
286
|
+
const lines: string[] = [];
|
|
287
|
+
lines.push("发布验证结果:");
|
|
288
|
+
lines.push(`- Slug: ${resp.slug ?? "(未知)"}`);
|
|
289
|
+
lines.push(`- Version: ${resp.version ?? "(未知)"}`);
|
|
290
|
+
lines.push(`- Backtest Status: ${resp.backtestStatus ?? "(未知)"}`);
|
|
291
|
+
|
|
292
|
+
if (resp.backtestStatus === "completed" && resp.backtestReport) {
|
|
293
|
+
const perf = (resp.backtestReport as Record<string, unknown>).performance as
|
|
294
|
+
| Record<string, unknown>
|
|
295
|
+
| undefined;
|
|
296
|
+
if (perf) {
|
|
297
|
+
lines.push("");
|
|
298
|
+
lines.push("回测报告摘要:");
|
|
299
|
+
if (typeof perf.totalReturn === "number")
|
|
300
|
+
lines.push(`- 总收益率: ${(perf.totalReturn * 100).toFixed(2)}%`);
|
|
301
|
+
if (typeof perf.sharpe === "number")
|
|
302
|
+
lines.push(`- 夏普比率: ${perf.sharpe.toFixed(3)}`);
|
|
303
|
+
if (typeof perf.maxDrawdown === "number")
|
|
304
|
+
lines.push(`- 最大回撤: ${(perf.maxDrawdown * 100).toFixed(2)}%`);
|
|
305
|
+
if (typeof perf.winRate === "number")
|
|
306
|
+
lines.push(`- 胜率: ${(perf.winRate * 100).toFixed(1)}%`);
|
|
307
|
+
}
|
|
299
308
|
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
312
|
+
details: { success: true, ...resp },
|
|
313
|
+
};
|
|
300
314
|
}
|
|
301
315
|
|
|
302
|
-
return {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
316
|
+
return json({
|
|
317
|
+
success: false,
|
|
318
|
+
status,
|
|
319
|
+
error:
|
|
320
|
+
(data as { code?: string; message?: string })?.message ??
|
|
321
|
+
(data as { detail?: string })?.detail ??
|
|
322
|
+
data,
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
return json({
|
|
326
|
+
success: false,
|
|
327
|
+
error: err instanceof Error ? err.message : String(err),
|
|
328
|
+
});
|
|
306
329
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
success: false,
|
|
310
|
-
status,
|
|
311
|
-
error:
|
|
312
|
-
(data as { code?: string; message?: string })?.message ??
|
|
313
|
-
(data as { detail?: string })?.detail ??
|
|
314
|
-
data,
|
|
315
|
-
});
|
|
316
|
-
} catch (err) {
|
|
317
|
-
return json({
|
|
318
|
-
success: false,
|
|
319
|
-
error: err instanceof Error ? err.message : String(err),
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
}),
|
|
330
|
+
},
|
|
331
|
+
),
|
|
323
332
|
},
|
|
324
333
|
{ names: ["skill_publish_verify"] },
|
|
325
334
|
);
|
|
@@ -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
|
+
});
|