opencandle 0.9.0 → 0.11.0

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.
Files changed (98) hide show
  1. package/README.md +7 -5
  2. package/dist/analysts/orchestrator.d.ts +1 -4
  3. package/dist/analysts/orchestrator.js +15 -44
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/cli-main.js +242 -9
  6. package/dist/cli-main.js.map +1 -1
  7. package/dist/config.d.ts +4 -9
  8. package/dist/config.js +7 -10
  9. package/dist/config.js.map +1 -1
  10. package/dist/market-state/daily-report.d.ts +1 -1
  11. package/dist/market-state/daily-report.js +5 -16
  12. package/dist/market-state/daily-report.js.map +1 -1
  13. package/dist/market-state/service.d.ts +0 -35
  14. package/dist/market-state/service.js +0 -63
  15. package/dist/market-state/service.js.map +1 -1
  16. package/dist/memory/sqlite.js +20 -19
  17. package/dist/memory/sqlite.js.map +1 -1
  18. package/dist/pi/opencandle-extension.js +13 -221
  19. package/dist/pi/opencandle-extension.js.map +1 -1
  20. package/dist/pi/session-action-dedupe.d.ts +6 -0
  21. package/dist/pi/session-action-dedupe.js +126 -0
  22. package/dist/pi/session-action-dedupe.js.map +1 -0
  23. package/dist/pi/session-writer-lock.d.ts +26 -2
  24. package/dist/pi/session-writer-lock.js +230 -18
  25. package/dist/pi/session-writer-lock.js.map +1 -1
  26. package/dist/pi/setup.js +5 -5
  27. package/dist/pi/setup.js.map +1 -1
  28. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  29. package/dist/pi/tui-session-coordinator.js +283 -0
  30. package/dist/pi/tui-session-coordinator.js.map +1 -0
  31. package/dist/prompts/context-builder.js +1 -1
  32. package/dist/prompts/policy-cards.js +1 -1
  33. package/dist/prompts/policy-cards.js.map +1 -1
  34. package/dist/routing/classify-intent.js +4 -5
  35. package/dist/routing/classify-intent.js.map +1 -1
  36. package/dist/routing/route-manifest.js +0 -1
  37. package/dist/routing/route-manifest.js.map +1 -1
  38. package/dist/routing/router-prompt.js +1 -1
  39. package/dist/routing/router.js +35 -4
  40. package/dist/routing/router.js.map +1 -1
  41. package/dist/runtime/session-coordinator.js +2 -13
  42. package/dist/runtime/session-coordinator.js.map +1 -1
  43. package/dist/system-prompt.js +1 -1
  44. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  45. package/dist/tools/fundamentals/dcf.js +49 -6
  46. package/dist/tools/fundamentals/dcf.js.map +1 -1
  47. package/dist/tools/index.d.ts +0 -1
  48. package/dist/tools/index.js +0 -3
  49. package/dist/tools/index.js.map +1 -1
  50. package/dist/tools/portfolio/daily-report.js +10 -4
  51. package/dist/tools/portfolio/daily-report.js.map +1 -1
  52. package/dist/tools/technical/backtest.d.ts +23 -5
  53. package/dist/tools/technical/backtest.js +131 -94
  54. package/dist/tools/technical/backtest.js.map +1 -1
  55. package/gui/server/http-routes.ts +395 -19
  56. package/gui/server/invoke-tool.ts +164 -16
  57. package/gui/server/local-session-coordinator.ts +97 -0
  58. package/gui/server/market-state-api.ts +1 -43
  59. package/gui/server/server.ts +51 -6
  60. package/gui/server/session-actions.ts +117 -8
  61. package/gui/server/tool-metadata.ts +3 -1
  62. package/gui/server/ws-hub.ts +61 -6
  63. package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +1 -0
  64. package/gui/web/dist/assets/index-BzyqyVnd.css +2 -0
  65. package/gui/web/dist/assets/index-DvMjkOP9.js +65 -0
  66. package/gui/web/dist/index.html +2 -2
  67. package/package.json +8 -5
  68. package/src/analysts/orchestrator.ts +15 -56
  69. package/src/cli-main.ts +253 -13
  70. package/src/config.ts +12 -20
  71. package/src/market-state/daily-report.ts +6 -16
  72. package/src/market-state/service.ts +0 -136
  73. package/src/memory/sqlite.ts +23 -19
  74. package/src/pi/opencandle-extension.ts +12 -265
  75. package/src/pi/session-action-dedupe.ts +155 -0
  76. package/src/pi/session-writer-lock.ts +290 -20
  77. package/src/pi/setup.ts +6 -6
  78. package/src/pi/tui-session-coordinator.ts +351 -0
  79. package/src/prompts/context-builder.ts +1 -1
  80. package/src/prompts/policy-cards.ts +1 -1
  81. package/src/routing/classify-intent.ts +4 -5
  82. package/src/routing/route-manifest.ts +0 -1
  83. package/src/routing/router-prompt.ts +1 -1
  84. package/src/routing/router.ts +39 -3
  85. package/src/runtime/session-coordinator.ts +2 -17
  86. package/src/system-prompt.ts +1 -1
  87. package/src/tools/AGENTS.md +1 -1
  88. package/src/tools/fundamentals/dcf.ts +57 -7
  89. package/src/tools/index.ts +0 -3
  90. package/src/tools/portfolio/daily-report.ts +10 -4
  91. package/src/tools/technical/backtest.ts +167 -108
  92. package/dist/tools/portfolio/predictions.d.ts +0 -55
  93. package/dist/tools/portfolio/predictions.js +0 -422
  94. package/dist/tools/portfolio/predictions.js.map +0 -1
  95. package/gui/web/dist/assets/CatalogOverlay-DcTXLc7u.js +0 -1
  96. package/gui/web/dist/assets/index-DdMnIvZ7.css +0 -2
  97. package/gui/web/dist/assets/index-YlMaahAS.js +0 -65
  98. package/src/tools/portfolio/predictions.ts +0 -553
