opencandle 0.12.0 → 0.13.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 (75) hide show
  1. package/README.md +48 -180
  2. package/dist/cli-main.js +9 -8
  3. package/dist/config.d.ts +4 -0
  4. package/dist/config.js +1 -0
  5. package/dist/doctor/cli-command.js +7 -4
  6. package/dist/doctor/report.js +13 -0
  7. package/dist/gui/server/chat-event-adapter.js +145 -20
  8. package/dist/gui/server/history-snapshot-store.d.ts +8 -0
  9. package/dist/gui/server/history-snapshot-store.js +43 -0
  10. package/dist/gui/server/http-routes.d.ts +2 -0
  11. package/dist/gui/server/http-routes.js +32 -7
  12. package/dist/gui/server/live-chat-event-adapter.d.ts +2 -0
  13. package/dist/gui/server/live-chat-event-adapter.js +33 -15
  14. package/dist/gui/server/market-indices-api.d.ts +21 -0
  15. package/dist/gui/server/market-indices-api.js +26 -0
  16. package/dist/gui/server/market-indices-snapshot-store.d.ts +12 -0
  17. package/dist/gui/server/market-indices-snapshot-store.js +56 -0
  18. package/dist/gui/server/market-state-api.d.ts +112 -0
  19. package/dist/gui/server/market-state-api.js +246 -4
  20. package/dist/gui/server/model-setup.d.ts +2 -11
  21. package/dist/gui/server/model-setup.js +10 -6
  22. package/dist/gui/server/server.js +12 -9
  23. package/dist/gui/server/tool-metadata.d.ts +14 -14
  24. package/dist/gui/server/ws-hub.js +1 -1
  25. package/dist/infra/cache.d.ts +4 -0
  26. package/dist/infra/cache.js +4 -0
  27. package/dist/infra/lse-byte-budget.d.ts +10 -0
  28. package/dist/infra/lse-byte-budget.js +47 -0
  29. package/dist/infra/rate-limiter.js +1 -0
  30. package/dist/onboarding/providers.d.ts +16 -1
  31. package/dist/onboarding/providers.js +20 -0
  32. package/dist/onboarding/validate-model-key.js +33 -1
  33. package/dist/onboarding/validation.js +8 -0
  34. package/dist/pi/opencandle-extension.d.ts +2 -1
  35. package/dist/pi/opencandle-extension.js +2 -2
  36. package/dist/pi/session.d.ts +2 -3
  37. package/dist/pi/session.js +7 -5
  38. package/dist/pi/setup.d.ts +3 -3
  39. package/dist/pi/setup.js +51 -59
  40. package/dist/prompts/workflow-prompts.js +16 -14
  41. package/dist/providers/index.d.ts +1 -0
  42. package/dist/providers/index.js +1 -0
  43. package/dist/providers/lse.d.ts +35 -0
  44. package/dist/providers/lse.js +284 -0
  45. package/dist/providers/polymarket.js +22 -5
  46. package/dist/providers/wrap-provider.js +1 -0
  47. package/dist/providers/yahoo-finance.js +1 -0
  48. package/dist/routing/route-manifest.js +1 -0
  49. package/dist/routing/router.js +8 -1
  50. package/dist/runtime/evidence.d.ts +1 -0
  51. package/dist/runtime/evidence.js +1 -1
  52. package/dist/runtime/session-coordinator.d.ts +2 -2
  53. package/dist/runtime/session-coordinator.js +2 -2
  54. package/dist/tools/fundamentals/dcf.js +37 -8
  55. package/dist/tools/fundamentals/financials.js +88 -10
  56. package/dist/tools/index.d.ts +10 -5
  57. package/dist/tools/index.js +3 -0
  58. package/dist/tools/market/price-comparison.d.ts +30 -0
  59. package/dist/tools/market/price-comparison.js +202 -0
  60. package/dist/tools/market/stock-history.d.ts +18 -3
  61. package/dist/tools/market/stock-history.js +132 -17
  62. package/dist/tools/portfolio/correlation.d.ts +1 -1
  63. package/dist/tools/portfolio/risk-analysis.d.ts +1 -1
  64. package/dist/types/market.d.ts +2 -0
  65. package/gui/web/dist/assets/CatalogOverlay-Cy8Fq0fn.js +1 -0
  66. package/gui/web/dist/assets/allocation-donut-DgBRIKCk.js +52 -0
  67. package/gui/web/dist/assets/index-DIlFyIOO.js +66 -0
  68. package/gui/web/dist/assets/index-DS4jESOB.css +2 -0
  69. package/gui/web/dist/assets/market-chart-DammaCjH.js +1 -0
  70. package/gui/web/dist/assets/utils-CnADgYgh.js +1 -0
  71. package/gui/web/dist/index.html +3 -2
  72. package/package.json +15 -17
  73. package/gui/web/dist/assets/CatalogOverlay-CAc7e3Pf.js +0 -1
  74. package/gui/web/dist/assets/index-BOEKd9wT.css +0 -2
  75. package/gui/web/dist/assets/index-C-H05cQ2.js +0 -65
