@openfinclaw/openfinclaw-strategy 2026.3.310 → 2026.4.2
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 +3 -25
- package/openclaw.plugin.json +12 -2
- package/package.json +1 -1
- package/src/config.ts +27 -1
- package/src/db/schema.ts +0 -4
- package/src/scheduler/cron-setup.ts +93 -26
- package/src/types.ts +6 -0
- package/src/tournament/cron-setup.ts +0 -102
- package/src/tournament/db.test.ts +0 -222
- package/src/tournament/db.ts +0 -286
- package/src/tournament/orchestrator.test.ts +0 -232
- package/src/tournament/orchestrator.ts +0 -238
- package/src/tournament/prompts.ts +0 -65
- package/src/tournament/tools.test.ts +0 -221
- package/src/tournament/tools.ts +0 -192
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for tournament tools.
|
|
3
|
-
* @module openfinclaw/tournament/tools.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
|
-
/**
|
|
10
|
-
* Minimal tool executor for testing.
|
|
11
|
-
* Collects registered tools and lets tests call them directly.
|
|
12
|
-
*/
|
|
13
|
-
class ToolCollector {
|
|
14
|
-
tools = new Map<
|
|
15
|
-
string,
|
|
16
|
-
{ execute: (id: string, params: Record<string, unknown>) => Promise<unknown> }
|
|
17
|
-
>();
|
|
18
|
-
|
|
19
|
-
registerTool(factory: unknown, opts?: unknown) {
|
|
20
|
-
const tool =
|
|
21
|
-
typeof factory === "function" ? (factory as (ctx: unknown) => unknown)(null) : factory;
|
|
22
|
-
const t = tool as {
|
|
23
|
-
name: string;
|
|
24
|
-
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
25
|
-
};
|
|
26
|
-
const names = (opts as { names?: string[] } | undefined)?.names ?? [t.name];
|
|
27
|
-
for (const name of names) {
|
|
28
|
-
this.tools.set(name, t);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async call(
|
|
33
|
-
name: string,
|
|
34
|
-
params: Record<string, unknown>,
|
|
35
|
-
): Promise<{ content: Array<{ type: string; text: string }>; details: unknown }> {
|
|
36
|
-
const tool = this.tools.get(name);
|
|
37
|
-
if (!tool) throw new Error(`Tool not found: ${name}`);
|
|
38
|
-
return tool.execute("test-call-id", params) as Promise<{
|
|
39
|
-
content: Array<{ type: string; text: string }>;
|
|
40
|
-
details: unknown;
|
|
41
|
-
}>;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Extract parsed details from tool result. */
|
|
46
|
-
function details(result: { details: unknown }): Record<string, unknown> {
|
|
47
|
-
if (typeof result.details === "string") return JSON.parse(result.details);
|
|
48
|
-
return result.details as Record<string, unknown>;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe("tournament tools", () => {
|
|
52
|
-
let db: DatabaseSync;
|
|
53
|
-
let tdb: TournamentDb;
|
|
54
|
-
let collector: ToolCollector;
|
|
55
|
-
|
|
56
|
-
beforeEach(async () => {
|
|
57
|
-
db = new DatabaseSync(":memory:");
|
|
58
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
59
|
-
ensureTournamentSchema(db);
|
|
60
|
-
tdb = new TournamentDb(db);
|
|
61
|
-
|
|
62
|
-
collector = new ToolCollector();
|
|
63
|
-
const { registerTournamentTools } = await import("./tools.js");
|
|
64
|
-
registerTournamentTools(collector.registerTool.bind(collector), () => tdb);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
afterEach(() => {
|
|
68
|
-
db.close();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("tournament_pick", () => {
|
|
72
|
-
it("records valid pick", async () => {
|
|
73
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
74
|
-
tdb.updateRoundStatus("round-20260331", "completed");
|
|
75
|
-
tdb.saveStrategy({
|
|
76
|
-
round_id: "round-20260331",
|
|
77
|
-
agent_name: "bull",
|
|
78
|
-
thesis: "test",
|
|
79
|
-
confidence: 80,
|
|
80
|
-
entry_price: null,
|
|
81
|
-
exit_price: null,
|
|
82
|
-
stop_loss: null,
|
|
83
|
-
position_pct: null,
|
|
84
|
-
sharpe: 1.5,
|
|
85
|
-
max_drawdown: null,
|
|
86
|
-
total_return: null,
|
|
87
|
-
raw_result: null,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const result = await collector.call("tournament_pick", {
|
|
91
|
-
agent_name: "bull",
|
|
92
|
-
user_id: "tg:123",
|
|
93
|
-
session_key: "agent:main",
|
|
94
|
-
});
|
|
95
|
-
const d = details(result);
|
|
96
|
-
expect(d.agent_name).toBe("bull");
|
|
97
|
-
expect(d.message).toContain("BULL");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("rejects invalid agent name", async () => {
|
|
101
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
102
|
-
tdb.updateRoundStatus("round-20260331", "completed");
|
|
103
|
-
|
|
104
|
-
const result = await collector.call("tournament_pick", {
|
|
105
|
-
agent_name: "invalid",
|
|
106
|
-
user_id: "tg:123",
|
|
107
|
-
session_key: "agent:main",
|
|
108
|
-
});
|
|
109
|
-
const d = details(result);
|
|
110
|
-
expect(d.error).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("returns error when no active round", async () => {
|
|
114
|
-
const result = await collector.call("tournament_pick", {
|
|
115
|
-
agent_name: "bull",
|
|
116
|
-
user_id: "tg:123",
|
|
117
|
-
session_key: "agent:main",
|
|
118
|
-
});
|
|
119
|
-
const d = details(result);
|
|
120
|
-
expect(d.error).toBe(true);
|
|
121
|
-
expect(d.message).toContain("没有活跃");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("handles duplicate pick idempotently", async () => {
|
|
125
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
126
|
-
tdb.updateRoundStatus("round-20260331", "completed");
|
|
127
|
-
|
|
128
|
-
await collector.call("tournament_pick", {
|
|
129
|
-
agent_name: "bull",
|
|
130
|
-
user_id: "tg:123",
|
|
131
|
-
session_key: "agent:main",
|
|
132
|
-
});
|
|
133
|
-
const result = await collector.call("tournament_pick", {
|
|
134
|
-
agent_name: "bear",
|
|
135
|
-
user_id: "tg:123",
|
|
136
|
-
session_key: "agent:main",
|
|
137
|
-
});
|
|
138
|
-
const d = details(result);
|
|
139
|
-
expect(d.message).toContain("已经");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("allows different users to pick same round", async () => {
|
|
143
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
144
|
-
tdb.updateRoundStatus("round-20260331", "completed");
|
|
145
|
-
|
|
146
|
-
const r1 = await collector.call("tournament_pick", {
|
|
147
|
-
agent_name: "bull",
|
|
148
|
-
user_id: "tg:111",
|
|
149
|
-
session_key: "agent:main",
|
|
150
|
-
});
|
|
151
|
-
const r2 = await collector.call("tournament_pick", {
|
|
152
|
-
agent_name: "bear",
|
|
153
|
-
user_id: "tg:222",
|
|
154
|
-
session_key: "agent:main",
|
|
155
|
-
});
|
|
156
|
-
expect(details(r1).agent_name).toBe("bull");
|
|
157
|
-
expect(details(r2).agent_name).toBe("bear");
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe("tournament_leaderboard", () => {
|
|
162
|
-
it("returns formatted ranking with data", async () => {
|
|
163
|
-
tdb.recordWin("bull", 2.0);
|
|
164
|
-
tdb.recordWin("bear", 1.0);
|
|
165
|
-
tdb.recordWin("contrarian", 3.0);
|
|
166
|
-
|
|
167
|
-
const result = await collector.call("tournament_leaderboard", {});
|
|
168
|
-
const d = details(result);
|
|
169
|
-
expect(d.message).toContain("排行榜");
|
|
170
|
-
expect((d.agents as Array<{ name: string }>)[0].name).toBe("contrarian");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("returns empty message when no data", async () => {
|
|
174
|
-
const result = await collector.call("tournament_leaderboard", {});
|
|
175
|
-
const d = details(result);
|
|
176
|
-
expect(d.message).toContain("还没有");
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
describe("tournament_result", () => {
|
|
181
|
-
it("stores strategy with all fields", async () => {
|
|
182
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
183
|
-
|
|
184
|
-
const result = await collector.call("tournament_result", {
|
|
185
|
-
round_id: "round-20260331",
|
|
186
|
-
agent_name: "bull",
|
|
187
|
-
thesis: "Strong momentum signals",
|
|
188
|
-
entry_price: 150,
|
|
189
|
-
exit_price: 165,
|
|
190
|
-
stop_loss: 145,
|
|
191
|
-
position_pct: 0.25,
|
|
192
|
-
confidence: 85,
|
|
193
|
-
sharpe: 1.5,
|
|
194
|
-
max_drawdown: -0.08,
|
|
195
|
-
total_return: 0.12,
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
const d = details(result);
|
|
199
|
-
expect(d.agent_name).toBe("bull");
|
|
200
|
-
|
|
201
|
-
const strategies = tdb.getStrategies("round-20260331");
|
|
202
|
-
expect(strategies).toHaveLength(1);
|
|
203
|
-
expect(strategies[0].confidence).toBe(85);
|
|
204
|
-
expect(strategies[0].sharpe).toBe(1.5);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("rejects invalid confidence range", async () => {
|
|
208
|
-
tdb.createRound({ id: "round-20260331", date: "2026-03-31", ticker: "AAPL" });
|
|
209
|
-
|
|
210
|
-
const result = await collector.call("tournament_result", {
|
|
211
|
-
round_id: "round-20260331",
|
|
212
|
-
agent_name: "bull",
|
|
213
|
-
thesis: "test",
|
|
214
|
-
confidence: 150,
|
|
215
|
-
});
|
|
216
|
-
const d = details(result);
|
|
217
|
-
expect(d.error).toBe(true);
|
|
218
|
-
expect(d.message).toContain("0-100");
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
});
|
package/src/tournament/tools.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tournament tools: pick, leaderboard, and result submission.
|
|
3
|
-
* @module openfinclaw/tournament/tools
|
|
4
|
-
*/
|
|
5
|
-
import { Type } from "@sinclair/typebox";
|
|
6
|
-
import type { TournamentDb } from "./db.js";
|
|
7
|
-
|
|
8
|
-
const VALID_AGENTS = ["bull", "bear", "contrarian"] as const;
|
|
9
|
-
type AgentName = (typeof VALID_AGENTS)[number];
|
|
10
|
-
|
|
11
|
-
/** JSON helper for tool results. */
|
|
12
|
-
function json(data: unknown): { content: Array<{ type: "text"; text: string }>; details: unknown } {
|
|
13
|
-
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
14
|
-
return { content: [{ type: "text" as const, text }], details: data };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Register tournament tools on the plugin API.
|
|
19
|
-
* @param registerTool - api.registerTool from plugin entry
|
|
20
|
-
* @param getDb - lazy getter for TournamentDb (DB may not be ready at registration time)
|
|
21
|
-
*/
|
|
22
|
-
export function registerTournamentTools(
|
|
23
|
-
registerTool: (tool: unknown, opts?: unknown) => void,
|
|
24
|
-
getDb: () => TournamentDb,
|
|
25
|
-
): void {
|
|
26
|
-
// ── tournament_pick ─────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
registerTool(
|
|
29
|
-
(_ctx: unknown) => ({
|
|
30
|
-
name: "tournament_pick",
|
|
31
|
-
label: "Tournament Pick",
|
|
32
|
-
description:
|
|
33
|
-
"Record a user's strategy pick for the current tournament round. Use when user says '/pick bull', '/pick bear', or '/pick contrarian'.",
|
|
34
|
-
parameters: Type.Object({
|
|
35
|
-
agent_name: Type.String({
|
|
36
|
-
description: "Agent to pick: bull, bear, or contrarian",
|
|
37
|
-
}),
|
|
38
|
-
user_id: Type.Optional(
|
|
39
|
-
Type.String({
|
|
40
|
-
description: "User identifier from the channel (auto-resolved if omitted)",
|
|
41
|
-
}),
|
|
42
|
-
),
|
|
43
|
-
session_key: Type.Optional(
|
|
44
|
-
Type.String({ description: "OpenClaw session key (auto-resolved if omitted)" }),
|
|
45
|
-
),
|
|
46
|
-
}),
|
|
47
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
48
|
-
const db = getDb();
|
|
49
|
-
const agentName = String(params.agent_name).toLowerCase().trim();
|
|
50
|
-
|
|
51
|
-
if (!VALID_AGENTS.includes(agentName as AgentName)) {
|
|
52
|
-
return json({
|
|
53
|
-
error: true,
|
|
54
|
-
message: `无效选择。请用 /pick bull|bear|contrarian,可选值: ${VALID_AGENTS.join(", ")}`,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const roundId = db.getLatestCompletedRoundId();
|
|
59
|
-
if (!roundId) {
|
|
60
|
-
return json({ error: true, message: "当前没有活跃的锦标赛轮次。" });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const userId = String(params.user_id ?? "unknown");
|
|
64
|
-
const sessionKey = String(params.session_key ?? "unknown");
|
|
65
|
-
|
|
66
|
-
const isNew = db.recordPick({
|
|
67
|
-
round_id: roundId,
|
|
68
|
-
user_id: userId,
|
|
69
|
-
session_key: sessionKey,
|
|
70
|
-
agent_name: agentName,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
if (!isNew) {
|
|
74
|
-
return json({ message: `你已经在本轮选择过了。你的选择: ${agentName}` });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Update agent W/L based on the pick
|
|
78
|
-
const strategies = db.getStrategies(roundId);
|
|
79
|
-
const picked = strategies.find((s) => s.agent_name === agentName);
|
|
80
|
-
if (picked) {
|
|
81
|
-
db.recordWin(agentName, picked.sharpe);
|
|
82
|
-
for (const s of strategies) {
|
|
83
|
-
if (s.agent_name !== agentName) {
|
|
84
|
-
db.recordLoss(s.agent_name, s.sharpe);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return json({
|
|
90
|
-
message: `已记录你的选择: 🎯 ${agentName.toUpperCase()}`,
|
|
91
|
-
round_id: roundId,
|
|
92
|
-
agent_name: agentName,
|
|
93
|
-
});
|
|
94
|
-
},
|
|
95
|
-
}),
|
|
96
|
-
{ names: ["tournament_pick"] },
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
// ── tournament_leaderboard ──────────────────────────────────────────
|
|
100
|
-
|
|
101
|
-
registerTool(
|
|
102
|
-
(_ctx: unknown) => ({
|
|
103
|
-
name: "tournament_leaderboard",
|
|
104
|
-
label: "Tournament Leaderboard",
|
|
105
|
-
description:
|
|
106
|
-
"Show the strategy tournament agent leaderboard with win/loss records and Sharpe ratios.",
|
|
107
|
-
parameters: Type.Object({}),
|
|
108
|
-
async execute() {
|
|
109
|
-
const db = getDb();
|
|
110
|
-
const agents = db.getLeaderboard();
|
|
111
|
-
|
|
112
|
-
if (agents.length === 0) {
|
|
113
|
-
return json({ message: "还没有锦标赛记录。等待第一轮锦标赛完成后再查看。" });
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const medals = ["🥇", "🥈", "🥉"];
|
|
117
|
-
const lines = agents.map((a, i) => {
|
|
118
|
-
const medal = medals[i] ?? " ";
|
|
119
|
-
const winRate = a.rounds_played > 0 ? ((a.wins / a.rounds_played) * 100).toFixed(0) : "0";
|
|
120
|
-
return `${medal} ${a.name.toUpperCase().padEnd(12)} W:${String(a.wins).padStart(3)} L:${String(a.losses).padStart(3)} | Sharpe: ${a.avg_sharpe.toFixed(2)} | Win Rate: ${winRate}% | Rounds: ${a.rounds_played}`;
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
return json({
|
|
124
|
-
message: `📊 策略锦标赛排行榜\n${"─".repeat(60)}\n${lines.join("\n")}\n${"─".repeat(60)}`,
|
|
125
|
-
agents,
|
|
126
|
-
});
|
|
127
|
-
},
|
|
128
|
-
}),
|
|
129
|
-
{ names: ["tournament_leaderboard"] },
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
// ── tournament_result ───────────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
registerTool(
|
|
135
|
-
(_ctx: unknown) => ({
|
|
136
|
-
name: "tournament_result",
|
|
137
|
-
label: "Tournament Result",
|
|
138
|
-
description:
|
|
139
|
-
"Submit analysis result from a tournament subagent. Called by Bull/Bear/Contrarian subagents after completing their analysis.",
|
|
140
|
-
parameters: Type.Object({
|
|
141
|
-
round_id: Type.String({ description: "Tournament round ID" }),
|
|
142
|
-
agent_name: Type.String({ description: "Agent role: bull, bear, or contrarian" }),
|
|
143
|
-
thesis: Type.String({ description: "Analysis thesis (1 paragraph)" }),
|
|
144
|
-
entry_price: Type.Optional(Type.Number({ description: "Entry price" })),
|
|
145
|
-
exit_price: Type.Optional(Type.Number({ description: "Exit/target price" })),
|
|
146
|
-
stop_loss: Type.Optional(Type.Number({ description: "Stop loss price" })),
|
|
147
|
-
position_pct: Type.Optional(
|
|
148
|
-
Type.Number({ description: "Position size as fraction (0-1)" }),
|
|
149
|
-
),
|
|
150
|
-
confidence: Type.Number({ description: "Confidence score (0-100)" }),
|
|
151
|
-
sharpe: Type.Optional(Type.Number({ description: "Sharpe ratio from backtest" })),
|
|
152
|
-
max_drawdown: Type.Optional(Type.Number({ description: "Max drawdown from backtest" })),
|
|
153
|
-
total_return: Type.Optional(Type.Number({ description: "Total return from backtest" })),
|
|
154
|
-
}),
|
|
155
|
-
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
156
|
-
const db = getDb();
|
|
157
|
-
const agentName = String(params.agent_name).toLowerCase().trim();
|
|
158
|
-
const confidence = Number(params.confidence);
|
|
159
|
-
|
|
160
|
-
if (!VALID_AGENTS.includes(agentName as AgentName)) {
|
|
161
|
-
return json({ error: true, message: `Invalid agent name: ${agentName}` });
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (confidence < 0 || confidence > 100 || !Number.isFinite(confidence)) {
|
|
165
|
-
return json({ error: true, message: `Confidence must be 0-100, got: ${confidence}` });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
db.saveStrategy({
|
|
169
|
-
round_id: String(params.round_id),
|
|
170
|
-
agent_name: agentName,
|
|
171
|
-
thesis: String(params.thesis),
|
|
172
|
-
entry_price: params.entry_price != null ? Number(params.entry_price) : null,
|
|
173
|
-
exit_price: params.exit_price != null ? Number(params.exit_price) : null,
|
|
174
|
-
stop_loss: params.stop_loss != null ? Number(params.stop_loss) : null,
|
|
175
|
-
position_pct: params.position_pct != null ? Number(params.position_pct) : null,
|
|
176
|
-
confidence,
|
|
177
|
-
sharpe: params.sharpe != null ? Number(params.sharpe) : null,
|
|
178
|
-
max_drawdown: params.max_drawdown != null ? Number(params.max_drawdown) : null,
|
|
179
|
-
total_return: params.total_return != null ? Number(params.total_return) : null,
|
|
180
|
-
raw_result: JSON.stringify(params),
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
return json({
|
|
184
|
-
message: `Strategy submitted: ${agentName} (confidence: ${confidence})`,
|
|
185
|
-
round_id: params.round_id,
|
|
186
|
-
agent_name: agentName,
|
|
187
|
-
});
|
|
188
|
-
},
|
|
189
|
-
}),
|
|
190
|
-
{ names: ["tournament_result"] },
|
|
191
|
-
);
|
|
192
|
-
}
|