@@ -0,0 +1,351 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import type { AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ clearPendingSessionAction,
7
+ hasAcceptedSessionAction,
8
+ hasPendingSessionAction,
9
+ recordAcceptedSessionAction,
10
+ recordPendingSessionAction,
11
+ } from "./session-action-dedupe.js";
12
+
13
+ interface TuiSessionCoordinatorOptions {
14
+ getSession: () => AgentSession;
15
+ getSessionManager: () => SessionManager;
16
+ getModelUnavailableMessage: () => string | null;
17
+ syncWriterLockScope?: () => void;
18
+ now?: () => number;
19
+ }
20
+
21
+ export interface TuiSessionCoordinatorServer {
22
+ endpoint: string;
23
+ secret: string;
24
+ close(): Promise<void>;
25
+ }
26
+
27
+ interface AcceptedAction {
28
+ expiresAt: number;
29
+ }
30
+
31
+ const DEDUPE_RETENTION_MS = 10 * 60 * 1000;
32
+
33
+ export async function startTuiSessionCoordinatorServer(
34
+ options: TuiSessionCoordinatorOptions,
35
+ ): Promise<TuiSessionCoordinatorServer> {
36
+ const now = options.now ?? Date.now;
37
+ const secret = randomBytes(32).toString("base64url");
38
+ const acceptedActions = new Map<string, AcceptedAction>();
39
+ const activeRunSessions = new Set<string>();
40
+
41
+ const server = createServer((req, res) => {
42
+ void handleRequest(req, res).catch((error) => {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ writeJson(res, { error: message }, 500);
45
+ });
46
+ });
47
+
48
+ await new Promise<void>((resolve, reject) => {
49
+ server.once("error", reject);
50
+ server.listen(0, "127.0.0.1", () => {
51
+ server.off("error", reject);
52
+ resolve();
53
+ });
54
+ });
55
+
56
+ const address = server.address() as AddressInfo;
57
+ const endpoint = `http://127.0.0.1:${address.port}`;
58
+
59
+ return {
60
+ endpoint,
61
+ secret,
62
+ close: () =>
63
+ new Promise<void>((resolve, reject) => {
64
+ server.close((error) => (error ? reject(error) : resolve()));
65
+ }),
66
+ };
67
+
68
+ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
69
+ const url = new URL(req.url ?? "/", endpoint);
70
+ if (url.pathname !== "/api/local-coordinator/chat-run" || req.method !== "POST") {
71
+ writeJson(res, { error: "Not found" }, 404);
72
+ return;
73
+ }
74
+
75
+ if (
76
+ !isLoopbackAddress(req.socket.remoteAddress) ||
77
+ req.headers["x-opencandle-coordinator-secret"] !== secret
78
+ ) {
79
+ writeJson(res, { error: "Local coordinator request was not authorized." }, 403);
80
+ return;
81
+ }
82
+
83
+ const body = asRecord(await readJsonBody(req));
84
+ const prompt = String(body.prompt ?? "").trim();
85
+ const requestedSessionId = String(body.sessionId ?? "").trim();
86
+ const actionId = String(body.actionId ?? "").trim() || `legacy-tui-chat-${now()}`;
87
+ const sessionManager = options.getSessionManager();
88
+ const sessionId = sessionManager.getSessionId();
89
+ if (!prompt) {
90
+ writeJson(res, { error: "prompt is required" }, 400);
91
+ return;
92
+ }
93
+ if (requestedSessionId && requestedSessionId !== sessionId) {
94
+ writeJson(
95
+ res,
96
+ { error: "Session route and request body disagree", code: "session_changed" },
97
+ 409,
98
+ );
99
+ return;
100
+ }
101
+ try {
102
+ options.syncWriterLockScope?.();
103
+ } catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ writeJson(res, { error: message, code: "syncing" }, 409);
106
+ return;
107
+ }
108
+
109
+ pruneExpiredActions();
110
+ const actionKey = `${sessionId}:${actionId}`;
111
+ if (acceptedActions.has(actionKey) || hasAcceptedSessionAction(sessionManager, actionId)) {
112
+ writeJson(res, { ok: true, duplicate: true });
113
+ return;
114
+ }
115
+ if (hasPendingSessionAction(sessionManager, actionId)) {
116
+ writeJson(
117
+ res,
118
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
119
+ 409,
120
+ );
121
+ return;
122
+ }
123
+ let session: AgentSession;
124
+ try {
125
+ session = options.getSession();
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ writeJson(res, { error: message, code: "session_starting" }, 409);
129
+ return;
130
+ }
131
+ if (
132
+ activeRunSessions.has(sessionId) ||
133
+ session.isStreaming ||
134
+ session.pendingMessageCount > 0
135
+ ) {
136
+ writeJson(
137
+ res,
138
+ {
139
+ error: "OpenCandle is still working in this session. Try again when it finishes.",
140
+ code: "session_busy",
141
+ },
142
+ 409,
143
+ );
144
+ return;
145
+ }
146
+
147
+ activeRunSessions.add(sessionId);
148
+ try {
149
+ recordPendingSessionAction(sessionManager, actionId);
150
+ let actionAccepted = false;
151
+ const recordAcceptedAction = () => {
152
+ if (actionAccepted) return;
153
+ recordAcceptedSessionAction(sessionManager, actionId);
154
+ options.syncWriterLockScope?.();
155
+ acceptedActions.set(actionKey, { expiresAt: now() + DEDUPE_RETENTION_MS });
156
+ actionAccepted = true;
157
+ };
158
+ let completed: boolean;
159
+ try {
160
+ completed = await streamPromptRun(res, prompt, sessionId, recordAcceptedAction);
161
+ } catch (error) {
162
+ if (!actionAccepted) clearPendingSessionAction(sessionManager, actionId);
163
+ throw error;
164
+ }
165
+ if (completed) {
166
+ recordAcceptedAction();
167
+ } else if (!actionAccepted) {
168
+ clearPendingSessionAction(sessionManager, actionId);
169
+ }
170
+ } finally {
171
+ activeRunSessions.delete(sessionId);
172
+ }
173
+ }
174
+
175
+ async function streamPromptRun(
176
+ res: ServerResponse,
177
+ prompt: string,
178
+ sessionId: string,
179
+ onPromptAdmitted: () => void,
180
+ ): Promise<boolean> {
181
+ options.syncWriterLockScope?.();
182
+ const session = options.getSession();
183
+ const sessionManager = options.getSessionManager();
184
+ const beforeEntries = sessionManager.getEntries();
185
+ const beforeCount = beforeEntries.length;
186
+ let seq = 1;
187
+ const runId = `tui-run-${now()}`;
188
+
189
+ res.writeHead(200, {
190
+ "content-type": "text/event-stream; charset=utf-8",
191
+ "cache-control": "no-cache, no-transform",
192
+ connection: "keep-alive",
193
+ });
194
+ writeSse(res, { type: "run.started", runId, sessionId, seq: seq++ });
195
+ res.flushHeaders?.();
196
+
197
+ try {
198
+ const modelUnavailableMessage = options.getModelUnavailableMessage();
199
+ if (!prompt.startsWith("/") && modelUnavailableMessage) {
200
+ sessionManager.appendMessage({ role: "user", content: prompt, timestamp: now() });
201
+ onPromptAdmitted();
202
+ options.syncWriterLockScope?.();
203
+ sessionManager.appendCustomMessageEntry(
204
+ "opencandle-model-setup",
205
+ modelUnavailableMessage,
206
+ true,
207
+ {
208
+ source: "tui",
209
+ },
210
+ );
211
+ } else {
212
+ const unsubscribe = subscribeToPromptAdmission(session, prompt, onPromptAdmitted);
213
+ try {
214
+ await session.prompt(prompt);
215
+ options.syncWriterLockScope?.();
216
+ await waitForTurnSettlement(session);
217
+ } finally {
218
+ unsubscribe?.();
219
+ }
220
+ }
221
+ const newEntries = sessionManager.getEntries().slice(beforeCount);
222
+ for (const event of entriesToChatEvents(newEntries, sessionId, seq)) {
223
+ writeSse(res, event);
224
+ seq = event.seq + 1;
225
+ }
226
+ writeSse(res, { type: "run.completed", runId, sessionId, seq });
227
+ return true;
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error);
230
+ writeSse(res, { type: "run.failed", runId, sessionId, error: { message }, seq });
231
+ return false;
232
+ } finally {
233
+ res.end();
234
+ }
235
+ }
236
+
237
+ function pruneExpiredActions(): void {
238
+ const currentTime = now();
239
+ for (const [actionKey, accepted] of acceptedActions) {
240
+ if (accepted.expiresAt <= currentTime) acceptedActions.delete(actionKey);
241
+ }
242
+ }
243
+
244
+ function subscribeToPromptAdmission(
245
+ session: AgentSession,
246
+ prompt: string,
247
+ onPromptAdmitted: () => void,
248
+ ): (() => void) | undefined {
249
+ if (prompt.startsWith("/")) return undefined;
250
+ const maybeSession = session as AgentSession & {
251
+ subscribe?: (handler: (event: unknown) => void) => () => void;
252
+ };
253
+ if (typeof maybeSession.subscribe !== "function") return undefined;
254
+ return maybeSession.subscribe((event) => {
255
+ const record = asRecord(event);
256
+ if (record.type !== "message_start") return;
257
+ const message = asRecord(record.message);
258
+ if (message.role !== "user") return;
259
+ if (messageTextFromContent(message.content).trim() === prompt.trim()) onPromptAdmitted();
260
+ });
261
+ }
262
+ }
263
+
264
+ async function waitForTurnSettlement(session: AgentSession): Promise<void> {
265
+ const startedAt = Date.now();
266
+ while (session.isStreaming || session.pendingMessageCount > 0) {
267
+ if (Date.now() - startedAt > 120_000) {
268
+ throw new Error("Timed out waiting for session turn to settle");
269
+ }
270
+ await new Promise((resolve) => setTimeout(resolve, 50));
271
+ }
272
+ }
273
+
274
+ function entriesToChatEvents(
275
+ entries: unknown[],
276
+ sessionId: string,
277
+ startSeq: number,
278
+ ): Array<Record<string, unknown> & { seq: number }> {
279
+ let seq = startSeq;
280
+ const events: Array<Record<string, unknown> & { seq: number }> = [];
281
+ for (const entry of entries) {
282
+ const record = asRecord(entry);
283
+ const message = asRecord(record.message);
284
+ const source = Object.keys(message).length > 0 ? message : record;
285
+ const role =
286
+ typeof source.role === "string"
287
+ ? source.role
288
+ : record.type === "custom_message"
289
+ ? "assistant"
290
+ : undefined;
291
+ const content = normalizeContent(source.content);
292
+ if (!role || content.length === 0) continue;
293
+ events.push({
294
+ type: "message.completed",
295
+ sessionId,
296
+ messageId: String(record.id ?? `tui-entry-${seq}`),
297
+ role,
298
+ content,
299
+ seq: seq++,
300
+ });
301
+ }
302
+ return events;
303
+ }
304
+
305
+ function normalizeContent(content: unknown): Array<Record<string, unknown>> {
306
+ if (typeof content === "string") return [{ type: "text", text: content }];
307
+ if (Array.isArray(content)) return content.map(asRecord).filter((item) => item.type);
308
+ const record = asRecord(content);
309
+ if (typeof record.text === "string") return [{ type: "text", text: record.text }];
310
+ return [];
311
+ }
312
+
313
+ function messageTextFromContent(content: unknown): string {
314
+ if (typeof content === "string") return content;
315
+ if (!Array.isArray(content)) return "";
316
+ return content
317
+ .map((part) =>
318
+ typeof part === "object" && part !== null && "text" in part && typeof part.text === "string"
319
+ ? part.text
320
+ : "",
321
+ )
322
+ .join("");
323
+ }
324
+
325
+ function writeJson(res: ServerResponse, value: unknown, status = 200): void {
326
+ res.writeHead(status, { "content-type": "application/json" });
327
+ res.end(JSON.stringify(value));
328
+ }
329
+
330
+ function writeSse(res: ServerResponse, event: unknown): void {
331
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
332
+ }
333
+
334
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
335
+ const chunks: Buffer[] = [];
336
+ for await (const chunk of req) {
337
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
338
+ }
339
+ if (chunks.length === 0) return {};
340
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
341
+ }
342
+
343
+ function isLoopbackAddress(address: string | undefined): boolean {
344
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
345
+ }
346
+
347
+ function asRecord(value: unknown): Record<string, unknown> {
348
+ return typeof value === "object" && value !== null && !Array.isArray(value)
349
+ ? (value as Record<string, unknown>)
350
+ : {};
351
+ }
@@ -291,7 +291,7 @@ const TOOL_CATALOG = `## Available Tools
291
291
  - **Sentiment**: get_reddit_sentiment, get_twitter_sentiment, get_web_sentiment, get_sentiment_trend, get_sentiment_summary — retail and news sentiment from Reddit, Twitter/X, and web sources with historical trends and cross-source divergence detection
292
292
  - **Web Search**: search_web — breaking news, earnings context, company events, regulatory developments. Supported freshness values are hours, day, week, and month; use category general with freshness month for broad industry context; never pass unsupported values such as all, year, 3mo, quarter, or custom date ranges. When a dedicated tool can answer the question (quotes, fundamentals, earnings, macro, SEC filings, sentiment), use that tool instead — do not add search_web as a supplementary source for data available through dedicated tools
293
293
  - **Options**: get_option_chain — full options chain with strikes, bids/asks, volume, OI, IV, and computed Greeks (delta, gamma, theta, vega, rho)
294
- - **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, analyze_holdings_overlap, track_prediction, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, correlation matrix, ETF/fund holdings overlap, and prediction tracking with accuracy scoring
294
+ - **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, analyze_holdings_overlap, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, correlation matrix, and ETF/fund holdings overlap
295
295
  - **User Interaction**: ask_user — ask the user a clarification question when their request is ambiguous or missing key details`;