@@ -0,0 +1,284 @@
1
+ import { getConfig } from "../config.js";
2
+ import { cache, STALE_LIMIT, TTL } from "../infra/cache.js";
3
+ import { markExhausted, recordBytes } from "../infra/lse-byte-budget.js";
4
+ import { rateLimiter } from "../infra/rate-limiter.js";
5
+ import { ProviderCredentialError } from "./provider-credential-error.js";
6
+ const BASE_URL = "https://api.londonstrategicedge.com/vault";
7
+ const MAX_RETRIES = 2;
8
+ const RETRY_DELAY_MS = 1_000;
9
+ const MAX_RETRY_AFTER_MS = 5_000;
10
+ export class LseHttpError extends Error {
11
+ status;
12
+ detail;
13
+ constructor(status, detail) {
14
+ super(`LSE HTTP ${status}: ${detail}`);
15
+ this.status = status;
16
+ this.detail = detail;
17
+ this.name = "LseHttpError";
18
+ }
19
+ }
20
+ function throwIfLseAuthError(error) {
21
+ if (error instanceof ProviderCredentialError && error.provider === "lse")
22
+ throw error;
23
+ if (error instanceof LseHttpError && (error.status === 401 || error.status === 403)) {
24
+ throw new ProviderCredentialError("lse", "stale", error.status);
25
+ }
26
+ }
27
+ async function readErrorDetail(response) {
28
+ try {
29
+ const body = (await response.json());
30
+ if (typeof body.detail === "string")
31
+ return body.detail;
32
+ }
33
+ catch {
34
+ // Preserve a useful typed HTTP error even when the provider body is not JSON.
35
+ }
36
+ return response.statusText || `HTTP ${response.status}`;
37
+ }
38
+ function parseRetryAfterMs(value) {
39
+ if (!value?.trim())
40
+ return undefined;
41
+ const seconds = Number(value.trim());
42
+ if (Number.isFinite(seconds) && seconds >= 0)
43
+ return seconds * 1_000;
44
+ const dateMs = Date.parse(value.trim());
45
+ if (Number.isNaN(dateMs))
46
+ return undefined;
47
+ return Math.max(0, dateMs - Date.now());
48
+ }
49
+ function sleep(ms) {
50
+ return new Promise((resolve) => setTimeout(resolve, ms));
51
+ }
52
+ async function lseGet(path, params) {
53
+ const apiKey = getConfig().lseApiKey;
54
+ if (!apiKey)
55
+ throw new ProviderCredentialError("lse", "missing");
56
+ const url = `${BASE_URL}${path}?${new URLSearchParams(params)}`;
57
+ let lastError;
58
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
59
+ const response = await globalThis.fetch(url, { headers: { "x-api-key": apiKey } });
60
+ const dataBytes = response.headers.get("x-data-bytes");
61
+ if (dataBytes !== null) {
62
+ const parsedBytes = Number(dataBytes);
63
+ if (Number.isFinite(parsedBytes))
64
+ recordBytes(parsedBytes);
65
+ }
66
+ if (response.ok)
67
+ return (await response.json());
68
+ const error = new LseHttpError(response.status, await readErrorDetail(response));
69
+ throwIfLseAuthError(error);
70
+ lastError = error;
71
+ if (response.status === 429 && error.detail.toLowerCase().includes("allowance")) {
72
+ markExhausted();
73
+ throw error;
74
+ }
75
+ const retryable = response.status === 429 || response.status >= 500;
76
+ if (!retryable || attempt === MAX_RETRIES)
77
+ throw error;
78
+ const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
79
+ await sleep(retryAfterMs === undefined
80
+ ? RETRY_DELAY_MS * (attempt + 1)
81
+ : Math.min(retryAfterMs, MAX_RETRY_AFTER_MS));
82
+ }
83
+ throw lastError ?? new Error("LSE request failed without an error");
84
+ }
85
+ export async function getLseCandles(symbol, timeframe, opts = {}) {
86
+ const params = { symbol, timeframe };
87
+ if (opts.start !== undefined)
88
+ params.start = opts.start.slice(0, 10);
89
+ if (opts.end !== undefined)
90
+ params.end = opts.end;
91
+ if (opts.order !== undefined)
92
+ params.order = opts.order;
93
+ if (opts.limit !== undefined)
94
+ params.limit = String(opts.limit);
95
+ const cacheKey = `lse:candles:${new URLSearchParams(params)}`;
96
+ const cached = cache.get(cacheKey);
97
+ if (cached)
98
+ return cached;
99
+ try {
100
+ await rateLimiter.acquire("lse");
101
+ const rows = await lseGet("/candles", params);
102
+ if (!isValidLseCandlePayload(rows)) {
103
+ throw new Error("LSE candles response did not contain valid candle rows");
104
+ }
105
+ cache.set(cacheKey, rows, TTL.CANDLES);
106
+ return rows;
107
+ }
108
+ catch (error) {
109
+ throwIfLseAuthError(error);
110
+ const stale = cache.getStale(cacheKey, STALE_LIMIT.CANDLES);
111
+ if (stale)
112
+ return stale.value;
113
+ throw error;
114
+ }
115
+ }
116
+ function isValidLseCandlePayload(value) {
117
+ return (Array.isArray(value) &&
118
+ value.length > 0 &&
119
+ value.every((row) => {
120
+ if (typeof row !== "object" || row === null)
121
+ return false;
122
+ const candle = row;
123
+ const timestamp = typeof candle.ts === "string"
124
+ ? Date.parse(/(?:Z|[+-]\d{2}:\d{2})$/i.test(candle.ts) ? candle.ts : `${candle.ts}Z`)
125
+ : Number.NaN;
126
+ const prices = [candle.open, candle.high, candle.low, candle.close];
127
+ return (Number.isFinite(timestamp) &&
128
+ prices.every((price) => typeof price === "number" && Number.isFinite(price) && price > 0) &&
129
+ (candle.high ?? 0) >= Math.max(candle.open ?? 0, candle.close ?? 0) &&
130
+ (candle.low ?? Number.POSITIVE_INFINITY) <=
131
+ Math.min(candle.open ?? Number.POSITIVE_INFINITY, candle.close ?? Number.POSITIVE_INFINITY) &&
132
+ typeof candle.volume === "number" &&
133
+ Number.isFinite(candle.volume) &&
134
+ candle.volume >= 0);
135
+ }));
136
+ }
137
+ export async function getLseFinancialReports(symbol, reportType, opts = {}) {
138
+ const params = { symbol, report_type: reportType };
139
+ if (opts.period !== undefined)
140
+ params.period = opts.period;
141
+ if (opts.start !== undefined)
142
+ params.start = opts.start;
143
+ if (opts.end !== undefined)
144
+ params.end = opts.end;
145
+ if (opts.order !== undefined)
146
+ params.order = opts.order;
147
+ if (opts.limit !== undefined)
148
+ params.limit = String(opts.limit);
149
+ const cacheKey = `lse:financial_reports:${new URLSearchParams(params)}`;
150
+ const cached = cache.get(cacheKey);
151
+ if (cached)
152
+ return cached;
153
+ try {
154
+ await rateLimiter.acquire("lse");
155
+ const rows = await lseGet("/ref/financial_reports", params);
156
+ cache.set(cacheKey, rows, TTL.FINANCIAL_REPORTS);
157
+ return rows;
158
+ }
159
+ catch (error) {
160
+ throwIfLseAuthError(error);
161
+ const stale = cache.getStale(cacheKey, STALE_LIMIT.FINANCIAL_REPORTS);
162
+ if (stale)
163
+ return stale.value;
164
+ throw error;
165
+ }
166
+ }
167
+ function parseNum(value) {
168
+ if (typeof value !== "number" && typeof value !== "string")
169
+ return undefined;
170
+ if (typeof value === "string" && value.trim() === "")
171
+ return undefined;
172
+ const parsed = Number(value);
173
+ return Number.isFinite(parsed) ? parsed : undefined;
174
+ }
175
+ function parseReportData(row) {
176
+ if (typeof row.data !== "string")
177
+ return undefined;
178
+ try {
179
+ const parsed = JSON.parse(row.data);
180
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
181
+ ? parsed
182
+ : undefined;
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
188
+ function indexReportData(rows) {
189
+ const indexed = new Map();
190
+ for (const row of rows) {
191
+ if (typeof row.date !== "string")
192
+ continue;
193
+ const data = parseReportData(row);
194
+ if (data)
195
+ indexed.set(row.date, data);
196
+ }
197
+ return indexed;
198
+ }
199
+ export async function getLseFinancials(symbol) {
200
+ const [incomeRows, balanceRows, cashflowRows] = await Promise.all([
201
+ getLseFinancialReports(symbol, "income", { period: "FY" }),
202
+ getLseFinancialReports(symbol, "balance", { period: "FY" }),
203
+ getLseFinancialReports(symbol, "cashflow", { period: "FY" }),
204
+ ]);
205
+ const balanceByDate = indexReportData(balanceRows);
206
+ const cashflowByDate = indexReportData(cashflowRows);
207
+ const statements = incomeRows
208
+ .flatMap((row) => {
209
+ if (typeof row.date !== "string")
210
+ return [];
211
+ const income = parseReportData(row);
212
+ return income ? [{ date: row.date, income }] : [];
213
+ })
214
+ .sort((a, b) => b.date.localeCompare(a.date))
215
+ .flatMap(({ date, income }) => {
216
+ const balance = balanceByDate.get(date);
217
+ const cashflow = cashflowByDate.get(date);
218
+ if (!balance || !cashflow)
219
+ return [];
220
+ const revenue = parseNum(income.revenue);
221
+ const grossProfit = parseNum(income.grossProfit);
222
+ const operatingIncome = parseNum(income.operatingIncome);
223
+ const netIncome = parseNum(income.netIncome);
224
+ const eps = parseNum(income.eps);
225
+ const totalAssets = parseNum(balance.totalAssets);
226
+ const totalLiabilities = parseNum(balance.totalLiabilities);
227
+ const totalEquity = parseNum(balance.totalEquity);
228
+ const operatingCashFlow = parseNum(cashflow.operatingCashFlow);
229
+ const reportedFreeCashFlow = parseNum(cashflow.freeCashFlow);
230
+ const capitalExpenditure = parseNum(cashflow.capitalExpenditure);
231
+ const freeCashFlow = reportedFreeCashFlow ??
232
+ (operatingCashFlow !== undefined && capitalExpenditure !== undefined
233
+ ? // LSE capex is negative; subtract its magnitude to match AV's positive-capex semantics.
234
+ operatingCashFlow - Math.abs(capitalExpenditure)
235
+ : undefined);
236
+ if (revenue === undefined ||
237
+ grossProfit === undefined ||
238
+ operatingIncome === undefined ||
239
+ netIncome === undefined ||
240
+ eps === undefined ||
241
+ totalAssets === undefined ||
242
+ totalLiabilities === undefined ||
243
+ totalEquity === undefined ||
244
+ operatingCashFlow === undefined ||
245
+ freeCashFlow === undefined) {
246
+ return [];
247
+ }
248
+ return [
249
+ {
250
+ fiscalDate: date,
251
+ revenue,
252
+ grossProfit,
253
+ operatingIncome,
254
+ netIncome,
255
+ eps,
256
+ totalAssets,
257
+ totalLiabilities,
258
+ totalEquity,
259
+ operatingCashFlow,
260
+ freeCashFlow,
261
+ totalDebt: parseNum(balance.totalDebt),
262
+ cashAndEquivalents: parseNum(balance.cashAndCashEquivalents),
263
+ sharesOutstanding: parseNum(income.weightedAverageShsOut),
264
+ },
265
+ ];
266
+ })
267
+ .slice(0, 4);
268
+ if (statements.length === 0) {
269
+ throw new Error(`LSE returned no complete financial statements for ${symbol}`);
270
+ }
271
+ return statements;
272
+ }
273
+ export function toLseTimeframe(interval) {
274
+ const timeframes = {
275
+ "1m": "1m",
276
+ "5m": "5m",
277
+ "15m": "15m",
278
+ "1h": "1h",
279
+ "1d": "1d",
280
+ "1wk": "1w",
281
+ "1mo": "1mo",
282
+ };
283
+ return timeframes[interval];
284
+ }
@@ -51,7 +51,13 @@ function marketToQuotes(market, event) {
51
51
  const liquidityUsd = numberValue(market.liquidityNum) ??
52
52
  numberValue(market.liquidity) ??
53
53
  numberValue(event?.liquidity);
54
- const closeDate = stringValue(market.endDate) ?? normalizeDateOnly(market.endDateIso ?? event?.endDate);
54
+ const rawCloseDate = stringValue(market.endDate) ?? normalizeDateOnly(market.endDateIso ?? event?.endDate);
55
+ // A past close date on a market Gamma explicitly marks active is the same
56
+ // stale metadata isOpenMarket ignores; presenting it as a close date would
57
+ // tell users an open market already closed, so it is omitted instead.
58
+ const closeDate = rawCloseDate !== undefined && booleanValue(market.active) === true && isPastDate(rawCloseDate)
59
+ ? undefined
60
+ : rawCloseDate;
55
61
  const url = marketUrl(market, event);
56
62
  const asOf = stringValue(market.updatedAt) ?? stringValue(event?.updatedAt);
57
63
  return outcomes.flatMap((outcome, index) => {
@@ -76,15 +82,26 @@ function marketToQuotes(market, event) {
76
82
  });
77
83
  }
78
84
  function isOpenMarket(market) {
79
- if (booleanValue(market.closed) === true)
85
+ const closed = booleanValue(market.closed);
86
+ const active = booleanValue(market.active);
87
+ if (closed === true)
80
88
  return false;
81
- if (booleanValue(market.active) === false)
89
+ if (active === false)
82
90
  return false;
91
+ // An explicit affirmative active flag is Gamma's authoritative openness
92
+ // signal and beats the endDate heuristic, whose metadata is demonstrably
93
+ // stale on live markets. A lone `closed: false` only says "not settled",
94
+ // so it still falls through to the date check.
95
+ if (active === true)
96
+ return true;
83
97
  const endDate = stringValue(market.endDate) ?? stringValue(market.endDateIso);
84
98
  if (!endDate)
85
99
  return true;
86
- const parsed = new Date(endDate);
87
- return Number.isNaN(parsed.getTime()) || parsed.getTime() > Date.now();
100
+ return !isPastDate(endDate);
101
+ }
102
+ function isPastDate(value) {
103
+ const parsed = new Date(value);
104
+ return !Number.isNaN(parsed.getTime()) && parsed.getTime() <= Date.now();
88
105
  }
89
106
  function normalizeLimit(limit) {
90
107
  if (!Number.isFinite(limit))
@@ -36,6 +36,7 @@ export async function wrapProvider(providerId, fn) {
36
36
  status: "ok",
37
37
  data,
38
38
  timestamp,
39
+ provider: providerId,
39
40
  cached: cache?.status === "cached" ? true : undefined,
40
41
  stale: cache?.status === "stale" ? true : undefined,
41
42
  };
@@ -310,6 +310,7 @@ export async function getHistory(symbol, range = "6mo", interval = "1d") {
310
310
  const ohlcv = timestamps
311
311
  .map((ts, i) => ({
312
312
  date: new Date(ts * 1000).toISOString().split("T")[0],
313
+ timestamp: ts,
313
314
  open: quotes.open[i],
314
315
  high: quotes.high[i],
315
316
  low: quotes.low[i],
@@ -9,6 +9,7 @@ export const TOOL_BUNDLE_TOOLS = {
9
9
  "search_ticker",
10
10
  "get_stock_quote",
11
11
  "get_stock_history",
12
+ "get_price_comparison",
12
13
  "screen_stocks",
13
14
  "get_crypto_price",
14
15
  "get_crypto_history",
@@ -638,6 +638,13 @@ export function postProcessRouterOutput(text, output, inputContext) {
638
638
  const selectedToolBundles = isConceptualEducationRequest(text, next)
639
639
  ? []
640
640
  : selectToolBundles(next);
641
+ if (isExplicitMacroDataRequest(text) && !selectedToolBundles.includes("macro")) {
642
+ selectedToolBundles.push("macro");
643
+ diagnostics.push({
644
+ code: "macro_tool_bundle_recovered",
645
+ message: "explicit macro data request retained the macro tool bundle",
646
+ });
647
+ }
641
648
  if (selectedToolBundles.length === 0 && isConceptualEducationRequest(text, next)) {
642
649
  diagnostics.push({
643
650
  code: "conceptual_education_no_tools",
@@ -659,7 +666,7 @@ export function postProcessRouterOutput(text, output, inputContext) {
659
666
  });
660
667
  }
661
668
  function isExplicitMacroDataRequest(text) {
662
- return /\b(?:get_economic_data|fred|cpi|inflation|fed\s+funds?|unemployment|gdp|macro)\b/i.test(text);
669
+ return /\b(?:get_economic_data|fred|cpi|inflation|fed\s+funds?|unemployment|gdp|macro|fear\s*(?:&|and)\s*greed)\b/i.test(text);
663
670
  }
664
671
  function isConceptualEducationRequest(text, output) {
665
672
  if (output.routeKind !== "agent_task")
@@ -19,6 +19,7 @@ export interface ProviderResultOk<T> {
19
19
  status: "ok";
20
20
  data: T;
21
21
  timestamp: string;
22
+ provider?: string;
22
23
  cached?: boolean;
23
24
  stale?: boolean;
24
25
  }
@@ -11,7 +11,7 @@ export function toEvidenceRecord(label, result, providerId) {
11
11
  provenance: {
12
12
  source: result.stale ? "stale_cache" : "fetched",
13
13
  timestamp: result.timestamp,
14
- provider: providerId,
14
+ provider: providerId ?? result.provider,
15
15
  confidence: result.stale ? 0.5 : undefined,
16
16
  },
17
17
  };
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ModelRuntime, SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import { MemoryStorage } from "../memory/index.js";
3
3
  /**
4
4
  * Alias for the session-manager handle extensions receive via
@@ -52,7 +52,7 @@ export declare class SessionCoordinator {
52
52
  /** Run setup flow. */
53
53
  runSetup(pi: ExtensionAPI, ctx: ExtensionContext | ExtensionCommandContext, options: {
54
54
  mode: "startup" | "manual";
55
- }): Promise<"ready" | "shutdown" | "cancelled">;
55
+ }, modelRuntime?: ModelRuntime): Promise<"ready" | "shutdown" | "cancelled">;
56
56
  /** Extract and persist user preferences from natural language. */
57
57
  extractAndStorePreferences(text: string): void;
58
58
  /** Record a workflow run in storage. */
@@ -112,8 +112,8 @@ export class SessionCoordinator {
112
112
  this.sessionId = sessionId;
113
113
  }
114
114
  /** Run setup flow. */
115
- async runSetup(pi, ctx, options) {
116
- return runOpenCandleSetup(pi, ctx, options);
115
+ async runSetup(pi, ctx, options, modelRuntime) {
116
+ return runOpenCandleSetup(pi, ctx, options, modelRuntime);
117
117
  }
118
118
  /** Extract and persist user preferences from natural language. */
119
119
  extractAndStorePreferences(text) {
@@ -1,6 +1,8 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getConfig } from "../../config.js";
3
+ import { isOverSoftThreshold } from "../../infra/lse-byte-budget.js";
3
4
  import { getFinancials, getOverview } from "../../providers/alpha-vantage.js";
5
+ import { getLseFinancials } from "../../providers/lse.js";
4
6
  import { ProviderCredentialError } from "../../providers/provider-credential-error.js";
5
7
  import { wrapProvider } from "../../providers/wrap-provider.js";
6
8
  import { getQuote, getYahooFinancials } from "../../providers/yahoo-finance.js";
@@ -129,10 +131,33 @@ export const dcfTool = {
129
131
  const symbol = args.symbol.toUpperCase();
130
132
  const config = getConfig();
131
133
  const alphaVantageApiKey = config.alphaVantageApiKey;
132
- const [alphaVantageFinancialsResult, quoteResult] = await Promise.all([
133
- optionalAlphaVantage(alphaVantageApiKey, (apiKey) => getFinancials(symbol, apiKey)),
134
- wrapProvider("yahoo", () => getQuote(symbol)),
135
- ]);
134
+ const quotePromise = wrapProvider("yahoo", () => getQuote(symbol));
135
+ const lseEligible = !!config.lseApiKey && !isOverSoftThreshold();
136
+ let lseFinancialsResult;
137
+ if (lseEligible) {
138
+ try {
139
+ lseFinancialsResult = await wrapProvider("lse", () => getLseFinancials(symbol));
140
+ }
141
+ catch (error) {
142
+ if (error instanceof ProviderCredentialError) {
143
+ lseFinancialsResult = {
144
+ status: "unavailable",
145
+ reason: `London Strategic Edge credential ${error.reason}`,
146
+ provider: "lse",
147
+ };
148
+ }
149
+ else {
150
+ throw error;
151
+ }
152
+ }
153
+ }
154
+ const lseIsFresh = lseFinancialsResult?.status === "ok" &&
155
+ lseFinancialsResult.data.length > 0 &&
156
+ !lseFinancialsResult.stale;
157
+ const alphaVantageFinancialsResult = lseIsFresh
158
+ ? undefined
159
+ : await optionalAlphaVantage(alphaVantageApiKey, (apiKey) => getFinancials(symbol, apiKey));
160
+ const quoteResult = await quotePromise;
136
161
  const missing = [];
137
162
  if (quoteResult.status === "unavailable")
138
163
  missing.push(`stock quote (${quoteResult.reason})`);
@@ -159,17 +184,21 @@ export const dcfTool = {
159
184
  details: null,
160
185
  };
161
186
  }
162
- let financialsSource = "Alpha Vantage";
187
+ let financialsSource = lseIsFresh ? "London Strategic Edge" : "Alpha Vantage";
163
188
  let financials;
164
- if (alphaVantageFinancialsResult.status === "ok" && !alphaVantageFinancialsResult.stale) {
189
+ if (lseIsFresh && lseFinancialsResult?.status === "ok") {
190
+ financials = lseFinancialsResult.data;
191
+ }
192
+ else if (alphaVantageFinancialsResult?.status === "ok" &&
193
+ !alphaVantageFinancialsResult.stale) {
165
194
  financials = alphaVantageFinancialsResult.data;
166
195
  }
167
196
  else {
168
- const alphaVantageReason = alphaVantageFinancialsResult.status === "ok"
197
+ const alphaVantageReason = alphaVantageFinancialsResult?.status === "ok"
169
198
  ? `stale cached financial statements${alphaVantageFinancialsResult.timestamp
170
199
  ? ` from ${alphaVantageFinancialsResult.timestamp}`
171
200
  : ""}`
172
- : alphaVantageFinancialsResult.reason;
201
+ : (alphaVantageFinancialsResult?.reason ?? "Alpha Vantage unavailable");
173
202
  const yahooFinancialsResult = await wrapProvider("yahoo", () => getYahooFinancials(symbol));
174
203
  if (yahooFinancialsResult.status === "unavailable") {
175
204
  return {
@@ -1,7 +1,10 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { getConfig } from "../../config.js";
3
+ import { isOverSoftThreshold } from "../../infra/lse-byte-budget.js";
3
4
  import { withCredentialCheck } from "../../onboarding/tool-helpers.js";
4
5
  import { getFinancials } from "../../providers/alpha-vantage.js";
6
+ import { getLseFinancials } from "../../providers/lse.js";
7
+ import { ProviderCredentialError } from "../../providers/provider-credential-error.js";
5
8
  import { wrapProvider } from "../../providers/wrap-provider.js";
6
9
  const params = Type.Object({
7
10
  symbol: Type.String({ description: "Stock ticker symbol (e.g. AAPL, MSFT)" }),
@@ -9,20 +12,18 @@ const params = Type.Object({
9
12
  export const financialsTool = {
10
13
  name: "get_financials",
11
14
  label: "Financial Statements",
12
- description: "Get annual income statement data: revenue, gross profit, operating income, net income, and EPS. Requires Alpha Vantage.",
15
+ description: "Get annual income statement data: revenue, gross profit, operating income, net income, and EPS. Uses London Strategic Edge or Alpha Vantage.",
13
16
  parameters: params,
14
17
  async execute(_toolCallId, args) {
15
- return withCredentialCheck("alpha_vantage", async () => {
16
- const apiKey = getConfig().alphaVantageApiKey;
17
- if (!apiKey)
18
- throw new Error("Alpha Vantage credential is missing.");
19
- const result = await wrapProvider("alphavantage", () => getFinancials(args.symbol.toUpperCase(), apiKey));
18
+ const symbol = args.symbol.toUpperCase();
19
+ const config = getConfig();
20
+ const renderResult = (result, source) => {
20
21
  if (result.status === "unavailable") {
21
22
  return {
22
23
  content: [
23
24
  {
24
25
  type: "text",
25
- text: `⚠ Financial statements unavailable for ${args.symbol.toUpperCase()} (${result.reason}). Analysis will proceed without financials.`,
26
+ text: `⚠ Financial statements unavailable for ${symbol} (${result.reason}). Analysis will proceed without financials.`,
26
27
  },
27
28
  ],
28
29
  details: [],
@@ -35,10 +36,87 @@ export const financialsTool = {
35
36
  details: [],
36
37
  };
37
38
  }
38
- const header = `${args.symbol.toUpperCase()} — Annual Income Statement (${statements.length} years)`;
39
+ const header = `${symbol} — Annual Income Statement (${statements.length} years)`;
39
40
  const rows = statements.map((s) => `${s.fiscalDate} | Rev: $${fmt(s.revenue)} | GP: $${fmt(s.grossProfit)} | OpInc: $${fmt(s.operatingIncome)} | Net: $${fmt(s.netIncome)}`);
40
- const text = [header, ...rows].join("\n");
41
- return { content: [{ type: "text", text }], details: statements };
41
+ const sourceLine = source ? [`Source: ${source}`] : [];
42
+ const staleLine = result.stale
43
+ ? [`Data status: stale cache from ${result.timestamp} (provider unavailable)`]
44
+ : [];
45
+ return {
46
+ content: [
47
+ {
48
+ type: "text",
49
+ text: [header, ...sourceLine, ...staleLine, ...rows].join("\n"),
50
+ },
51
+ ],
52
+ details: statements,
53
+ };
54
+ };
55
+ const runAlphaVantageOnly = () => withCredentialCheck("alpha_vantage", async () => {
56
+ const apiKey = getConfig().alphaVantageApiKey;
57
+ if (!apiKey)
58
+ throw new Error("Alpha Vantage credential is missing.");
59
+ const result = await wrapProvider("alphavantage", () => getFinancials(symbol, apiKey));
60
+ return renderResult(result);
61
+ });
62
+ const lseEligible = !!config.lseApiKey && !isOverSoftThreshold();
63
+ if (!lseEligible)
64
+ return runAlphaVantageOnly();
65
+ const lseEntry = {
66
+ provider: "lse",
67
+ fn: async () => {
68
+ try {
69
+ const statements = await getLseFinancials(symbol);
70
+ if (statements.length === 0)
71
+ throw new Error("no financial statements returned");
72
+ return statements;
73
+ }
74
+ catch (error) {
75
+ if (error instanceof ProviderCredentialError) {
76
+ throw new Error(`London Strategic Edge credential ${error.reason}`);
77
+ }
78
+ throw error;
79
+ }
80
+ },
81
+ };
82
+ if (!config.alphaVantageApiKey) {
83
+ const result = await wrapProvider(lseEntry.provider, lseEntry.fn);
84
+ if (result.status === "ok")
85
+ return renderResult(result, "London Strategic Edge");
86
+ return runAlphaVantageOnly();
87
+ }
88
+ return withCredentialCheck("alpha_vantage", async () => {
89
+ const apiKey = config.alphaVantageApiKey;
90
+ if (!apiKey)
91
+ throw new Error("Alpha Vantage credential is missing.");
92
+ const lseResult = await wrapProvider(lseEntry.provider, lseEntry.fn);
93
+ if (lseResult.status === "ok" && !lseResult.stale) {
94
+ return renderResult(lseResult, "London Strategic Edge");
95
+ }
96
+ let alphaVantageResult;
97
+ try {
98
+ alphaVantageResult = await wrapProvider("alphavantage", () => getFinancials(symbol, apiKey));
99
+ }
100
+ catch (error) {
101
+ if (error instanceof ProviderCredentialError && lseResult.status === "ok") {
102
+ return renderResult(lseResult, "London Strategic Edge");
103
+ }
104
+ throw error;
105
+ }
106
+ if (alphaVantageResult.status === "ok" && !alphaVantageResult.stale) {
107
+ return renderResult(alphaVantageResult, "Alpha Vantage");
108
+ }
109
+ if (lseResult.status === "ok") {
110
+ return renderResult(lseResult, "London Strategic Edge");
111
+ }
112
+ if (alphaVantageResult.status === "ok") {
113
+ return renderResult(alphaVantageResult, "Alpha Vantage");
114
+ }
115
+ return renderResult({
116
+ status: "unavailable",
117
+ provider: "fallback",
118
+ reason: `lse: ${lseResult.reason}; alphavantage: ${alphaVantageResult.reason}`,
119
+ });
42
120
  });
43
121
  },
44
122
  };