@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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Tournament orchestrator — code-driven daily tournament flow.
3
+ * Deterministic orchestration: cron → ticker → spawn subagents → collect → format → send.
4
+ * LLM only handles analysis inside each subagent.
5
+ * @module openfinclaw/tournament/orchestrator
6
+ */
7
+ import type { TournamentDb, TournamentStrategy } from "./db.js";
8
+ import { type TournamentRole, buildAgentTask } from "./prompts.js";
9
+
10
+ // ── Types ─────────────────────────────────────────────────────────────────
11
+
12
+ export interface TournamentConfig {
13
+ /** Maximum concurrent tournament agents (default: 3) */
14
+ maxAgents: number;
15
+ /** Subagent timeout in seconds (default: 600 = 10 min) */
16
+ subagentTimeoutSeconds: number;
17
+ /** Consecutive skips before alerting (default: 3) */
18
+ alertAfterSkips: number;
19
+ /** Session key for sending messages */
20
+ sessionKey: string;
21
+ }
22
+
23
+ export interface SpawnResult {
24
+ status: "accepted" | "forbidden" | "error";
25
+ childSessionKey?: string;
26
+ runId?: string;
27
+ error?: string;
28
+ }
29
+
30
+ export interface OrchestratorDeps {
31
+ db: TournamentDb;
32
+ /** Get today's top mover ticker symbol */
33
+ selectTicker: () => Promise<string>;
34
+ /** Spawn a subagent session */
35
+ spawnSubagent: (params: {
36
+ task: string;
37
+ label: string;
38
+ runTimeoutSeconds: number;
39
+ }) => Promise<SpawnResult>;
40
+ /** Push a message to the user's channel */
41
+ enqueueSystemEvent: (text: string, options: { sessionKey: string; contextKey?: string }) => void;
42
+ /** Wake the agent to deliver the message */
43
+ requestHeartbeatNow: (options?: { reason?: string; sessionKey?: string }) => void;
44
+ /** Wait for subagent results (poll DB for tournament_result submissions) */
45
+ waitForResults: (
46
+ roundId: string,
47
+ expectedCount: number,
48
+ timeoutMs: number,
49
+ ) => Promise<TournamentStrategy[]>;
50
+ logger: {
51
+ info: (msg: string) => void;
52
+ warn: (msg: string) => void;
53
+ error: (msg: string) => void;
54
+ };
55
+ config: TournamentConfig;
56
+ }
57
+
58
+ // ── Orchestrator ──────────────────────────────────────────────────────────
59
+
60
+ export class TournamentOrchestrator {
61
+ constructor(private deps: OrchestratorDeps) {}
62
+
63
+ /**
64
+ * Run the daily tournament. Called by cron hook.
65
+ * Pure code flow — no LLM involvement in orchestration.
66
+ */
67
+ async runDailyTournament(): Promise<void> {
68
+ const { db, config, logger } = this.deps;
69
+
70
+ // 1. Idempotent check
71
+ const today = new Date().toISOString().slice(0, 10);
72
+ const roundId = `round-${today.replace(/-/g, "")}`;
73
+ const existing = db.getRound(roundId);
74
+ if (existing && existing.status !== "pending") {
75
+ logger.info(`[tournament] Round ${roundId} already ${existing.status}, skipping`);
76
+ return;
77
+ }
78
+
79
+ // 2. Select today's ticker
80
+ let ticker: string;
81
+ try {
82
+ ticker = await this.deps.selectTicker();
83
+ } catch (err) {
84
+ logger.error(`[tournament] Failed to select ticker: ${err}`);
85
+ db.createRound({ id: roundId, date: today, ticker: "UNKNOWN" });
86
+ db.updateRoundStatus(roundId, "skipped", `Ticker selection failed: ${err}`);
87
+ this.checkConsecutiveFailures();
88
+ return;
89
+ }
90
+
91
+ // 3. Create round record
92
+ db.createRound({ id: roundId, date: today, ticker });
93
+ logger.info(`[tournament] Starting round ${roundId} for ${ticker}`);
94
+
95
+ // 4. Spawn 3 subagents in parallel
96
+ const roles: TournamentRole[] = ["bull", "bear", "contrarian"];
97
+ const spawnResults = await Promise.allSettled(
98
+ roles.map((role) =>
99
+ this.deps.spawnSubagent({
100
+ task: buildAgentTask(role, ticker, roundId),
101
+ label: `tournament-${role}`,
102
+ runTimeoutSeconds: config.subagentTimeoutSeconds,
103
+ }),
104
+ ),
105
+ );
106
+
107
+ // 5. Check spawn results
108
+ const spawned: Array<{ role: TournamentRole; result: SpawnResult }> = [];
109
+ for (let i = 0; i < roles.length; i++) {
110
+ const r = spawnResults[i];
111
+ if (r.status === "fulfilled" && r.value.status === "accepted") {
112
+ spawned.push({ role: roles[i], result: r.value });
113
+ logger.info(`[tournament] ${roles[i]} agent spawned: ${r.value.runId ?? "ok"}`);
114
+ } else {
115
+ const reason =
116
+ r.status === "rejected"
117
+ ? String(r.reason)
118
+ : ((r.value as SpawnResult).error ?? r.value.status);
119
+ logger.warn(`[tournament] ${roles[i]} agent spawn failed: ${reason}`);
120
+ }
121
+ }
122
+
123
+ if (spawned.length < 2) {
124
+ db.updateRoundStatus(roundId, "skipped", `Only ${spawned.length}/3 agents spawned`);
125
+ logger.warn(`[tournament] Round ${roundId} skipped: insufficient agents`);
126
+ this.checkConsecutiveFailures();
127
+ return;
128
+ }
129
+
130
+ // 6. Wait for subagent results (they call tournament_result tool)
131
+ const timeoutMs = config.subagentTimeoutSeconds * 1000 + 30_000; // extra 30s buffer
132
+ let strategies: TournamentStrategy[];
133
+ try {
134
+ strategies = await this.deps.waitForResults(roundId, spawned.length, timeoutMs);
135
+ } catch (err) {
136
+ logger.error(`[tournament] Result collection failed: ${err}`);
137
+ db.updateRoundStatus(roundId, "skipped", `Result collection failed: ${err}`);
138
+ this.checkConsecutiveFailures();
139
+ return;
140
+ }
141
+
142
+ if (strategies.length < 2) {
143
+ db.updateRoundStatus(roundId, "skipped", `Only ${strategies.length} strategies submitted`);
144
+ logger.warn(`[tournament] Round ${roundId} skipped: insufficient strategies`);
145
+ this.checkConsecutiveFailures();
146
+ return;
147
+ }
148
+
149
+ // 7. Format and send results
150
+ const message = this.formatMessage(ticker, roundId, strategies);
151
+ this.deps.enqueueSystemEvent(message, {
152
+ sessionKey: config.sessionKey,
153
+ contextKey: `tournament:${roundId}:result`,
154
+ });
155
+ this.deps.requestHeartbeatNow({ reason: "tournament-result", sessionKey: config.sessionKey });
156
+
157
+ db.updateRoundStatus(roundId, "completed");
158
+ logger.info(`[tournament] Round ${roundId} completed with ${strategies.length} strategies`);
159
+ }
160
+
161
+ /** Check for consecutive failures and send alert if threshold reached. */
162
+ private checkConsecutiveFailures(): void {
163
+ const { db, config, logger } = this.deps;
164
+ const consecutiveSkips = db.countConsecutiveSkips();
165
+ if (consecutiveSkips >= config.alertAfterSkips) {
166
+ const alertMsg =
167
+ `⚠️ Strategy Tournament 已连续 ${consecutiveSkips} 天跳过。\n` +
168
+ "请检查 DeepAgent API 配置和网络连接。";
169
+ this.deps.enqueueSystemEvent(alertMsg, {
170
+ sessionKey: config.sessionKey,
171
+ contextKey: `tournament:alert:consecutive-skip-${consecutiveSkips}`,
172
+ });
173
+ this.deps.requestHeartbeatNow({ reason: "tournament-alert", sessionKey: config.sessionKey });
174
+ logger.warn(`[tournament] Alert sent: ${consecutiveSkips} consecutive skips`);
175
+ }
176
+ }
177
+
178
+ /** Format tournament results as a human-readable message. */
179
+ private formatMessage(ticker: string, roundId: string, strategies: TournamentStrategy[]): string {
180
+ const header = `🏆 策略锦标赛 — ${ticker} (${roundId})\n${"═".repeat(50)}`;
181
+
182
+ const roleEmoji: Record<string, string> = {
183
+ bull: "🐂",
184
+ bear: "🐻",
185
+ contrarian: "🔄",
186
+ };
187
+
188
+ const sections = strategies.map((s) => {
189
+ const emoji = roleEmoji[s.agent_name] ?? "📊";
190
+ const lines = [`${emoji} ${s.agent_name.toUpperCase()} (信心: ${s.confidence}%)`];
191
+ lines.push(s.thesis);
192
+ if (s.entry_price != null)
193
+ lines.push(`入场: ${s.entry_price} | 出场: ${s.exit_price} | 止损: ${s.stop_loss}`);
194
+ if (s.sharpe != null)
195
+ lines.push(
196
+ `Sharpe: ${s.sharpe.toFixed(2)} | 回撤: ${((s.max_drawdown ?? 0) * 100).toFixed(1)}% | 收益: ${((s.total_return ?? 0) * 100).toFixed(1)}%`,
197
+ );
198
+ return lines.join("\n");
199
+ });
200
+
201
+ const footer = [
202
+ "─".repeat(50),
203
+ "选择你认为最佳的策略:",
204
+ " /pick bull | /pick bear | /pick contrarian",
205
+ "查看排行榜: /tournament leaderboard",
206
+ ].join("\n");
207
+
208
+ return [header, ...sections, footer].join("\n\n");
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Default waitForResults implementation: poll DB every 15 seconds.
214
+ * Subagents call tournament_result tool which writes to DB.
215
+ */
216
+ export function createDbPoller(db: TournamentDb): OrchestratorDeps["waitForResults"] {
217
+ return async (
218
+ roundId: string,
219
+ expectedCount: number,
220
+ timeoutMs: number,
221
+ ): Promise<TournamentStrategy[]> => {
222
+ const pollInterval = 15_000;
223
+ const deadline = Date.now() + timeoutMs;
224
+
225
+ while (Date.now() < deadline) {
226
+ const strategies = db.getStrategies(roundId);
227
+ if (strategies.length >= expectedCount) return strategies;
228
+ if (strategies.length >= 2) {
229
+ // If we have at least 2 and are past 75% of timeout, accept what we have
230
+ if (Date.now() > deadline - timeoutMs * 0.25) return strategies;
231
+ }
232
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
233
+ }
234
+
235
+ // Return whatever we have at timeout
236
+ return db.getStrategies(roundId);
237
+ };
238
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Tournament subagent task prompt templates.
3
+ * Each role is constrained to a different analytical framework
4
+ * to produce genuinely diverse strategies.
5
+ * @module openfinclaw/tournament/prompts
6
+ */
7
+
8
+ export type TournamentRole = "bull" | "bear" | "contrarian";
9
+
10
+ /**
11
+ * Build the task prompt for a tournament subagent.
12
+ * This is the `task` parameter passed to `sessions_spawn`.
13
+ */
14
+ export function buildAgentTask(role: TournamentRole, ticker: string, roundId: string): string {
15
+ const rolePrompts: Record<TournamentRole, string> = {
16
+ bull: `你是看多分析师 🐂。分析 ${ticker}:
17
+ 1. 调用 fin_deepagent_research 进行深度分析,聚焦动量指标(EMA 交叉、突破形态、成交量确认)
18
+ 2. 基于分析结果,生成看多交易策略:入场价、出场价、止损价、仓位比例
19
+ 3. 调用 tournament_result 提交结果,参数如下:
20
+ - round_id: "${roundId}"
21
+ - agent_name: "bull"
22
+ - thesis: 你的分析论点(1 段,150 字以内)
23
+ - entry_price, exit_price, stop_loss, position_pct: 策略参数
24
+ - confidence: 0-100 的信心分数
25
+ - sharpe, max_drawdown, total_return: 如有回测数据则填入
26
+ 约束:只使用动量和趋势类指标(EMA, MACD, 突破, 成交量)`,
27
+
28
+ bear: `你是看空分析师 🐻。分析 ${ticker}:
29
+ 1. 调用 fin_deepagent_research 进行深度分析,聚焦风险指标(RSI 超买、布林带收缩、波动率)
30
+ 2. 基于分析结果,生成看空或对冲策略:入场价、出场价、止损价、仓位比例
31
+ 3. 调用 tournament_result 提交结果,参数如下:
32
+ - round_id: "${roundId}"
33
+ - agent_name: "bear"
34
+ - thesis: 你的分析论点(1 段,150 字以内)
35
+ - entry_price, exit_price, stop_loss, position_pct: 策略参数
36
+ - confidence: 0-100 的信心分数
37
+ - sharpe, max_drawdown, total_return: 如有回测数据则填入
38
+ 约束:只使用均值回归和风险类指标(RSI, 布林带, ATR, VIX 相关性)`,
39
+
40
+ contrarian: `你是逆向分析师 🔄。分析 ${ticker}:
41
+ 1. 调用 fin_deepagent_research 进行深度分析,聚焦情绪和资金流(新闻情绪反转、异常期权活动、资金流向)
42
+ 2. 基于分析结果,生成逆势策略:入场价、出场价、止损价、仓位比例
43
+ 3. 调用 tournament_result 提交结果,参数如下:
44
+ - round_id: "${roundId}"
45
+ - agent_name: "contrarian"
46
+ - thesis: 你的分析论点(1 段,150 字以内)
47
+ - entry_price, exit_price, stop_loss, position_pct: 策略参数
48
+ - confidence: 0-100 的信心分数
49
+ - sharpe, max_drawdown, total_return: 如有回测数据则填入
50
+ 约束:只使用情绪和资金流类指标(新闻情绪、期权异动、资金流入/流出)`,
51
+ };
52
+
53
+ return rolePrompts[role];
54
+ }
55
+
56
+ /**
57
+ * Build the system prompt addendum for the main agent.
58
+ * Injected via before_prompt_build hook.
59
+ */
60
+ export function buildOrchestratorPrompt(): string {
61
+ return `你有 Strategy Tournament 锦标赛功能。
62
+ - 当用户说 "/pick bull|bear|contrarian" 时,调用 tournament_pick 工具记录选择
63
+ - 当用户说 "/tournament leaderboard" 或询问锦标赛排名时,调用 tournament_leaderboard 工具
64
+ - 锦标赛每日自动运行,你不需要手动触发`;
65
+ }
@@ -0,0 +1,221 @@
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
+ });