296
296
 
297
297
  function buildToolCatalog(addonDescriptions?: string[]): string {
@@ -141,7 +141,7 @@ For strategy backtest prompts, use backtest_strategy evidence before judging whe
141
141
  status: "implemented",
142
142
  capabilityGapIds: [],
143
143
  content: `## Stateful Tracking Update Policy
144
- For watchlist, portfolio tracking, alert management, daily-report, prediction recording, and prediction-check prompts, use the state tool that owns the change or lookup: manage_watchlist, track_prediction, track_portfolio, manage_alerts, or daily_watchlist_report. Do not confirm a saved change from prose alone. Confirm the persisted state update with the symbol, action, direction, entry price, target, stop, conviction, timeframe, or check result that the tool accepted. If the user asks to create an alert and check it now in the same request, call manage_alerts with the matching create action and check_after_create=true. If required fields for a state mutation are missing, ask the smallest clarification question instead of inventing values. For check or list operations, summarize the saved state and say clearly when no records exist. Do not turn a state update into a buy/sell recommendation unless the user separately asks for market analysis.`,
144
+ For watchlist, portfolio tracking, alert management, and daily-report prompts, use the state tool that owns the change or lookup: manage_watchlist, track_portfolio, manage_alerts, or daily_watchlist_report. Do not confirm a saved change from prose alone. Confirm the persisted state update with the symbol, action, quantity, cost, target, stop, or check result that the tool accepted. If the user asks to create an alert and check it now in the same request, call manage_alerts with the matching create action and check_after_create=true. If required fields for a state mutation are missing, ask the smallest clarification question instead of inventing values. For check or list operations, summarize the saved state and say clearly when no records exist. Do not turn a state update into a buy/sell recommendation unless the user separately asks for market analysis.`,
145
145
  },
146
146
  retail_finance_tradeoff: {
147
147
  id: "retail_finance_tradeoff",
@@ -136,9 +136,9 @@ const RULES: Rule[] = [
136
136
  return hasOptionKeywordsInText(lower) && entities.symbols.length >= 1;
137
137
  },
138
138
  },
139
- // Stateful portfolio/watchlist/alert/prediction mutations must not be
140
- // mistaken for compare or portfolio-construction workflows just because a
141
- // cost basis, target, or currency token is present.
139
+ // Stateful portfolio/watchlist/alert mutations must not be mistaken for
140
+ // compare or portfolio-construction workflows just because a cost basis,
141
+ // target, or currency token is present.
142
142
  {
143
143
  workflow: "watchlist_or_tracking",
144
144
  confidence: 0.95,
@@ -184,7 +184,6 @@ const RULES: Rule[] = [
184
184
  const lower = input.toLowerCase();
185
185
  return (
186
186
  /\bwatchlist\b/.test(lower) ||
187
- /\bprediction/i.test(lower) ||
188
187
  /\bshow\s+my\s+portfolio\b/.test(lower) ||
189
188
  /\bmy\s+portfolio\b/.test(lower) ||
190
189
  /\btrack\b/.test(lower)
@@ -314,7 +313,7 @@ function isStatefulTrackingRequest(input: string): boolean {
314
313
  lower,
315
314
  );
316
315
  const hasStateObject =
317
- /\b(?:watchlist|portfolio|holding|holdings|position|positions|prediction|predictions|alert|alerts|daily\s+report|watchlist\s+report|report\s+history)\b/.test(
316
+ /\b(?:watchlist|portfolio|holding|holdings|position|positions|alert|alerts|daily\s+report|watchlist\s+report|report\s+history)\b/.test(
318
317
  lower,
319
318
  );
320
319
  const hasPortfolioLotShape =
@@ -29,7 +29,6 @@ export const TOOL_BUNDLE_TOOLS: Record<ToolBundleName, readonly string[]> = {
29
29
  "analyze_holdings_overlap",
30
30
  "track_portfolio",
31
31
  "manage_watchlist",
32
- "track_prediction",
33
32
  "manage_alerts",
34
33
  "daily_watchlist_report",
35
34
  "manage_notifications",
@@ -19,7 +19,7 @@ function renderCatalog(): string {
19
19
  compare_assets: "user asks to compare two or more symbols (vs / versus / which is better)",
20
20
  single_asset_analysis:
21
21
  "user asks for a full analysis / deep dive / 'is X attractive' on ONE symbol",
22
- watchlist_or_tracking: "user manages or asks about their saved watchlist / prediction history",
22
+ watchlist_or_tracking: "user manages or asks about their saved watchlist or tracked positions",
23
23
  general_finance_qa:
24
24
  "definitional / conceptual questions plus broad market structure, sector, industry, monetary policy, and emerging markets research",
25
25
  };
@@ -12,6 +12,7 @@ import {
12
12
  legacyRouteForRouteKind,
13
13
  routeKindFromLegacyRoute,
14
14
  selectToolBundles,
15
+ workflowRequiredSlots,
15
16
  } from "./route-manifest.js";
16
17
  import { buildRouterPrompt } from "./router-prompt.js";
17
18
  import type {
@@ -127,7 +128,7 @@ export function validateRouterOutput(raw: string): RouterOutput {
127
128
  }
128
129
 
129
130
  const entities = validateEntities(obj.entities);
130
- const slots = validateSlots(obj.slots);
131
+ const slots = canonicalizeSymbolSlots(workflow, validateSlots(obj.slots));
131
132
  const preference_updates = validatePreferenceUpdates(obj.preference_updates);
132
133
  const missing_required = rawMissingRequired;
133
134
  const tool_bundles = validateToolBundles(obj.tool_bundles);
@@ -659,7 +660,7 @@ function isStatefulTrackingRequest(text: string): boolean {
659
660
  lower,
660
661
  );
661
662
  const hasStateObject =
662
- /\b(?:watchlist|portfolio|holding|holdings|position|positions|prediction|predictions|alert|alerts|daily\s+report|watchlist\s+report|report\s+history)\b/.test(
663
+ /\b(?:watchlist|portfolio|holding|holdings|position|positions|alert|alerts|daily\s+report|watchlist\s+report|report\s+history)\b/.test(
663
664
  lower,
664
665
  );
665
666
  const hasPortfolioLotShape =
@@ -816,7 +817,8 @@ function validateSlots(raw: unknown): Record<string, RouterSlot> {
816
817
  if (!VALID_CONFIDENCE.has(s.confidence as string)) {
817
818
  throw new Error(`slot ${key} has invalid confidence: ${JSON.stringify(s.confidence)}`);
818
819
  }
819
- out[key] = {
820
+ const canonicalKey = canonicalSlotKey(key);
821
+ out[canonicalKey] = {
820
822
  value: s.value,
821
823
  source: s.source as RouterSlot["source"],
822
824
  confidence: s.confidence as RouterSlot["confidence"],
@@ -825,6 +827,40 @@ function validateSlots(raw: unknown): Record<string, RouterSlot> {
825
827
  return out;
826
828
  }
827
829
 
830
+ // The slot contract uses snake_case keys; non-Claude router models sometimes
831
+ // emit camelCase (timeHorizon, riskProfile). Canonicalize deterministically so
832
+ // downstream slot resolution and missing-required checks match.
833
+ function canonicalSlotKey(key: string): string {
834
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
835
+ }
836
+
837
+ // Models also drift between scalar `symbol` and one-element `symbols` slot
838
+ // shapes. Canonicalize toward the workflow's manifest-declared required slot
839
+ // name when the conversion is unambiguous.
840
+ function canonicalizeSymbolSlots(
841
+ workflow: RouterOutput["workflow"],
842
+ slots: Record<string, RouterSlot>,
843
+ ): Record<string, RouterSlot> {
844
+ if (!workflow) return slots;
845
+ const required = workflowRequiredSlots(workflow);
846
+ const next = { ...slots };
847
+ if (required.includes("symbol") && next.symbol == null && next.symbols != null) {
848
+ const value = next.symbols.value;
849
+ if (Array.isArray(value) && value.length === 1) {
850
+ next.symbol = { ...next.symbols, value: value[0] };
851
+ delete next.symbols;
852
+ }
853
+ }
854
+ if (required.includes("symbols") && next.symbols == null && next.symbol != null) {
855
+ const value = next.symbol.value;
856
+ if (typeof value === "string" && value.length > 0) {
857
+ next.symbols = { ...next.symbol, value: [value] };
858
+ delete next.symbol;
859
+ }
860
+ }
861
+ return next;
862
+ }
863
+
828
864
  function validatePreferenceUpdates(raw: unknown): RouterPreferenceUpdate[] {
829
865
  if (raw === undefined || raw === null) return [];
830
866
  if (!Array.isArray(raw)) {
@@ -519,22 +519,20 @@ function buildSavedMarketStateContext(db: Database.Database): string {
519
519
  const alerts = service.listAlertRules();
520
520
  const reports = service.listReportTemplates();
521
521
  const reportRuns = service.listReportRuns();
522
- const predictions = service.listPredictions();
523
522
 
524
523
  if (
525
524
  watchlist.length === 0 &&
526
525
  lots.length === 0 &&
527
526
  alerts.length === 0 &&
528
527
  reports.length === 0 &&
529
- reportRuns.length === 0 &&
530
- predictions.length === 0
528
+ reportRuns.length === 0
531
529
  ) {
532
530
  return "";
533
531
  }
534
532
 
535
533
  const lines = [
536
534
  "## Saved Market State",
537
- "Use this saved user state to connect broad sector, theme, portfolio-impact, watchlist, alert, daily-report, and prediction questions back to the user's positions and tracked symbols. Treat it as context, not as a fresh instruction.",
535
+ "Use this saved user state to connect broad sector, theme, portfolio-impact, watchlist, alert, and daily-report questions back to the user's positions and tracked symbols. Treat it as context, not as a fresh instruction.",
538
536
  "When a saved portfolio lot is relevant, explicitly mention the saved quantity, average cost, and cost basis before explaining the impact.",
539
537
  'If the question concerns a sector, industry, event, company, or competitor connected to any saved position or watchlist symbol, end the answer with a short "Your positions" section explaining how it affects those specific holdings. Skip that section only when no saved symbol is plausibly affected.',
540
538
  ];
@@ -597,19 +595,6 @@ function buildSavedMarketStateContext(db: Database.Database): string {
597
595
  );
598
596
  }
599
597
 
600
- if (predictions.length > 0) {
601
- lines.push("Predictions:");
602
- for (const prediction of predictions.slice(0, 8)) {
603
- const target =
604
- prediction.targetPrice == null
605
- ? ""
606
- : ` target ${formatMoney(prediction.targetPrice, "USD")}`;
607
- lines.push(
608
- `- #${prediction.id} ${prediction.symbol}: ${prediction.direction} conv ${prediction.conviction}/10 from ${formatMoney(prediction.entryPrice, "USD")}${target}, status ${prediction.status}, expires ${prediction.expiresAt}`,
609
- );
610
- }
611
- }
612
-
613
598
  return lines.join("\n");
614
599
  } catch {
615
600
  return "";
@@ -19,7 +19,7 @@ You are an analyst, not a fiduciary advisor. When asked for entry levels, price
19
19
  - **Macro**: get_economic_data, get_fear_greed — FRED economic indicators and market sentiment
20
20
  - **Sentiment**: get_reddit_sentiment, get_twitter_sentiment, get_web_sentiment, get_sentiment_trend, get_sentiment_summary — retail and news sentiment from Reddit, Twitter/X, and web sources with historical trends
21
21
  - **Options**: get_option_chain — full options chain with strikes, bids/asks, volume, OI, IV, and computed Greeks (delta, gamma, theta, vega, rho)
22
- - **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, track_prediction, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, correlation matrix, and prediction tracking with accuracy scoring
22
+ - **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, and correlation matrix
23
23
  - **User Interaction**: ask_user — ask clarification questions and setup confirmations
24
24
 
25
25
  ## Analytical Framework
@@ -10,7 +10,7 @@ src/tools/
10
10
  ├── options/ # Options chains, Greeks computation
11
11
  ├── macro/ # FRED economic data, fear & greed index
12
12
  ├── sentiment/ # Reddit sentiment, news sentiment
13
- ├── portfolio/ # Tracker, risk analysis, watchlist, correlation, predictions
13
+ ├── portfolio/ # Tracker, risk analysis, watchlist, correlation
14
14
  ├── market/ # Stock quotes, history, crypto, ticker search
15
15
  └── index.ts # getAllTools() registry — add new tools here
16
16
  ```
@@ -45,6 +45,14 @@ export function computeDCF(params: DCFParams): DCFResult {
45
45
  sharesOutstanding,
46
46
  } = params;
47
47
 
48
+ // Gordon Growth requires discount > terminal growth; a non-positive spread
49
+ // divides by zero or flips the terminal value's sign.
50
+ if (discountRate <= terminalGrowth) {
51
+ throw new Error(
52
+ `Gordon Growth constraint violated: terminal growth (${(terminalGrowth * 100).toFixed(1)}%) must be below the discount rate (${(discountRate * 100).toFixed(1)}%).`,
53
+ );
54
+ }
55
+
48
56
  // Project future cash flows (mid-year convention: discount at year-0.5)
49
57
  const projectedCashFlows: Array<{ year: number; fcf: number; presentValue: number }> = [];
50
58
  for (let y = 1; y <= years; y++) {
@@ -152,12 +160,11 @@ function computeDCFSimple(
152
160
  return (sumPV + pvTV - debt) / shares;
153
161
  }
154
162
 
155
- export function computeNetDebt(f: FinancialStatement): number {
163
+ export function computeNetDebt(f: FinancialStatement): number | null {
156
164
  if (f.totalDebt != null && f.cashAndEquivalents != null) {
157
165
  return f.totalDebt - f.cashAndEquivalents;
158
166
  }
159
- // Fallback: totalLiabilities - totalAssets (negative means net cash position)
160
- return f.totalLiabilities - f.totalAssets;
167
+ return null;
161
168
  }
162
169
 
163
170
  const params = Type.Object({
@@ -243,10 +250,45 @@ export const dcfTool: AgentTool<typeof params> = {
243
250
 
244
251
  const discountRate = args.discount_rate ?? 0.1;
245
252
  const terminalGrowth = args.terminal_growth ?? 0.03;
253
+ if (discountRate <= terminalGrowth) {
254
+ return {
255
+ content: [
256
+ {
257
+ type: "text",
258
+ text: `⚠ Invalid DCF assumptions for ${symbol}: terminal growth (${(terminalGrowth * 100).toFixed(1)}%) must be below the discount rate (${(discountRate * 100).toFixed(1)}%) for the Gordon Growth terminal value to be meaningful. Lower terminal_growth or raise discount_rate.`,
259
+ },
260
+ ],
261
+ details: null,
262
+ };
263
+ }
264
+
246
265
  const years = args.projection_years ?? 5;
247
- const marketCap = overview?.marketCap ?? 0;
248
- const sharesOutstanding = quote.price > 0 && marketCap > 0 ? marketCap / quote.price : 1;
249
- const netDebt = financials[0] ? computeNetDebt(financials[0]) : 0;
266
+ // Prefer the overview market cap; fall back to the quote's market cap.
267
+ // Never substitute a placeholder share count a fabricated
268
+ // per-share value is worse than an honest refusal.
269
+ const marketCap =
270
+ overview?.marketCap && overview.marketCap > 0
271
+ ? overview.marketCap
272
+ : quote.marketCap > 0
273
+ ? quote.marketCap
274
+ : 0;
275
+ if (quote.price <= 0 || marketCap <= 0) {
276
+ return {
277
+ content: [
278
+ {
279
+ type: "text",
280
+ text: `⚠ Cannot compute a per-share DCF for ${symbol}: shares outstanding cannot be derived because ${quote.price <= 0 ? "the current quote price is unavailable" : "market capitalization is unavailable from the overview and quote providers"}. Per-share intrinsic value requires a real share count.`,
281
+ },
282
+ ],
283
+ details: null,
284
+ };
285
+ }
286
+ const sharesOutstanding = marketCap / quote.price;
287
+ // Signed on purpose: net cash (negative net debt) adds to equity value.
288
+ // Omit the adjustment if debt/cash fields are missing; assets minus
289
+ // liabilities is book equity, not a net-cash proxy.
290
+ const computedNetDebt = financials[0] ? computeNetDebt(financials[0]) : null;
291
+ const netDebt = computedNetDebt ?? 0;
250
292
 
251
293
  const result = computeDCF({
252
294
  freeCashFlow: latestFCF,
@@ -254,9 +296,14 @@ export const dcfTool: AgentTool<typeof params> = {
254
296
  discountRate,
255
297
  terminalGrowth,
256
298
  years,
257
- netDebt: Math.max(0, netDebt),
299
+ netDebt,
258
300
  sharesOutstanding,
259
301
  });
302
+ if (computedNetDebt == null) {
303
+ result.warnings.push(
304
+ "Net debt adjustment omitted because total debt and cash equivalents were unavailable.",
305
+ );
306
+ }
260
307
 
261
308
  const marginOfSafety = (result.intrinsicValue - quote.price) / result.intrinsicValue;
262
309
  const upside = (result.intrinsicValue - quote.price) / quote.price;
@@ -286,6 +333,9 @@ export const dcfTool: AgentTool<typeof params> = {
286
333
  ``,
287
334
  `**Sensitivity Table** (Intrinsic Value at different Growth/Discount rates)`,
288
335
  ...formatSensitivityTable(result.sensitivityTable),
336
+ ...(result.warnings.length > 0
337
+ ? ["", "**Warnings**", ...result.warnings.map((warning) => `- ${warning}`)]
338
+ : []),
289
339
  ];
290
340
 
291
341
  return {