stock-scanner-mcp 1.16.2 → 1.16.3
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/dist/chunk-5IWY6HOQ.js +3995 -0
- package/dist/index.js +10 -2423
- package/dist/sidecar/index.js +499 -730
- package/docs/sidecar-openapi.json +4780 -0
- package/package.json +5 -3
- package/dist/chunk-T5IF6777.js +0 -1646
|
@@ -0,0 +1,3995 @@
|
|
|
1
|
+
// src/modules/tradingview/index.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// src/shared/types.ts
|
|
5
|
+
var SEC_USER_AGENT = "StockScanner contact@example.com";
|
|
6
|
+
function errorResult(message, code = "INTERNAL_ERROR") {
|
|
7
|
+
return {
|
|
8
|
+
content: [{
|
|
9
|
+
type: "text",
|
|
10
|
+
text: JSON.stringify({
|
|
11
|
+
error: true,
|
|
12
|
+
code,
|
|
13
|
+
message
|
|
14
|
+
}, null, 2)
|
|
15
|
+
}],
|
|
16
|
+
isError: true
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function successResult(text) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text }]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/shared/http.ts
|
|
26
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
27
|
+
var SENSITIVE_PARAMS = /([?&])(apikey|api_key|token|secret|key)=[^&]*/gi;
|
|
28
|
+
var DEFAULT_HEADERS = {
|
|
29
|
+
"User-Agent": "stock-scanner-mcp/0.1.0 (+https://github.com/yyordanov-tradu/stock-scanner-mcp)"
|
|
30
|
+
};
|
|
31
|
+
function sanitizeUrl(url) {
|
|
32
|
+
return url.replace(SENSITIVE_PARAMS, "$1$2=REDACTED");
|
|
33
|
+
}
|
|
34
|
+
function sanitizeError(err) {
|
|
35
|
+
if (err instanceof Error) {
|
|
36
|
+
const sanitized = new Error(sanitizeUrl(err.message));
|
|
37
|
+
sanitized.stack = err.stack;
|
|
38
|
+
return sanitized;
|
|
39
|
+
}
|
|
40
|
+
return new Error(sanitizeUrl(String(err)));
|
|
41
|
+
}
|
|
42
|
+
async function httpPost(url, body, options = {}) {
|
|
43
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, headers = {} } = options;
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(url, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
...DEFAULT_HEADERS,
|
|
52
|
+
...headers
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
signal: controller.signal
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const text = await response.text().catch(() => "");
|
|
59
|
+
throw new Error(
|
|
60
|
+
`HTTP ${response.status}: ${response.statusText} -- ${text.slice(0, 200)}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return await response.json();
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw sanitizeError(err);
|
|
66
|
+
} finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function httpGet(url, options = {}) {
|
|
71
|
+
const {
|
|
72
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
73
|
+
headers = {},
|
|
74
|
+
responseType = "json"
|
|
75
|
+
} = options;
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(url, {
|
|
80
|
+
method: "GET",
|
|
81
|
+
headers: {
|
|
82
|
+
...DEFAULT_HEADERS,
|
|
83
|
+
...headers
|
|
84
|
+
},
|
|
85
|
+
signal: controller.signal
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const text = await response.text().catch(() => "");
|
|
89
|
+
throw new Error(
|
|
90
|
+
`HTTP ${response.status}: ${response.statusText} -- ${text.slice(0, 200)}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (responseType === "text") {
|
|
94
|
+
return await response.text();
|
|
95
|
+
}
|
|
96
|
+
return await response.json();
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw sanitizeError(err);
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function httpFetch(url, init) {
|
|
104
|
+
return fetch(url, init);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/modules/tradingview/columns.ts
|
|
108
|
+
var STOCK_COLUMNS = [
|
|
109
|
+
"close",
|
|
110
|
+
"change",
|
|
111
|
+
"change_abs",
|
|
112
|
+
"volume",
|
|
113
|
+
"market_cap_basic",
|
|
114
|
+
"price_earnings_ttm",
|
|
115
|
+
"earnings_per_share_basic_ttm",
|
|
116
|
+
"number_of_employees",
|
|
117
|
+
"sector",
|
|
118
|
+
"description",
|
|
119
|
+
"name",
|
|
120
|
+
"type",
|
|
121
|
+
"subtype",
|
|
122
|
+
"update_mode",
|
|
123
|
+
"exchange",
|
|
124
|
+
"pricescale",
|
|
125
|
+
"minmov",
|
|
126
|
+
"fractional",
|
|
127
|
+
"minmove2",
|
|
128
|
+
"premarket_close",
|
|
129
|
+
"premarket_change",
|
|
130
|
+
"premarket_change_abs",
|
|
131
|
+
"premarket_volume",
|
|
132
|
+
"postmarket_close",
|
|
133
|
+
"postmarket_change",
|
|
134
|
+
"postmarket_change_abs",
|
|
135
|
+
"postmarket_volume",
|
|
136
|
+
"earnings_release_date",
|
|
137
|
+
"earnings_release_next_date",
|
|
138
|
+
"dividend_yield_recent",
|
|
139
|
+
"return_on_equity_fq",
|
|
140
|
+
"total_revenue_fq",
|
|
141
|
+
"net_income_fq",
|
|
142
|
+
"total_assets_fq",
|
|
143
|
+
"total_debt_fq",
|
|
144
|
+
"Recommend.All",
|
|
145
|
+
"Recommend.MA",
|
|
146
|
+
"Recommend.Other",
|
|
147
|
+
"RSI",
|
|
148
|
+
"RSI[1]",
|
|
149
|
+
"Stoch.K",
|
|
150
|
+
"Stoch.D",
|
|
151
|
+
"Stoch.K[1]",
|
|
152
|
+
"Stoch.D[1]",
|
|
153
|
+
"CCI20",
|
|
154
|
+
"CCI20[1]",
|
|
155
|
+
"ADX",
|
|
156
|
+
"ADX+DI",
|
|
157
|
+
"ADX-DI",
|
|
158
|
+
"ADX+DI[1]",
|
|
159
|
+
"ADX-DI[1]",
|
|
160
|
+
"AO",
|
|
161
|
+
"AO[1]",
|
|
162
|
+
"AO[2]",
|
|
163
|
+
"Mom",
|
|
164
|
+
"Mom[1]",
|
|
165
|
+
"MACD.macd",
|
|
166
|
+
"MACD.signal",
|
|
167
|
+
"BB.lower",
|
|
168
|
+
"BB.upper",
|
|
169
|
+
"Pivot.M.Classic.S3",
|
|
170
|
+
"Pivot.M.Classic.S2",
|
|
171
|
+
"Pivot.M.Classic.S1",
|
|
172
|
+
"Pivot.M.Classic.Middle",
|
|
173
|
+
"Pivot.M.Classic.R1",
|
|
174
|
+
"Pivot.M.Classic.R2",
|
|
175
|
+
"Pivot.M.Classic.R3",
|
|
176
|
+
"EMA5",
|
|
177
|
+
"EMA10",
|
|
178
|
+
"EMA20",
|
|
179
|
+
"EMA30",
|
|
180
|
+
"EMA50",
|
|
181
|
+
"EMA100",
|
|
182
|
+
"EMA200",
|
|
183
|
+
"SMA5",
|
|
184
|
+
"SMA10",
|
|
185
|
+
"SMA20",
|
|
186
|
+
"SMA30",
|
|
187
|
+
"SMA50",
|
|
188
|
+
"SMA100",
|
|
189
|
+
"SMA200"
|
|
190
|
+
];
|
|
191
|
+
var STOCK_TIMEFRAMES = {
|
|
192
|
+
"1m": "|1",
|
|
193
|
+
"5m": "|5",
|
|
194
|
+
"15m": "|15",
|
|
195
|
+
"1h": "|60",
|
|
196
|
+
"4h": "|240",
|
|
197
|
+
"1W": "|1W",
|
|
198
|
+
"1M": "|1M",
|
|
199
|
+
"1d": ""
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// src/modules/tradingview/scanner.ts
|
|
203
|
+
var BASE_URL = "https://scanner.tradingview.com";
|
|
204
|
+
var META_COLUMNS = /* @__PURE__ */ new Set([
|
|
205
|
+
"name",
|
|
206
|
+
"description",
|
|
207
|
+
"type",
|
|
208
|
+
"subtype",
|
|
209
|
+
"exchange",
|
|
210
|
+
"sector",
|
|
211
|
+
"update_mode",
|
|
212
|
+
"pricescale",
|
|
213
|
+
"minmov",
|
|
214
|
+
"fractional",
|
|
215
|
+
"minmove2",
|
|
216
|
+
"number_of_employees",
|
|
217
|
+
"market_cap_basic",
|
|
218
|
+
"premarket_close",
|
|
219
|
+
"premarket_change",
|
|
220
|
+
"premarket_change_abs",
|
|
221
|
+
"premarket_volume",
|
|
222
|
+
"postmarket_close",
|
|
223
|
+
"postmarket_change",
|
|
224
|
+
"postmarket_change_abs",
|
|
225
|
+
"postmarket_volume",
|
|
226
|
+
"earnings_release_date",
|
|
227
|
+
"earnings_release_next_date",
|
|
228
|
+
"dividend_yield_recent",
|
|
229
|
+
"return_on_equity_fq",
|
|
230
|
+
"total_revenue_fq",
|
|
231
|
+
"net_income_fq",
|
|
232
|
+
"total_assets_fq",
|
|
233
|
+
"total_debt_fq"
|
|
234
|
+
]);
|
|
235
|
+
function applyTimeframeSuffix(columns, timeframe) {
|
|
236
|
+
const suffix = STOCK_TIMEFRAMES[timeframe] ?? "";
|
|
237
|
+
if (!suffix) return columns;
|
|
238
|
+
return columns.map((col) => META_COLUMNS.has(col) ? col : col + suffix);
|
|
239
|
+
}
|
|
240
|
+
async function scanStocks(request) {
|
|
241
|
+
const rawColumns = request.columns ?? STOCK_COLUMNS;
|
|
242
|
+
const timeframe = request.timeframe ?? "1d";
|
|
243
|
+
const columns = applyTimeframeSuffix(rawColumns, timeframe);
|
|
244
|
+
const body = {
|
|
245
|
+
columns,
|
|
246
|
+
options: { lang: "en" },
|
|
247
|
+
range: [0, request.limit ?? 50],
|
|
248
|
+
sort: request.sort ?? { sortBy: columns[0], sortOrder: "desc" }
|
|
249
|
+
};
|
|
250
|
+
if (request.tickers) {
|
|
251
|
+
body.symbols = { tickers: request.tickers };
|
|
252
|
+
} else {
|
|
253
|
+
if (request.filters && request.filters.length > 0) {
|
|
254
|
+
body.filter = request.filters.map((f) => ({
|
|
255
|
+
left: f.left,
|
|
256
|
+
operation: f.operation,
|
|
257
|
+
right: f.right
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
if (request.exchange) {
|
|
261
|
+
body.markets = [request.exchange];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const market = request.market ?? "america";
|
|
265
|
+
const response = await httpPost(
|
|
266
|
+
`${BASE_URL}/${market}/scan`,
|
|
267
|
+
body
|
|
268
|
+
);
|
|
269
|
+
return response.data.map((row) => {
|
|
270
|
+
const data = {};
|
|
271
|
+
rawColumns.forEach((col, i) => {
|
|
272
|
+
data[col] = row.d[i] ?? null;
|
|
273
|
+
});
|
|
274
|
+
return { symbol: row.s, data };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/shared/resolver.ts
|
|
279
|
+
function resolveTicker(input, defaultExchange = "NASDAQ") {
|
|
280
|
+
const cleaned = input.trim().toUpperCase();
|
|
281
|
+
if (cleaned.includes(":")) {
|
|
282
|
+
const [ex, tick] = cleaned.split(":");
|
|
283
|
+
return {
|
|
284
|
+
ticker: tick,
|
|
285
|
+
exchange: ex,
|
|
286
|
+
full: cleaned,
|
|
287
|
+
isCrypto: ex === "BINANCE" || ex === "COINBASE" || ex === "KRAKEN"
|
|
288
|
+
// Simple heuristic
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const commonCrypto = /* @__PURE__ */ new Set(["BTC", "ETH", "SOL", "XRP", "ADA", "DOGE", "DOT"]);
|
|
292
|
+
if (commonCrypto.has(cleaned)) {
|
|
293
|
+
return {
|
|
294
|
+
ticker: cleaned,
|
|
295
|
+
full: cleaned,
|
|
296
|
+
isCrypto: true
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
ticker: cleaned,
|
|
301
|
+
exchange: defaultExchange,
|
|
302
|
+
full: `${defaultExchange}:${cleaned}`,
|
|
303
|
+
isCrypto: false
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/shared/utils.ts
|
|
308
|
+
function withMetadata(handler, options) {
|
|
309
|
+
return async (args) => {
|
|
310
|
+
try {
|
|
311
|
+
const result = await handler(args);
|
|
312
|
+
result._meta = {
|
|
313
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
314
|
+
source: options.source,
|
|
315
|
+
dataDelay: options.dataDelay
|
|
316
|
+
};
|
|
317
|
+
return result;
|
|
318
|
+
} catch (err) {
|
|
319
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
320
|
+
let code = "INTERNAL_ERROR";
|
|
321
|
+
if (message.includes("HTTP 429")) code = "RATE_LIMITED";
|
|
322
|
+
if (message.includes("HTTP 403")) code = "FORBIDDEN";
|
|
323
|
+
if (message.includes("fetch failed")) code = "FETCH_FAILED";
|
|
324
|
+
if (message.includes("not found")) code = "NOT_FOUND";
|
|
325
|
+
return {
|
|
326
|
+
content: [{
|
|
327
|
+
type: "text",
|
|
328
|
+
text: JSON.stringify({
|
|
329
|
+
error: true,
|
|
330
|
+
code,
|
|
331
|
+
message,
|
|
332
|
+
retryable: code === "RATE_LIMITED" || code === "FETCH_FAILED"
|
|
333
|
+
}, null, 2)
|
|
334
|
+
}],
|
|
335
|
+
isError: true,
|
|
336
|
+
_meta: {
|
|
337
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
338
|
+
source: options.source
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/modules/tradingview/index.ts
|
|
346
|
+
function createTradingviewModule() {
|
|
347
|
+
const metadata3 = { source: "tradingview", dataDelay: "15min" };
|
|
348
|
+
return {
|
|
349
|
+
name: "tradingview",
|
|
350
|
+
description: "TradingView stock scanner \u2014 prices, technicals, and screener filters for US equities",
|
|
351
|
+
requiredEnvVars: [],
|
|
352
|
+
tools: [
|
|
353
|
+
{
|
|
354
|
+
name: "tradingview_scan",
|
|
355
|
+
description: "Scan US stocks with custom filters (price > X, RSI < 30, etc.). Returns up to `limit` rows with the requested columns.",
|
|
356
|
+
inputSchema: z.object({
|
|
357
|
+
exchange: z.string().optional().describe("Exchange filter, e.g. NASDAQ, NYSE, AMEX"),
|
|
358
|
+
filters: z.array(z.object({
|
|
359
|
+
left: z.string(),
|
|
360
|
+
operation: z.enum(["greater", "less", "equal", "in_range", "not_in_range", "crosses", "crosses_above", "crosses_below"]),
|
|
361
|
+
right: z.union([z.number(), z.string(), z.array(z.number())])
|
|
362
|
+
})).optional().describe("Scanner filters"),
|
|
363
|
+
columns: z.array(z.string()).optional().describe("Columns to return (default: all 66)"),
|
|
364
|
+
timeframe: z.string().optional().describe("Timeframe: 1m, 5m, 15m, 1h, 4h, 1d (default), 1W, 1M"),
|
|
365
|
+
limit: z.number().int().optional().describe("Max rows (default 50)")
|
|
366
|
+
}),
|
|
367
|
+
readOnly: true,
|
|
368
|
+
handler: withMetadata(async (input) => {
|
|
369
|
+
const rows = await scanStocks(input);
|
|
370
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
371
|
+
}, metadata3)
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
name: "tradingview_compare_stocks",
|
|
375
|
+
description: "Returns price, change, market cap, P/E, EPS, revenue, dividend yield, RSI, and analyst recommendation rating (-1 sell to +1 buy) for 2-5 stocks. Revenue growth rate and analyst price targets are not available.",
|
|
376
|
+
inputSchema: z.object({
|
|
377
|
+
tickers: z.array(z.string()).min(2).max(5).describe("2-5 stock tickers to compare (e.g. AAPL, MSFT)")
|
|
378
|
+
}),
|
|
379
|
+
readOnly: true,
|
|
380
|
+
handler: withMetadata(async (input) => {
|
|
381
|
+
const resolvedTickers = input.tickers.map((t) => resolveTicker(t).full);
|
|
382
|
+
const columns = [
|
|
383
|
+
"name",
|
|
384
|
+
"description",
|
|
385
|
+
"close",
|
|
386
|
+
"change",
|
|
387
|
+
"market_cap_basic",
|
|
388
|
+
"price_earnings_ttm",
|
|
389
|
+
"earnings_per_share_basic_ttm",
|
|
390
|
+
"total_revenue_fq",
|
|
391
|
+
"dividend_yield_recent",
|
|
392
|
+
"RSI",
|
|
393
|
+
"Recommend.All"
|
|
394
|
+
];
|
|
395
|
+
const rows = await scanStocks({
|
|
396
|
+
tickers: resolvedTickers,
|
|
397
|
+
columns
|
|
398
|
+
});
|
|
399
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
400
|
+
}, metadata3)
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
name: "tradingview_quote",
|
|
404
|
+
description: "Get a 15-minute delayed quote for one or more stock tickers (e.g. 'AAPL' or 'NASDAQ:AAPL'). Returns price, change, volume, market cap, and pre-market/after-hours data when available. Data is delayed ~15 minutes during market hours \u2014 use finnhub_quote for real-time prices if available. If a ticker returns empty results, retry with the correct exchange prefix (e.g. 'NYSE:CDE', 'AMEX:XYZ').",
|
|
405
|
+
inputSchema: z.object({
|
|
406
|
+
tickers: z.array(z.string()).describe("Stock tickers, e.g. ['AAPL', 'MSFT']")
|
|
407
|
+
}),
|
|
408
|
+
readOnly: true,
|
|
409
|
+
handler: withMetadata(async (input) => {
|
|
410
|
+
const resolvedTickers = input.tickers.map((t) => resolveTicker(t).full);
|
|
411
|
+
const rows = await scanStocks({
|
|
412
|
+
tickers: resolvedTickers,
|
|
413
|
+
columns: [
|
|
414
|
+
"close",
|
|
415
|
+
"change",
|
|
416
|
+
"change_abs",
|
|
417
|
+
"volume",
|
|
418
|
+
"market_cap_basic",
|
|
419
|
+
"name",
|
|
420
|
+
"description",
|
|
421
|
+
"premarket_close",
|
|
422
|
+
"premarket_change",
|
|
423
|
+
"premarket_change_abs",
|
|
424
|
+
"premarket_volume",
|
|
425
|
+
"postmarket_close",
|
|
426
|
+
"postmarket_change",
|
|
427
|
+
"postmarket_change_abs",
|
|
428
|
+
"postmarket_volume"
|
|
429
|
+
]
|
|
430
|
+
});
|
|
431
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
432
|
+
}, metadata3)
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
name: "tradingview_technicals",
|
|
436
|
+
description: "Get technical indicators (RSI, MACD, moving averages, pivot points, etc.) for one or more stock tickers. If a ticker returns empty results, retry with the correct exchange prefix (e.g. 'NYSE:CDE', 'AMEX:XYZ').",
|
|
437
|
+
inputSchema: z.object({
|
|
438
|
+
tickers: z.array(z.string()).describe("Stock tickers, e.g. ['AAPL', 'IBM']"),
|
|
439
|
+
timeframe: z.string().optional().describe("Timeframe (default: 1d)")
|
|
440
|
+
}),
|
|
441
|
+
readOnly: true,
|
|
442
|
+
handler: withMetadata(async (input) => {
|
|
443
|
+
const technicalCols = [
|
|
444
|
+
"Recommend.All",
|
|
445
|
+
"Recommend.MA",
|
|
446
|
+
"Recommend.Other",
|
|
447
|
+
"RSI",
|
|
448
|
+
"Stoch.K",
|
|
449
|
+
"Stoch.D",
|
|
450
|
+
"CCI20",
|
|
451
|
+
"ADX",
|
|
452
|
+
"ADX+DI",
|
|
453
|
+
"ADX-DI",
|
|
454
|
+
"AO",
|
|
455
|
+
"Mom",
|
|
456
|
+
"MACD.macd",
|
|
457
|
+
"MACD.signal",
|
|
458
|
+
"BB.lower",
|
|
459
|
+
"BB.upper",
|
|
460
|
+
"EMA20",
|
|
461
|
+
"EMA50",
|
|
462
|
+
"EMA200",
|
|
463
|
+
"SMA20",
|
|
464
|
+
"SMA50",
|
|
465
|
+
"SMA200",
|
|
466
|
+
"Pivot.M.Classic.S1",
|
|
467
|
+
"Pivot.M.Classic.Middle",
|
|
468
|
+
"Pivot.M.Classic.R1"
|
|
469
|
+
];
|
|
470
|
+
const resolvedTickers = input.tickers.map((t) => resolveTicker(t).full);
|
|
471
|
+
const rows = await scanStocks({
|
|
472
|
+
tickers: resolvedTickers,
|
|
473
|
+
columns: technicalCols,
|
|
474
|
+
timeframe: input.timeframe
|
|
475
|
+
});
|
|
476
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
477
|
+
}, metadata3)
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
name: "tradingview_top_gainers",
|
|
481
|
+
description: "Get today's top gaining stocks by percentage change on a given exchange. Defaults to major US exchanges (NYSE, NASDAQ, AMEX) with market cap > $100M. OTC penny stocks excluded by default.",
|
|
482
|
+
inputSchema: z.object({
|
|
483
|
+
exchange: z.string().optional().describe("Exchange (default: all US)"),
|
|
484
|
+
include_otc: z.boolean().optional().describe("Whether to include OTC penny stocks (default: false)"),
|
|
485
|
+
limit: z.number().int().optional().describe("Max results (default 20)")
|
|
486
|
+
}),
|
|
487
|
+
readOnly: true,
|
|
488
|
+
handler: withMetadata(async (input) => {
|
|
489
|
+
const filters = [];
|
|
490
|
+
if (!input.include_otc) {
|
|
491
|
+
filters.push({ left: "market_cap_basic", operation: "greater", right: 1e8 });
|
|
492
|
+
filters.push({ left: "volume", operation: "greater", right: 1e5 });
|
|
493
|
+
}
|
|
494
|
+
const rows = await scanStocks({
|
|
495
|
+
exchange: input.exchange,
|
|
496
|
+
columns: ["close", "change", "change_abs", "volume", "name", "description", "market_cap_basic"],
|
|
497
|
+
filters,
|
|
498
|
+
limit: input.limit ?? 20
|
|
499
|
+
});
|
|
500
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
501
|
+
}, metadata3)
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "tradingview_top_losers",
|
|
505
|
+
description: "Get today's top losing stocks by percentage change on a given exchange. Defaults to major US exchanges (NYSE, NASDAQ, AMEX) with market cap > $100M. OTC penny stocks excluded by default.",
|
|
506
|
+
inputSchema: z.object({
|
|
507
|
+
exchange: z.string().optional().describe("Exchange (default: all US)"),
|
|
508
|
+
include_otc: z.boolean().optional().describe("Whether to include OTC penny stocks (default: false)"),
|
|
509
|
+
limit: z.number().int().optional().describe("Max results (default 20)")
|
|
510
|
+
}),
|
|
511
|
+
readOnly: true,
|
|
512
|
+
handler: withMetadata(async (input) => {
|
|
513
|
+
const filters = [];
|
|
514
|
+
if (!input.include_otc) {
|
|
515
|
+
filters.push({ left: "market_cap_basic", operation: "greater", right: 1e8 });
|
|
516
|
+
filters.push({ left: "volume", operation: "greater", right: 1e5 });
|
|
517
|
+
}
|
|
518
|
+
const rows = await scanStocks({
|
|
519
|
+
exchange: input.exchange,
|
|
520
|
+
columns: ["close", "change", "change_abs", "volume", "name", "description", "market_cap_basic"],
|
|
521
|
+
filters,
|
|
522
|
+
limit: input.limit ?? 20,
|
|
523
|
+
sort: { sortBy: "change", sortOrder: "asc" }
|
|
524
|
+
});
|
|
525
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
526
|
+
}, metadata3)
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: "tradingview_top_volume",
|
|
530
|
+
description: "Get stocks with the highest trading volume today. Defaults to major US exchanges.",
|
|
531
|
+
inputSchema: z.object({
|
|
532
|
+
exchange: z.string().optional().describe("Exchange (default: all US)"),
|
|
533
|
+
include_otc: z.boolean().optional().describe("Whether to include OTC penny stocks (default: false)"),
|
|
534
|
+
limit: z.number().int().optional().describe("Max results (default 20)")
|
|
535
|
+
}),
|
|
536
|
+
readOnly: true,
|
|
537
|
+
handler: withMetadata(async (input) => {
|
|
538
|
+
const filters = [];
|
|
539
|
+
if (!input.include_otc) {
|
|
540
|
+
filters.push({ left: "market_cap_basic", operation: "greater", right: 1e8 });
|
|
541
|
+
}
|
|
542
|
+
const rows = await scanStocks({
|
|
543
|
+
exchange: input.exchange,
|
|
544
|
+
columns: ["volume", "close", "change", "name", "description", "market_cap_basic"],
|
|
545
|
+
filters,
|
|
546
|
+
limit: input.limit ?? 20
|
|
547
|
+
});
|
|
548
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
549
|
+
}, metadata3)
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: "tradingview_market_indices",
|
|
553
|
+
description: "Get real-time values for major market indices: VIX (volatility), S&P 500, NASDAQ Composite, and Dow Jones. Essential for gauging broad market conditions, risk sentiment, and options pricing context.",
|
|
554
|
+
inputSchema: z.object({}),
|
|
555
|
+
readOnly: true,
|
|
556
|
+
handler: withMetadata(async () => {
|
|
557
|
+
const tickers = ["CBOE:VIX", "SP:SPX", "NASDAQ:NDX", "TVC:DJI"];
|
|
558
|
+
const columns = [
|
|
559
|
+
"close",
|
|
560
|
+
"change",
|
|
561
|
+
"change_abs",
|
|
562
|
+
"high",
|
|
563
|
+
"low",
|
|
564
|
+
"open",
|
|
565
|
+
"name",
|
|
566
|
+
"description"
|
|
567
|
+
];
|
|
568
|
+
const rows = await scanStocks({ tickers, columns, market: "global" });
|
|
569
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
570
|
+
}, metadata3)
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
name: "tradingview_sector_performance",
|
|
574
|
+
description: "Get performance of S&P 500 sector ETFs (XLK, XLF, XLE, XLV, XLI, XLP, XLU, XLY, XLC, XLRE, XLB). Shows which sectors are leading or lagging today. Essential for sector rotation analysis.",
|
|
575
|
+
inputSchema: z.object({}),
|
|
576
|
+
readOnly: true,
|
|
577
|
+
handler: withMetadata(async () => {
|
|
578
|
+
const sectorEtfs = [
|
|
579
|
+
"AMEX:XLK",
|
|
580
|
+
"AMEX:XLF",
|
|
581
|
+
"AMEX:XLE",
|
|
582
|
+
"AMEX:XLV",
|
|
583
|
+
"AMEX:XLI",
|
|
584
|
+
"AMEX:XLP",
|
|
585
|
+
"AMEX:XLU",
|
|
586
|
+
"AMEX:XLY",
|
|
587
|
+
"AMEX:XLC",
|
|
588
|
+
"AMEX:XLRE",
|
|
589
|
+
"AMEX:XLB"
|
|
590
|
+
];
|
|
591
|
+
const rows = await scanStocks({
|
|
592
|
+
tickers: sectorEtfs,
|
|
593
|
+
columns: [
|
|
594
|
+
"close",
|
|
595
|
+
"change",
|
|
596
|
+
"change_abs",
|
|
597
|
+
"volume",
|
|
598
|
+
"name",
|
|
599
|
+
"description",
|
|
600
|
+
"Perf.W",
|
|
601
|
+
"Perf.1M",
|
|
602
|
+
"Perf.3M",
|
|
603
|
+
"Perf.YTD"
|
|
604
|
+
]
|
|
605
|
+
});
|
|
606
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
607
|
+
}, metadata3)
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
name: "tradingview_volume_breakout",
|
|
611
|
+
description: "Find stocks with unusual volume (current volume significantly above average). Defaults to major exchanges.",
|
|
612
|
+
inputSchema: z.object({
|
|
613
|
+
exchange: z.string().optional().describe("Exchange filter"),
|
|
614
|
+
limit: z.number().int().optional().describe("Max results (default 20)")
|
|
615
|
+
}),
|
|
616
|
+
readOnly: true,
|
|
617
|
+
handler: withMetadata(async (input) => {
|
|
618
|
+
const rows = await scanStocks({
|
|
619
|
+
exchange: input.exchange,
|
|
620
|
+
columns: ["volume", "relative_volume_10d_calc", "close", "change", "name", "description", "market_cap_basic", "RSI", "MACD.macd"],
|
|
621
|
+
filters: [
|
|
622
|
+
{ left: "relative_volume_10d_calc", operation: "greater", right: 2 },
|
|
623
|
+
{ left: "volume", operation: "greater", right: 1e6 },
|
|
624
|
+
{ left: "market_cap_basic", operation: "greater", right: 1e8 }
|
|
625
|
+
],
|
|
626
|
+
sort: { sortBy: "relative_volume_10d_calc", sortOrder: "desc" },
|
|
627
|
+
limit: input.limit ?? 20
|
|
628
|
+
});
|
|
629
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
630
|
+
}, metadata3)
|
|
631
|
+
}
|
|
632
|
+
]
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/modules/tradingview-crypto/index.ts
|
|
637
|
+
import { z as z2 } from "zod";
|
|
638
|
+
|
|
639
|
+
// src/modules/tradingview-crypto/columns.ts
|
|
640
|
+
var CRYPTO_COLUMNS = [
|
|
641
|
+
"close",
|
|
642
|
+
"change",
|
|
643
|
+
"change_abs",
|
|
644
|
+
"volume",
|
|
645
|
+
"market_cap_calc",
|
|
646
|
+
"Recommend.All",
|
|
647
|
+
"Recommend.MA",
|
|
648
|
+
"Recommend.Other",
|
|
649
|
+
"RSI",
|
|
650
|
+
"MACD.macd",
|
|
651
|
+
"MACD.signal",
|
|
652
|
+
"Stoch.K",
|
|
653
|
+
"Stoch.D",
|
|
654
|
+
"BB.lower",
|
|
655
|
+
"BB.upper",
|
|
656
|
+
"EMA20",
|
|
657
|
+
"EMA50",
|
|
658
|
+
"EMA200",
|
|
659
|
+
"SMA20",
|
|
660
|
+
"SMA50",
|
|
661
|
+
"SMA200",
|
|
662
|
+
"description",
|
|
663
|
+
"name"
|
|
664
|
+
];
|
|
665
|
+
var CRYPTO_TIMEFRAMES = {
|
|
666
|
+
"1m": "|1",
|
|
667
|
+
"5m": "|5",
|
|
668
|
+
"15m": "|15",
|
|
669
|
+
"1h": "|60",
|
|
670
|
+
"4h": "|240",
|
|
671
|
+
"1W": "|1W",
|
|
672
|
+
"1M": "|1M",
|
|
673
|
+
"1d": ""
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// src/modules/tradingview-crypto/scanner.ts
|
|
677
|
+
var API_URL = "https://scanner.tradingview.com/crypto/scan";
|
|
678
|
+
function applyTimeframeSuffix2(columns, timeframe) {
|
|
679
|
+
const suffix = CRYPTO_TIMEFRAMES[timeframe] ?? "";
|
|
680
|
+
if (!suffix) return columns;
|
|
681
|
+
const metaCols = ["name", "description"];
|
|
682
|
+
return columns.map((col) => metaCols.includes(col) ? col : col + suffix);
|
|
683
|
+
}
|
|
684
|
+
async function scanCrypto(request) {
|
|
685
|
+
const rawColumns = request.columns ?? CRYPTO_COLUMNS;
|
|
686
|
+
const timeframe = request.timeframe ?? "1d";
|
|
687
|
+
const columns = applyTimeframeSuffix2(rawColumns, timeframe);
|
|
688
|
+
const body = {
|
|
689
|
+
columns,
|
|
690
|
+
options: { lang: "en" },
|
|
691
|
+
range: [0, request.limit ?? 50],
|
|
692
|
+
sort: { sortBy: columns[0], sortOrder: "desc" }
|
|
693
|
+
};
|
|
694
|
+
if (request.tickers) {
|
|
695
|
+
body.symbols = { tickers: request.tickers };
|
|
696
|
+
} else {
|
|
697
|
+
if (request.filters && request.filters.length > 0) {
|
|
698
|
+
body.filter = request.filters.map((f) => ({
|
|
699
|
+
left: f.left,
|
|
700
|
+
operation: f.operation,
|
|
701
|
+
right: f.right
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const response = await httpPost(API_URL, body);
|
|
706
|
+
return response.data.map((row) => {
|
|
707
|
+
const data = {};
|
|
708
|
+
rawColumns.forEach((col, i) => {
|
|
709
|
+
data[col] = row.d[i] ?? null;
|
|
710
|
+
});
|
|
711
|
+
return { symbol: row.s, data };
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/modules/tradingview-crypto/index.ts
|
|
716
|
+
var MAJOR_EXCHANGES = ["BINANCE", "COINBASE", "KRAKEN", "OKX", "BYBIT", "BITSTAMP"];
|
|
717
|
+
var MAJOR_ONLY_FILTERS = [
|
|
718
|
+
{ left: "exchange", operation: "in_range", right: MAJOR_EXCHANGES },
|
|
719
|
+
{ left: "volume", operation: "greater", right: 1e4 }
|
|
720
|
+
// $10k min volume to filter junk
|
|
721
|
+
];
|
|
722
|
+
function createTradingviewCryptoModule() {
|
|
723
|
+
const metadata3 = { source: "tradingview", dataDelay: "15min" };
|
|
724
|
+
return {
|
|
725
|
+
name: "tradingview-crypto",
|
|
726
|
+
description: "TradingView crypto scanner -- real-time quotes, technical analysis, and screening for cryptocurrency pairs",
|
|
727
|
+
requiredEnvVars: [],
|
|
728
|
+
tools: [
|
|
729
|
+
{
|
|
730
|
+
name: "crypto_scan",
|
|
731
|
+
description: "Scan cryptocurrency pairs using TradingView filters. Returns price, volume, and technical indicators.",
|
|
732
|
+
inputSchema: z2.object({
|
|
733
|
+
filters: z2.array(
|
|
734
|
+
z2.object({
|
|
735
|
+
left: z2.string().describe("Column name (e.g. 'close', 'RSI', 'volume')"),
|
|
736
|
+
operation: z2.string().describe("Comparison: 'greater', 'less', 'in_range', 'equal'"),
|
|
737
|
+
right: z2.union([z2.number(), z2.string(), z2.array(z2.number()), z2.array(z2.string())]).describe("Value to compare against")
|
|
738
|
+
})
|
|
739
|
+
).optional().describe("Filter conditions combined with AND"),
|
|
740
|
+
major_only: z2.boolean().optional().describe("Only include major exchanges (default: true)"),
|
|
741
|
+
columns: z2.array(z2.string()).optional().describe("Columns to return (default: all 23)"),
|
|
742
|
+
timeframe: z2.string().optional().describe("Timeframe: '1m','5m','15m','1h','4h','1d','1W','1M'. Default: '1d'"),
|
|
743
|
+
limit: z2.number().optional().describe("Max results (default: 50, max: 200)")
|
|
744
|
+
}),
|
|
745
|
+
readOnly: true,
|
|
746
|
+
handler: withMetadata(async (params) => {
|
|
747
|
+
const majorOnly = params.major_only ?? true;
|
|
748
|
+
let filters = params.filters || [];
|
|
749
|
+
if (majorOnly) {
|
|
750
|
+
filters = [...MAJOR_ONLY_FILTERS, ...filters];
|
|
751
|
+
}
|
|
752
|
+
const rows = await scanCrypto({
|
|
753
|
+
filters,
|
|
754
|
+
columns: params.columns,
|
|
755
|
+
timeframe: params.timeframe,
|
|
756
|
+
limit: Math.min(params.limit ?? 50, 200)
|
|
757
|
+
});
|
|
758
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
759
|
+
}, metadata3)
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
name: "crypto_quote",
|
|
763
|
+
description: "Get real-time quotes for specific crypto pairs. Supports 'BTCUSDT' (defaults to BINANCE) or 'BINANCE:BTCUSDT'.",
|
|
764
|
+
inputSchema: z2.object({
|
|
765
|
+
symbols: z2.array(z2.string()).describe("Crypto pair symbols (e.g. ['BTCUSDT', 'ETHUSDT'])")
|
|
766
|
+
}),
|
|
767
|
+
readOnly: true,
|
|
768
|
+
handler: withMetadata(async (params) => {
|
|
769
|
+
const resolvedTickers = params.symbols.map((s) => {
|
|
770
|
+
const res = resolveTicker(s, "BINANCE");
|
|
771
|
+
return res.exchange ? `${res.exchange}:${res.ticker}` : `BINANCE:${res.ticker}`;
|
|
772
|
+
});
|
|
773
|
+
const rows = await scanCrypto({
|
|
774
|
+
tickers: resolvedTickers,
|
|
775
|
+
columns: [
|
|
776
|
+
"close",
|
|
777
|
+
"change",
|
|
778
|
+
"change_abs",
|
|
779
|
+
"volume",
|
|
780
|
+
"market_cap_calc",
|
|
781
|
+
"description"
|
|
782
|
+
]
|
|
783
|
+
});
|
|
784
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
785
|
+
}, metadata3)
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
name: "crypto_technicals",
|
|
789
|
+
description: "Get technical analysis for crypto pairs. Supports 'BTCUSDT' or 'BINANCE:BTCUSDT'.",
|
|
790
|
+
inputSchema: z2.object({
|
|
791
|
+
symbols: z2.array(z2.string()).describe("Crypto symbols (e.g. ['BTCUSDT'])"),
|
|
792
|
+
timeframe: z2.string().optional().describe("Timeframe. Default: '1d'")
|
|
793
|
+
}),
|
|
794
|
+
readOnly: true,
|
|
795
|
+
handler: withMetadata(async (params) => {
|
|
796
|
+
const resolvedTickers = params.symbols.map((s) => {
|
|
797
|
+
const res = resolveTicker(s, "BINANCE");
|
|
798
|
+
return res.exchange ? `${res.exchange}:${res.ticker}` : `BINANCE:${res.ticker}`;
|
|
799
|
+
});
|
|
800
|
+
const rows = await scanCrypto({
|
|
801
|
+
tickers: resolvedTickers,
|
|
802
|
+
timeframe: params.timeframe,
|
|
803
|
+
columns: [
|
|
804
|
+
"Recommend.All",
|
|
805
|
+
"Recommend.MA",
|
|
806
|
+
"Recommend.Other",
|
|
807
|
+
"RSI",
|
|
808
|
+
"MACD.macd",
|
|
809
|
+
"MACD.signal",
|
|
810
|
+
"Stoch.K",
|
|
811
|
+
"Stoch.D",
|
|
812
|
+
"EMA20",
|
|
813
|
+
"EMA50",
|
|
814
|
+
"EMA200",
|
|
815
|
+
"SMA20",
|
|
816
|
+
"SMA50",
|
|
817
|
+
"SMA200",
|
|
818
|
+
"BB.lower",
|
|
819
|
+
"BB.upper",
|
|
820
|
+
"close"
|
|
821
|
+
]
|
|
822
|
+
});
|
|
823
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
824
|
+
}, metadata3)
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
name: "crypto_top_gainers",
|
|
828
|
+
description: "Get top gaining cryptocurrency pairs by percentage change. Defaults to major exchanges and volume > $10k.",
|
|
829
|
+
inputSchema: z2.object({
|
|
830
|
+
exchange: z2.string().optional().describe("Specific exchange (e.g. BINANCE)"),
|
|
831
|
+
limit: z2.number().optional().describe("Number of results (default: 20, max: 50)")
|
|
832
|
+
}),
|
|
833
|
+
readOnly: true,
|
|
834
|
+
handler: withMetadata(async (params) => {
|
|
835
|
+
const filters = [{ left: "change", operation: "greater", right: 0 }];
|
|
836
|
+
if (params.exchange) {
|
|
837
|
+
filters.push({ left: "exchange", operation: "equal", right: params.exchange });
|
|
838
|
+
} else {
|
|
839
|
+
filters.push(...MAJOR_ONLY_FILTERS);
|
|
840
|
+
}
|
|
841
|
+
const rows = await scanCrypto({
|
|
842
|
+
filters,
|
|
843
|
+
columns: [
|
|
844
|
+
"close",
|
|
845
|
+
"change",
|
|
846
|
+
"change_abs",
|
|
847
|
+
"volume",
|
|
848
|
+
"market_cap_calc",
|
|
849
|
+
"description"
|
|
850
|
+
],
|
|
851
|
+
limit: Math.min(params.limit ?? 20, 50)
|
|
852
|
+
});
|
|
853
|
+
return successResult(JSON.stringify(rows, null, 2));
|
|
854
|
+
}, metadata3)
|
|
855
|
+
}
|
|
856
|
+
]
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/modules/sec-edgar/index.ts
|
|
861
|
+
import { z as z3 } from "zod";
|
|
862
|
+
|
|
863
|
+
// src/shared/cache.ts
|
|
864
|
+
var TtlCache = class {
|
|
865
|
+
store = /* @__PURE__ */ new Map();
|
|
866
|
+
ttlMs;
|
|
867
|
+
constructor(ttlMs) {
|
|
868
|
+
this.ttlMs = ttlMs;
|
|
869
|
+
}
|
|
870
|
+
get(key) {
|
|
871
|
+
const entry = this.store.get(key);
|
|
872
|
+
if (!entry) return void 0;
|
|
873
|
+
if (Date.now() > entry.expiresAt) {
|
|
874
|
+
this.store.delete(key);
|
|
875
|
+
return void 0;
|
|
876
|
+
}
|
|
877
|
+
return entry.value;
|
|
878
|
+
}
|
|
879
|
+
set(key, value) {
|
|
880
|
+
this.store.set(key, {
|
|
881
|
+
value,
|
|
882
|
+
expiresAt: Date.now() + this.ttlMs
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
async getOrFetch(key, fetcher) {
|
|
886
|
+
const cached = this.get(key);
|
|
887
|
+
if (cached !== void 0) return cached;
|
|
888
|
+
const value = await fetcher();
|
|
889
|
+
this.set(key, value);
|
|
890
|
+
return value;
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
// src/modules/sec-edgar/cik-mapper.ts
|
|
895
|
+
var TICKER_MAP_URL = "https://www.sec.gov/files/company_tickers.json";
|
|
896
|
+
var tickerToCik = null;
|
|
897
|
+
var initPromise = null;
|
|
898
|
+
async function initTickerMap() {
|
|
899
|
+
if (tickerToCik) return;
|
|
900
|
+
if (initPromise) return initPromise;
|
|
901
|
+
initPromise = (async () => {
|
|
902
|
+
try {
|
|
903
|
+
const data = await httpGet(TICKER_MAP_URL, {
|
|
904
|
+
headers: { "User-Agent": SEC_USER_AGENT }
|
|
905
|
+
});
|
|
906
|
+
tickerToCik = /* @__PURE__ */ new Map();
|
|
907
|
+
for (const entry of Object.values(data)) {
|
|
908
|
+
const cik = String(entry.cik_str).padStart(10, "0");
|
|
909
|
+
tickerToCik.set(entry.ticker.toUpperCase(), cik);
|
|
910
|
+
}
|
|
911
|
+
} catch (err) {
|
|
912
|
+
initPromise = null;
|
|
913
|
+
throw err;
|
|
914
|
+
}
|
|
915
|
+
})();
|
|
916
|
+
return initPromise;
|
|
917
|
+
}
|
|
918
|
+
async function getCikForTicker(ticker) {
|
|
919
|
+
await initTickerMap();
|
|
920
|
+
return tickerToCik?.get(ticker.toUpperCase()) ?? null;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/modules/sec-edgar/client.ts
|
|
924
|
+
var EFTS_BASE = "https://efts.sec.gov/LATEST/search-index";
|
|
925
|
+
var DATA_BASE = "https://data.sec.gov/api/xbrl/companyfacts";
|
|
926
|
+
var CACHE_TTL = 5 * 60 * 1e3;
|
|
927
|
+
var cache = new TtlCache(CACHE_TTL);
|
|
928
|
+
async function searchFilings(params) {
|
|
929
|
+
const searchParams = new URLSearchParams();
|
|
930
|
+
searchParams.set("q", params.query);
|
|
931
|
+
if (params.dateRange) searchParams.set("dateRange", params.dateRange);
|
|
932
|
+
if (params.forms?.length)
|
|
933
|
+
searchParams.set("forms", params.forms.join(","));
|
|
934
|
+
if (params.tickers?.length)
|
|
935
|
+
searchParams.set("tickers", params.tickers.join(","));
|
|
936
|
+
searchParams.set("from", "0");
|
|
937
|
+
searchParams.set("size", String(params.limit ?? 20));
|
|
938
|
+
const url = `${EFTS_BASE}?${searchParams.toString()}`;
|
|
939
|
+
const cached = cache.get(url);
|
|
940
|
+
if (cached) return cached;
|
|
941
|
+
const response = await httpGet(url, {
|
|
942
|
+
headers: { "User-Agent": SEC_USER_AGENT }
|
|
943
|
+
});
|
|
944
|
+
const filings = response.hits.hits.map((hit) => {
|
|
945
|
+
const cik = hit._id.replace(/-/g, "").slice(0, 10);
|
|
946
|
+
return {
|
|
947
|
+
accessionNumber: hit._id,
|
|
948
|
+
filedAt: hit._source.file_date,
|
|
949
|
+
formType: hit._source.form_type,
|
|
950
|
+
entityName: hit._source.entity_name,
|
|
951
|
+
ticker: hit._source.tickers ?? "",
|
|
952
|
+
description: hit._source.file_description ?? "",
|
|
953
|
+
documentUrl: `https://www.sec.gov/Archives/edgar/data/${cik}/${hit._id}.txt`
|
|
954
|
+
};
|
|
955
|
+
});
|
|
956
|
+
filings.sort((a, b) => b.filedAt.localeCompare(a.filedAt));
|
|
957
|
+
cache.set(url, filings);
|
|
958
|
+
return filings;
|
|
959
|
+
}
|
|
960
|
+
async function getCompanyFilings(params) {
|
|
961
|
+
const filings = await searchFilings({
|
|
962
|
+
query: params.ticker,
|
|
963
|
+
forms: params.forms,
|
|
964
|
+
limit: params.limit
|
|
965
|
+
});
|
|
966
|
+
const ticker = params.ticker.toUpperCase();
|
|
967
|
+
for (const f of filings) {
|
|
968
|
+
if (!f.ticker) f.ticker = ticker;
|
|
969
|
+
}
|
|
970
|
+
return filings;
|
|
971
|
+
}
|
|
972
|
+
async function getCompanyFacts(ticker) {
|
|
973
|
+
const cik = await getCikForTicker(ticker);
|
|
974
|
+
if (!cik) throw new Error(`Could not find CIK for ticker ${ticker}`);
|
|
975
|
+
const cacheKey = `facts-summary:${cik}`;
|
|
976
|
+
const cached = cache.get(cacheKey);
|
|
977
|
+
if (cached) return cached;
|
|
978
|
+
const url = `${DATA_BASE}/CIK${cik}.json`;
|
|
979
|
+
const data = await httpGet(url, {
|
|
980
|
+
headers: { "User-Agent": SEC_USER_AGENT }
|
|
981
|
+
});
|
|
982
|
+
const usGaap = data.facts["us-gaap"] || {};
|
|
983
|
+
const keyMetricsMap = {
|
|
984
|
+
Revenue: [
|
|
985
|
+
"SalesRevenueNet",
|
|
986
|
+
"Revenues",
|
|
987
|
+
"TotalRevenues",
|
|
988
|
+
"RevenueFromContractWithCustomerExcludingAssessedTax",
|
|
989
|
+
"NetRevenueV2"
|
|
990
|
+
],
|
|
991
|
+
NetIncome: [
|
|
992
|
+
"NetIncomeLoss",
|
|
993
|
+
"NetIncomeLossAvailableToCommonStockholdersBasic",
|
|
994
|
+
"ProfitLoss"
|
|
995
|
+
],
|
|
996
|
+
Assets: ["Assets"],
|
|
997
|
+
Liabilities: ["Liabilities"],
|
|
998
|
+
EPS: ["EarningsPerShareBasic", "EarningsPerShareDiluted"],
|
|
999
|
+
Cash: [
|
|
1000
|
+
"CashAndCashEquivalentsAtCarryingValue",
|
|
1001
|
+
"CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents"
|
|
1002
|
+
]
|
|
1003
|
+
};
|
|
1004
|
+
const summarizedMetrics = {};
|
|
1005
|
+
for (const [label, names] of Object.entries(keyMetricsMap)) {
|
|
1006
|
+
for (const name of names) {
|
|
1007
|
+
if (usGaap[name]) {
|
|
1008
|
+
const units = usGaap[name].units;
|
|
1009
|
+
const unitKey = Object.keys(units)[0];
|
|
1010
|
+
const values = [...units[unitKey]];
|
|
1011
|
+
values.sort((a, b) => b.end.localeCompare(a.end));
|
|
1012
|
+
summarizedMetrics[label] = values[0];
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const result = {
|
|
1018
|
+
ticker: ticker.toUpperCase(),
|
|
1019
|
+
cik,
|
|
1020
|
+
entityName: data.entityName,
|
|
1021
|
+
metrics: summarizedMetrics
|
|
1022
|
+
};
|
|
1023
|
+
cache.set(cacheKey, result);
|
|
1024
|
+
return result;
|
|
1025
|
+
}
|
|
1026
|
+
function mapTransactionCode(code) {
|
|
1027
|
+
switch (code) {
|
|
1028
|
+
case "P":
|
|
1029
|
+
return "BUY";
|
|
1030
|
+
case "S":
|
|
1031
|
+
return "SELL";
|
|
1032
|
+
case "M":
|
|
1033
|
+
return "OPTION_EXERCISE";
|
|
1034
|
+
case "A":
|
|
1035
|
+
return "GRANT";
|
|
1036
|
+
case "F":
|
|
1037
|
+
return "TAX_WITHHOLDING";
|
|
1038
|
+
default:
|
|
1039
|
+
return "OTHER";
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
async function parseForm4Filing(filing) {
|
|
1043
|
+
try {
|
|
1044
|
+
const docContent = await httpGet(filing.documentUrl, {
|
|
1045
|
+
headers: { "User-Agent": SEC_USER_AGENT },
|
|
1046
|
+
responseType: "text"
|
|
1047
|
+
});
|
|
1048
|
+
if (!docContent.includes("<ownershipDocument")) return [];
|
|
1049
|
+
const reporter = docContent.match(/<rptOwnerName>([^<]+)<\/rptOwnerName>/)?.[1] ?? "Unknown";
|
|
1050
|
+
const title = docContent.match(/<rptOwnerOfficerTitle>([^<]+)<\/rptOwnerOfficerTitle>/)?.[1] ?? "N/A";
|
|
1051
|
+
const transactions = [];
|
|
1052
|
+
const transRegex = /<(non)?DerivativeTransaction>([\s\S]*?)<\/(non)?DerivativeTransaction>/g;
|
|
1053
|
+
let match;
|
|
1054
|
+
while ((match = transRegex.exec(docContent)) !== null) {
|
|
1055
|
+
const trans = match[2];
|
|
1056
|
+
const security = trans.match(/<securityTitle>\s*<value>([^<]+)<\/value>/)?.[1] ?? "Unknown";
|
|
1057
|
+
const transDate = trans.match(/<transactionDate>\s*<value>([^<]+)<\/value>/)?.[1] ?? filing.filedAt;
|
|
1058
|
+
const transCode = trans.match(/<transactionCode>([^<]+)<\/transactionCode>/)?.[1] ?? trans.match(/<transactionAcquiredDisposedCode>\s*<value>([^<]+)<\/value>/)?.[1] ?? "";
|
|
1059
|
+
const shares = trans.match(/<transactionShares>\s*<value>([^<]+)<\/value>/)?.[1];
|
|
1060
|
+
const price = trans.match(/<transactionPricePerShare>\s*<value>([^<]+)<\/value>/)?.[1];
|
|
1061
|
+
transactions.push({
|
|
1062
|
+
reporter,
|
|
1063
|
+
title,
|
|
1064
|
+
date: transDate,
|
|
1065
|
+
security,
|
|
1066
|
+
type: mapTransactionCode(transCode),
|
|
1067
|
+
shares: shares ? parseFloat(shares) : 0,
|
|
1068
|
+
price: price ? parseFloat(price) : 0
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (transactions.length === 0) {
|
|
1072
|
+
console.error(`Warning: No transactions parsed for Form 4 ${filing.accessionNumber}`);
|
|
1073
|
+
}
|
|
1074
|
+
return transactions;
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
console.error(`Failed to parse Form 4 ${filing.accessionNumber}:`, err);
|
|
1077
|
+
return [];
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async function getInsiderTrades(ticker, limit = 10) {
|
|
1081
|
+
const cik = await getCikForTicker(ticker);
|
|
1082
|
+
if (!cik) throw new Error(`Could not find CIK for ticker ${ticker}`);
|
|
1083
|
+
const cacheKey = `insider-trades-v2:${cik}:${limit}`;
|
|
1084
|
+
const cached = cache.get(cacheKey);
|
|
1085
|
+
if (cached) return cached;
|
|
1086
|
+
const url = `https://data.sec.gov/submissions/CIK${cik}.json`;
|
|
1087
|
+
const data = await httpGet(url, {
|
|
1088
|
+
headers: { "User-Agent": SEC_USER_AGENT }
|
|
1089
|
+
});
|
|
1090
|
+
const filings = [];
|
|
1091
|
+
const recent = data.filings.recent;
|
|
1092
|
+
const count = recent.form.length;
|
|
1093
|
+
const thirtyDaysAgo = /* @__PURE__ */ new Date();
|
|
1094
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
1095
|
+
const cutoffStr = thirtyDaysAgo.toISOString().split("T")[0];
|
|
1096
|
+
for (let i = 0; i < count && filings.length < limit; i++) {
|
|
1097
|
+
const form = recent.form[i];
|
|
1098
|
+
const filingDate = recent.filingDate[i];
|
|
1099
|
+
if (filingDate < cutoffStr && filings.length > 0) break;
|
|
1100
|
+
if (form === "3" || form === "4" || form === "5") {
|
|
1101
|
+
const accession = recent.accessionNumber[i];
|
|
1102
|
+
const primaryDoc = recent.primaryDocument[i];
|
|
1103
|
+
const accNoDashes = accession.replace(/-/g, "");
|
|
1104
|
+
const rawDoc = primaryDoc.replace(/^xsl[^/]+\//, "");
|
|
1105
|
+
const docUrl = `https://www.sec.gov/Archives/edgar/data/${Number(cik)}/${accNoDashes}/${rawDoc}`;
|
|
1106
|
+
filings.push({
|
|
1107
|
+
accessionNumber: accession,
|
|
1108
|
+
filedAt: filingDate,
|
|
1109
|
+
formType: form,
|
|
1110
|
+
entityName: data.name,
|
|
1111
|
+
ticker: ticker.toUpperCase(),
|
|
1112
|
+
description: recent.primaryDocDescription[i] || "",
|
|
1113
|
+
documentUrl: docUrl
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const form4s = filings.filter((f) => f.formType === "4");
|
|
1118
|
+
const results = await Promise.allSettled(form4s.map((f) => parseForm4Filing(f)));
|
|
1119
|
+
let form4Idx = 0;
|
|
1120
|
+
for (let i = 0; i < filings.length; i++) {
|
|
1121
|
+
if (filings[i].formType === "4") {
|
|
1122
|
+
const result = results[form4Idx++];
|
|
1123
|
+
if (result.status === "fulfilled") {
|
|
1124
|
+
filings[i].parsedTransactions = result.value;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
cache.set(cacheKey, filings);
|
|
1129
|
+
return filings;
|
|
1130
|
+
}
|
|
1131
|
+
async function getInstitutionalHoldings(query, limit = 10) {
|
|
1132
|
+
const filings = await searchFilings({
|
|
1133
|
+
query,
|
|
1134
|
+
forms: ["13F-HR", "13F-HR/A", "13F-NT", "13F-NT/A"],
|
|
1135
|
+
limit
|
|
1136
|
+
});
|
|
1137
|
+
if (/^[A-Za-z]{1,5}$/.test(query.trim())) {
|
|
1138
|
+
const ticker = query.trim().toUpperCase();
|
|
1139
|
+
for (const f of filings) {
|
|
1140
|
+
if (!f.ticker) f.ticker = ticker;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return filings;
|
|
1144
|
+
}
|
|
1145
|
+
async function getOwnershipFilings(ticker, limit = 10) {
|
|
1146
|
+
const filings = await searchFilings({
|
|
1147
|
+
query: ticker,
|
|
1148
|
+
forms: ["SC 13D", "SC 13D/A", "SC 13G", "SC 13G/A"],
|
|
1149
|
+
limit
|
|
1150
|
+
});
|
|
1151
|
+
const upperTicker = ticker.toUpperCase();
|
|
1152
|
+
for (const f of filings) {
|
|
1153
|
+
if (!f.ticker) f.ticker = upperTicker;
|
|
1154
|
+
}
|
|
1155
|
+
return filings;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/modules/sec-edgar/index.ts
|
|
1159
|
+
function createSecEdgarModule() {
|
|
1160
|
+
const metadata3 = { source: "sec-edgar", dataDelay: "real-time" };
|
|
1161
|
+
return {
|
|
1162
|
+
name: "sec-edgar",
|
|
1163
|
+
description: "SEC EDGAR filing search -- full-text search and company filing lookup via EFTS",
|
|
1164
|
+
requiredEnvVars: [],
|
|
1165
|
+
tools: [
|
|
1166
|
+
{
|
|
1167
|
+
name: "edgar_search",
|
|
1168
|
+
description: "Search SEC EDGAR filings by keyword. Best for finding mentions of specific trends, technologies, or events across all companies. Returns metadata including accession numbers, form types, and direct sec.gov links.",
|
|
1169
|
+
inputSchema: z3.object({
|
|
1170
|
+
query: z3.string().describe(
|
|
1171
|
+
"Keyword or phrase to search for (e.g. 'lithium mining', 'share repurchase program')"
|
|
1172
|
+
),
|
|
1173
|
+
dateRange: z3.string().optional().describe("Date range as 'YYYY-MM-DD,YYYY-MM-DD'"),
|
|
1174
|
+
forms: z3.array(z3.string()).optional().describe("Form types to filter (e.g. ['10-K', '10-Q', '8-K'])"),
|
|
1175
|
+
tickers: z3.array(z3.string()).optional().describe("Company tickers to filter (e.g. ['AAPL', 'MSFT'])"),
|
|
1176
|
+
limit: z3.number().optional().describe("Max results (default: 20, max: 50)")
|
|
1177
|
+
}),
|
|
1178
|
+
readOnly: true,
|
|
1179
|
+
handler: withMetadata(async (params) => {
|
|
1180
|
+
const resolvedTickers = params.tickers?.map((t) => resolveTicker(t).ticker);
|
|
1181
|
+
const filings = await searchFilings({
|
|
1182
|
+
query: params.query,
|
|
1183
|
+
dateRange: params.dateRange,
|
|
1184
|
+
forms: params.forms,
|
|
1185
|
+
tickers: resolvedTickers,
|
|
1186
|
+
limit: Math.min(params.limit ?? 20, 50)
|
|
1187
|
+
});
|
|
1188
|
+
return successResult(JSON.stringify(filings, null, 2));
|
|
1189
|
+
}, metadata3)
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
name: "edgar_company_filings",
|
|
1193
|
+
description: "Retrieve the most recent official filings for a specific company. Use this to find a company's latest 10-K (annual), 10-Q (quarterly), or 8-K (current events) reports.",
|
|
1194
|
+
inputSchema: z3.object({
|
|
1195
|
+
ticker: z3.string().describe("Stock ticker symbol (e.g. 'AAPL')"),
|
|
1196
|
+
forms: z3.array(z3.string()).optional().describe("Form types (e.g. ['10-K', '10-Q']). Default: all."),
|
|
1197
|
+
limit: z3.number().optional().describe("Max results (default: 10, max: 50)")
|
|
1198
|
+
}),
|
|
1199
|
+
readOnly: true,
|
|
1200
|
+
handler: withMetadata(async (params) => {
|
|
1201
|
+
const ticker = resolveTicker(params.ticker).ticker;
|
|
1202
|
+
const filings = await getCompanyFilings({
|
|
1203
|
+
ticker,
|
|
1204
|
+
forms: params.forms,
|
|
1205
|
+
limit: Math.min(params.limit ?? 10, 50)
|
|
1206
|
+
});
|
|
1207
|
+
const enriched = filings.map((f) => ({ ...f, ticker }));
|
|
1208
|
+
return successResult(JSON.stringify(enriched, null, 2));
|
|
1209
|
+
}, metadata3)
|
|
1210
|
+
},
|
|
1211
|
+
{
|
|
1212
|
+
name: "edgar_company_facts",
|
|
1213
|
+
description: "Retrieve high-fidelity financial metrics (Revenue, Net Income, EPS, Assets, Liabilities) directly from SEC XBRL data. This is more reliable than extracting numbers from text filings.",
|
|
1214
|
+
inputSchema: z3.object({
|
|
1215
|
+
ticker: z3.string().describe("Stock ticker symbol (e.g. 'AAPL')")
|
|
1216
|
+
}),
|
|
1217
|
+
readOnly: true,
|
|
1218
|
+
handler: withMetadata(async (params) => {
|
|
1219
|
+
const ticker = resolveTicker(params.ticker).ticker;
|
|
1220
|
+
const facts = await getCompanyFacts(ticker);
|
|
1221
|
+
return successResult(JSON.stringify(facts, null, 2));
|
|
1222
|
+
}, metadata3)
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
name: "edgar_insider_trades",
|
|
1226
|
+
description: "Monitor legal stock trades made by company executives and directors (Forms 3, 4, 5). Returns detailed transaction data including insider names, titles, buy/sell type, share amounts, and prices.",
|
|
1227
|
+
inputSchema: z3.object({
|
|
1228
|
+
ticker: z3.string().describe("Stock ticker symbol (e.g. 'AAPL')"),
|
|
1229
|
+
limit: z3.number().optional().describe("Max results (default: 10)")
|
|
1230
|
+
}),
|
|
1231
|
+
readOnly: true,
|
|
1232
|
+
handler: withMetadata(async (params) => {
|
|
1233
|
+
const ticker = resolveTicker(params.ticker).ticker;
|
|
1234
|
+
const trades = await getInsiderTrades(
|
|
1235
|
+
ticker,
|
|
1236
|
+
params.limit
|
|
1237
|
+
);
|
|
1238
|
+
const enriched = trades.map((t) => ({ ...t, ticker }));
|
|
1239
|
+
return successResult(JSON.stringify(enriched, null, 2));
|
|
1240
|
+
}, metadata3)
|
|
1241
|
+
},
|
|
1242
|
+
{
|
|
1243
|
+
name: "edgar_institutional_holdings",
|
|
1244
|
+
description: "Track 'big money' moves by searching Form 13F filings. Use to find what hedge funds and institutional managers (e.g. 'Berkshire Hathaway') are holding or what firms own a specific ticker.",
|
|
1245
|
+
inputSchema: z3.object({
|
|
1246
|
+
query: z3.string().describe("Stock ticker (e.g. 'AAPL') or institutional manager name (e.g. 'Berkshire Hathaway')"),
|
|
1247
|
+
limit: z3.number().optional().describe("Max results (default: 10)")
|
|
1248
|
+
}),
|
|
1249
|
+
readOnly: true,
|
|
1250
|
+
handler: withMetadata(async (params) => {
|
|
1251
|
+
const res = resolveTicker(params.query);
|
|
1252
|
+
const query = res.ticker;
|
|
1253
|
+
const holdings = await getInstitutionalHoldings(
|
|
1254
|
+
query,
|
|
1255
|
+
params.limit
|
|
1256
|
+
);
|
|
1257
|
+
return successResult(JSON.stringify(holdings, null, 2));
|
|
1258
|
+
}, metadata3)
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
name: "edgar_ownership_filings",
|
|
1262
|
+
description: "Monitor significant changes in company ownership (5%+ stakes). Use 13D and 13G filings to identify activist investors (e.g. Carl Icahn, Ryan Cohen) entering or exiting a stock.",
|
|
1263
|
+
inputSchema: z3.object({
|
|
1264
|
+
ticker: z3.string().describe("Stock ticker symbol (e.g. 'AAPL')"),
|
|
1265
|
+
limit: z3.number().optional().describe("Max results (default: 10)")
|
|
1266
|
+
}),
|
|
1267
|
+
readOnly: true,
|
|
1268
|
+
handler: withMetadata(async (params) => {
|
|
1269
|
+
const ticker = resolveTicker(params.ticker).ticker;
|
|
1270
|
+
const filings = await getOwnershipFilings(
|
|
1271
|
+
ticker,
|
|
1272
|
+
params.limit
|
|
1273
|
+
);
|
|
1274
|
+
const enriched = filings.map((f) => ({ ...f, ticker }));
|
|
1275
|
+
return successResult(JSON.stringify(enriched, null, 2));
|
|
1276
|
+
}, metadata3)
|
|
1277
|
+
}
|
|
1278
|
+
]
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// src/modules/coingecko/index.ts
|
|
1283
|
+
import { z as z4 } from "zod";
|
|
1284
|
+
|
|
1285
|
+
// src/modules/coingecko/client.ts
|
|
1286
|
+
var BASE_URL2 = "https://api.coingecko.com/api/v3";
|
|
1287
|
+
var CACHE_TTL2 = 60 * 1e3;
|
|
1288
|
+
var cache2 = new TtlCache(CACHE_TTL2);
|
|
1289
|
+
async function getCoinDetail(coinId) {
|
|
1290
|
+
const cacheKey = `coin:${coinId}`;
|
|
1291
|
+
const cached = cache2.get(cacheKey);
|
|
1292
|
+
if (cached) return cached;
|
|
1293
|
+
const data = await httpGet(`${BASE_URL2}/coins/${coinId}?localization=false&tickers=false&market_data=true&community_data=false&developer_data=false`);
|
|
1294
|
+
const coin = {
|
|
1295
|
+
id: data.id,
|
|
1296
|
+
symbol: data.symbol,
|
|
1297
|
+
name: data.name,
|
|
1298
|
+
currentPrice: data.market_data.current_price.usd,
|
|
1299
|
+
marketCap: data.market_data.market_cap.usd,
|
|
1300
|
+
priceChange24h: data.market_data.price_change_24h,
|
|
1301
|
+
priceChangePercent24h: data.market_data.price_change_percentage_24h,
|
|
1302
|
+
totalVolume: data.market_data.total_volume.usd,
|
|
1303
|
+
high24h: data.market_data.high_24h.usd,
|
|
1304
|
+
low24h: data.market_data.low_24h.usd,
|
|
1305
|
+
ath: data.market_data.ath.usd,
|
|
1306
|
+
athChangePercent: data.market_data.ath_change_percentage.usd,
|
|
1307
|
+
description: (data.description.en || "").slice(0, 500)
|
|
1308
|
+
};
|
|
1309
|
+
cache2.set(cacheKey, coin);
|
|
1310
|
+
return coin;
|
|
1311
|
+
}
|
|
1312
|
+
async function getTrending() {
|
|
1313
|
+
const cacheKey = "trending";
|
|
1314
|
+
const cached = cache2.get(cacheKey);
|
|
1315
|
+
if (cached) return cached;
|
|
1316
|
+
const data = await httpGet(`${BASE_URL2}/search/trending`);
|
|
1317
|
+
const trending = data.coins.map((c) => ({
|
|
1318
|
+
id: c.item.id,
|
|
1319
|
+
name: c.item.name,
|
|
1320
|
+
symbol: c.item.symbol,
|
|
1321
|
+
marketCapRank: c.item.market_cap_rank,
|
|
1322
|
+
priceBtc: c.item.price_btc,
|
|
1323
|
+
score: c.item.score
|
|
1324
|
+
}));
|
|
1325
|
+
cache2.set(cacheKey, trending);
|
|
1326
|
+
return trending;
|
|
1327
|
+
}
|
|
1328
|
+
async function getGlobal() {
|
|
1329
|
+
const cacheKey = "global";
|
|
1330
|
+
const cached = cache2.get(cacheKey);
|
|
1331
|
+
if (cached) return cached;
|
|
1332
|
+
const data = await httpGet(`${BASE_URL2}/global`);
|
|
1333
|
+
const global = {
|
|
1334
|
+
totalMarketCap: data.data.total_market_cap.usd,
|
|
1335
|
+
totalVolume24h: data.data.total_volume.usd,
|
|
1336
|
+
btcDominance: data.data.market_cap_percentage.btc,
|
|
1337
|
+
ethDominance: data.data.market_cap_percentage.eth,
|
|
1338
|
+
activeCryptocurrencies: data.data.active_cryptocurrencies,
|
|
1339
|
+
marketCapChangePercent24h: data.data.market_cap_change_percentage_24h_usd
|
|
1340
|
+
};
|
|
1341
|
+
cache2.set(cacheKey, global);
|
|
1342
|
+
return global;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// src/modules/coingecko/index.ts
|
|
1346
|
+
var coinTool = {
|
|
1347
|
+
name: "coingecko_coin",
|
|
1348
|
+
description: "Get detailed cryptocurrency info from CoinGecko. Use slug IDs (e.g. 'bitcoin', 'ethereum', 'solana'), NOT ticker symbols.",
|
|
1349
|
+
inputSchema: z4.object({
|
|
1350
|
+
coinId: z4.string().describe("CoinGecko coin ID / slug (e.g. 'bitcoin', 'ethereum', 'cardano')")
|
|
1351
|
+
}),
|
|
1352
|
+
readOnly: true,
|
|
1353
|
+
handler: withMetadata(async (params) => {
|
|
1354
|
+
const coin = await getCoinDetail(params.coinId);
|
|
1355
|
+
return successResult(JSON.stringify(coin, null, 2));
|
|
1356
|
+
}, { source: "coingecko", dataDelay: "real-time" })
|
|
1357
|
+
};
|
|
1358
|
+
var trendingTool = {
|
|
1359
|
+
name: "coingecko_trending",
|
|
1360
|
+
description: "Get trending cryptocurrencies on CoinGecko (top 7 by search popularity in last 24h).",
|
|
1361
|
+
inputSchema: z4.object({}),
|
|
1362
|
+
readOnly: true,
|
|
1363
|
+
handler: withMetadata(async () => {
|
|
1364
|
+
const trending = await getTrending();
|
|
1365
|
+
return successResult(JSON.stringify(trending, null, 2));
|
|
1366
|
+
}, { source: "coingecko", dataDelay: "real-time" })
|
|
1367
|
+
};
|
|
1368
|
+
var globalTool = {
|
|
1369
|
+
name: "coingecko_global",
|
|
1370
|
+
description: "Get global cryptocurrency market statistics: total market cap, 24h volume, BTC/ETH dominance.",
|
|
1371
|
+
inputSchema: z4.object({}),
|
|
1372
|
+
readOnly: true,
|
|
1373
|
+
handler: withMetadata(async () => {
|
|
1374
|
+
const global = await getGlobal();
|
|
1375
|
+
return successResult(JSON.stringify(global, null, 2));
|
|
1376
|
+
}, { source: "coingecko", dataDelay: "real-time" })
|
|
1377
|
+
};
|
|
1378
|
+
function createCoingeckoModule() {
|
|
1379
|
+
return {
|
|
1380
|
+
name: "coingecko",
|
|
1381
|
+
description: "CoinGecko crypto data -- coin details, trending coins, and global market stats",
|
|
1382
|
+
requiredEnvVars: [],
|
|
1383
|
+
tools: [coinTool, trendingTool, globalTool]
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// src/modules/finnhub/index.ts
|
|
1388
|
+
import { z as z5 } from "zod";
|
|
1389
|
+
|
|
1390
|
+
// src/modules/finnhub/client.ts
|
|
1391
|
+
var BASE_URL3 = "https://finnhub.io/api/v1";
|
|
1392
|
+
var CACHE_TTL3 = 5 * 60 * 1e3;
|
|
1393
|
+
var cache3 = new TtlCache(CACHE_TTL3);
|
|
1394
|
+
async function getMarketNews(apiKey, category = "general", limit = 20) {
|
|
1395
|
+
const cacheKey = `news:${category}:${limit}`;
|
|
1396
|
+
const cached = cache3.get(cacheKey);
|
|
1397
|
+
if (cached) return cached;
|
|
1398
|
+
const articles = await httpGet(`${BASE_URL3}/news?category=${encodeURIComponent(category)}`, {
|
|
1399
|
+
headers: { "X-Finnhub-Token": apiKey }
|
|
1400
|
+
});
|
|
1401
|
+
const result = articles.slice(0, limit).map((a) => ({
|
|
1402
|
+
category: a.category,
|
|
1403
|
+
datetime: a.datetime,
|
|
1404
|
+
headline: a.headline,
|
|
1405
|
+
id: a.id,
|
|
1406
|
+
source: a.source,
|
|
1407
|
+
summary: a.summary.slice(0, 300),
|
|
1408
|
+
url: a.url,
|
|
1409
|
+
related: a.related
|
|
1410
|
+
}));
|
|
1411
|
+
cache3.set(cacheKey, result);
|
|
1412
|
+
return result;
|
|
1413
|
+
}
|
|
1414
|
+
async function getCompanyNews(apiKey, symbol, from, to, limit = 20) {
|
|
1415
|
+
const cacheKey = `company-news:${symbol}:${from}:${to}:${limit}`;
|
|
1416
|
+
const cached = cache3.get(cacheKey);
|
|
1417
|
+
if (cached) return cached;
|
|
1418
|
+
const articles = await httpGet(
|
|
1419
|
+
`${BASE_URL3}/company-news?symbol=${encodeURIComponent(symbol)}&from=${from}&to=${to}`,
|
|
1420
|
+
{ headers: { "X-Finnhub-Token": apiKey } }
|
|
1421
|
+
);
|
|
1422
|
+
const result = articles.slice(0, limit).map((a) => ({
|
|
1423
|
+
category: a.category,
|
|
1424
|
+
datetime: a.datetime,
|
|
1425
|
+
headline: a.headline,
|
|
1426
|
+
source: a.source,
|
|
1427
|
+
summary: a.summary.slice(0, 300),
|
|
1428
|
+
url: a.url
|
|
1429
|
+
}));
|
|
1430
|
+
cache3.set(cacheKey, result);
|
|
1431
|
+
return result;
|
|
1432
|
+
}
|
|
1433
|
+
async function getEarningsCalendar(apiKey, from, to, symbol) {
|
|
1434
|
+
const cacheKey = `earnings:${from}:${to}:${symbol ?? "all"}`;
|
|
1435
|
+
const cached = cache3.get(cacheKey);
|
|
1436
|
+
if (cached) return cached;
|
|
1437
|
+
let url = `${BASE_URL3}/calendar/earnings?from=${from}&to=${to}`;
|
|
1438
|
+
if (symbol) {
|
|
1439
|
+
url += `&symbol=${encodeURIComponent(symbol)}`;
|
|
1440
|
+
}
|
|
1441
|
+
const data = await httpGet(
|
|
1442
|
+
url,
|
|
1443
|
+
{ headers: { "X-Finnhub-Token": apiKey } }
|
|
1444
|
+
);
|
|
1445
|
+
const result = data.earningsCalendar;
|
|
1446
|
+
cache3.set(cacheKey, result);
|
|
1447
|
+
return result;
|
|
1448
|
+
}
|
|
1449
|
+
async function getAnalystRecommendations(apiKey, symbol) {
|
|
1450
|
+
const cacheKey = `recommendations:${symbol}`;
|
|
1451
|
+
const cached = cache3.get(cacheKey);
|
|
1452
|
+
if (cached) return cached;
|
|
1453
|
+
const data = await httpGet(
|
|
1454
|
+
`${BASE_URL3}/stock/recommendation?symbol=${encodeURIComponent(symbol)}`,
|
|
1455
|
+
{ headers: { "X-Finnhub-Token": apiKey } }
|
|
1456
|
+
);
|
|
1457
|
+
cache3.set(cacheKey, data);
|
|
1458
|
+
return data;
|
|
1459
|
+
}
|
|
1460
|
+
async function getCompanyProfile(apiKey, symbol) {
|
|
1461
|
+
const cacheKey = `profile:${symbol}`;
|
|
1462
|
+
const cached = cache3.get(cacheKey);
|
|
1463
|
+
if (cached) return cached;
|
|
1464
|
+
const data = await httpGet(
|
|
1465
|
+
`${BASE_URL3}/stock/profile2?symbol=${encodeURIComponent(symbol)}`,
|
|
1466
|
+
{ headers: { "X-Finnhub-Token": apiKey } }
|
|
1467
|
+
);
|
|
1468
|
+
cache3.set(cacheKey, data);
|
|
1469
|
+
return data;
|
|
1470
|
+
}
|
|
1471
|
+
async function getPeers(apiKey, symbol) {
|
|
1472
|
+
const cacheKey = `peers:${symbol}`;
|
|
1473
|
+
const cached = cache3.get(cacheKey);
|
|
1474
|
+
if (cached) return cached;
|
|
1475
|
+
const data = await httpGet(
|
|
1476
|
+
`${BASE_URL3}/stock/peers?symbol=${encodeURIComponent(symbol)}`,
|
|
1477
|
+
{ headers: { "X-Finnhub-Token": apiKey } }
|
|
1478
|
+
);
|
|
1479
|
+
cache3.set(cacheKey, data);
|
|
1480
|
+
return data;
|
|
1481
|
+
}
|
|
1482
|
+
async function getMarketStatus(apiKey, exchange) {
|
|
1483
|
+
const cacheKey = `market-status:${exchange}`;
|
|
1484
|
+
const cached = cache3.get(cacheKey);
|
|
1485
|
+
if (cached) return cached;
|
|
1486
|
+
const data = await httpGet(
|
|
1487
|
+
`${BASE_URL3}/stock/market-status?exchange=${encodeURIComponent(exchange)}`,
|
|
1488
|
+
{ headers: { "X-Finnhub-Token": apiKey } }
|
|
1489
|
+
);
|
|
1490
|
+
cache3.set(cacheKey, data);
|
|
1491
|
+
return data;
|
|
1492
|
+
}
|
|
1493
|
+
async function getQuote(apiKey, symbol) {
|
|
1494
|
+
const cacheKey = `quote:${symbol}`;
|
|
1495
|
+
const cached = cache3.get(cacheKey);
|
|
1496
|
+
if (cached) return cached;
|
|
1497
|
+
const data = await httpGet(
|
|
1498
|
+
`${BASE_URL3}/quote?symbol=${encodeURIComponent(symbol)}`,
|
|
1499
|
+
{ headers: { "X-Finnhub-Token": apiKey } }
|
|
1500
|
+
);
|
|
1501
|
+
cache3.set(cacheKey, data);
|
|
1502
|
+
return data;
|
|
1503
|
+
}
|
|
1504
|
+
async function getShortInterest(apiKey, symbol) {
|
|
1505
|
+
const cacheKey = `short-interest:${symbol}`;
|
|
1506
|
+
const cached = cache3.get(cacheKey);
|
|
1507
|
+
if (cached) return cached;
|
|
1508
|
+
const data = await httpGet(
|
|
1509
|
+
`${BASE_URL3}/stock/metric?symbol=${encodeURIComponent(symbol)}&metric=all`,
|
|
1510
|
+
{ headers: { "X-Finnhub-Token": apiKey } }
|
|
1511
|
+
);
|
|
1512
|
+
const result = {
|
|
1513
|
+
symbol: data.symbol,
|
|
1514
|
+
metric: {
|
|
1515
|
+
"52WeekHigh": data.metric?.["52WeekHigh"],
|
|
1516
|
+
"52WeekLow": data.metric?.["52WeekLow"],
|
|
1517
|
+
"shortInterest": data.metric?.["shortInterest"],
|
|
1518
|
+
"shortRatio": data.metric?.["shortRatio"],
|
|
1519
|
+
"shortPercentOfFloat": data.metric?.["shortPercentOfFloat"]
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
cache3.set(cacheKey, result);
|
|
1523
|
+
return result;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// src/modules/finnhub/index.ts
|
|
1527
|
+
function createFinnhubModule(apiKey) {
|
|
1528
|
+
const metadata3 = { source: "finnhub", dataDelay: "real-time" };
|
|
1529
|
+
const marketNewsTool = {
|
|
1530
|
+
name: "finnhub_market_news",
|
|
1531
|
+
description: "Get latest market news by category (general, forex, crypto, merger). Rate limit: 60 calls/min (free tier).",
|
|
1532
|
+
inputSchema: z5.object({
|
|
1533
|
+
category: z5.string().optional().describe("Category: general, forex, crypto, merger"),
|
|
1534
|
+
limit: z5.number().optional().describe("Max results (default: 20, max: 50)")
|
|
1535
|
+
}),
|
|
1536
|
+
readOnly: true,
|
|
1537
|
+
handler: withMetadata(async (params) => {
|
|
1538
|
+
const news = await getMarketNews(
|
|
1539
|
+
apiKey,
|
|
1540
|
+
params.category,
|
|
1541
|
+
Math.min(params.limit ?? 20, 50)
|
|
1542
|
+
);
|
|
1543
|
+
return successResult(JSON.stringify(news, null, 2));
|
|
1544
|
+
}, metadata3)
|
|
1545
|
+
};
|
|
1546
|
+
const companyNewsTool = {
|
|
1547
|
+
name: "finnhub_company_news",
|
|
1548
|
+
description: "Get recent news for a specific company by ticker symbol. Rate limit: 60 calls/min (free tier).",
|
|
1549
|
+
inputSchema: z5.object({
|
|
1550
|
+
symbol: z5.string().describe("Stock symbol (e.g. 'AAPL')"),
|
|
1551
|
+
from: z5.string().describe("From date (YYYY-MM-DD)"),
|
|
1552
|
+
to: z5.string().describe("To date (YYYY-MM-DD)"),
|
|
1553
|
+
limit: z5.number().optional().describe("Max results (default: 20, max: 50)")
|
|
1554
|
+
}),
|
|
1555
|
+
readOnly: true,
|
|
1556
|
+
handler: withMetadata(async (params) => {
|
|
1557
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1558
|
+
const news = await getCompanyNews(
|
|
1559
|
+
apiKey,
|
|
1560
|
+
symbol,
|
|
1561
|
+
params.from,
|
|
1562
|
+
params.to,
|
|
1563
|
+
Math.min(params.limit ?? 20, 50)
|
|
1564
|
+
);
|
|
1565
|
+
return successResult(JSON.stringify(news, null, 2));
|
|
1566
|
+
}, metadata3)
|
|
1567
|
+
};
|
|
1568
|
+
const earningsCalendarTool = {
|
|
1569
|
+
name: "finnhub_earnings_calendar",
|
|
1570
|
+
description: "Get upcoming or historical earnings reports within a date range. Rate limit: 60 calls/min (free tier).",
|
|
1571
|
+
inputSchema: z5.object({
|
|
1572
|
+
from: z5.string().describe("Start date (YYYY-MM-DD)"),
|
|
1573
|
+
to: z5.string().describe("End date (YYYY-MM-DD)"),
|
|
1574
|
+
symbol: z5.string().optional().describe("Filter by specific symbol"),
|
|
1575
|
+
limit: z5.number().optional().describe("Max results to return (default: 20, max: 100)")
|
|
1576
|
+
}),
|
|
1577
|
+
readOnly: true,
|
|
1578
|
+
handler: withMetadata(async (params) => {
|
|
1579
|
+
const symbol = params.symbol ? resolveTicker(params.symbol).ticker : void 0;
|
|
1580
|
+
const results = await getEarningsCalendar(
|
|
1581
|
+
apiKey,
|
|
1582
|
+
params.from,
|
|
1583
|
+
params.to,
|
|
1584
|
+
symbol
|
|
1585
|
+
);
|
|
1586
|
+
const limit = Math.min(params.limit ?? 20, 100);
|
|
1587
|
+
const capped = results.slice(0, limit);
|
|
1588
|
+
return successResult(JSON.stringify(capped, null, 2));
|
|
1589
|
+
}, metadata3)
|
|
1590
|
+
};
|
|
1591
|
+
const analystRatingsTool = {
|
|
1592
|
+
name: "finnhub_analyst_ratings",
|
|
1593
|
+
description: "Get analyst consensus recommendations for a stock. Returns counts of Strong Buy, Buy, Hold, Sell, Strong Sell ratings and the last 4 months of rating history. Rate limit: 60 calls/min (free tier).",
|
|
1594
|
+
inputSchema: z5.object({
|
|
1595
|
+
symbol: z5.string().describe("Stock symbol (e.g. 'AAPL')")
|
|
1596
|
+
}),
|
|
1597
|
+
readOnly: true,
|
|
1598
|
+
handler: withMetadata(async (params) => {
|
|
1599
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1600
|
+
const recs = await getAnalystRecommendations(apiKey, symbol);
|
|
1601
|
+
return successResult(JSON.stringify({
|
|
1602
|
+
symbol,
|
|
1603
|
+
currentConsensus: recs[0] || null,
|
|
1604
|
+
recommendationHistory: recs.slice(0, 4)
|
|
1605
|
+
}, null, 2));
|
|
1606
|
+
}, metadata3)
|
|
1607
|
+
};
|
|
1608
|
+
const companyProfileTool = {
|
|
1609
|
+
name: "finnhub_company_profile",
|
|
1610
|
+
description: "Get company profile: name, industry, market cap, IPO date, logo, website, share count, and exchange. Rate limit: 60 calls/min (free tier).",
|
|
1611
|
+
inputSchema: z5.object({
|
|
1612
|
+
symbol: z5.string().describe("Stock symbol (e.g. 'AAPL')")
|
|
1613
|
+
}),
|
|
1614
|
+
readOnly: true,
|
|
1615
|
+
handler: withMetadata(async (params) => {
|
|
1616
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1617
|
+
const profile = await getCompanyProfile(apiKey, symbol);
|
|
1618
|
+
return successResult(JSON.stringify(profile, null, 2));
|
|
1619
|
+
}, metadata3)
|
|
1620
|
+
};
|
|
1621
|
+
const peersTool = {
|
|
1622
|
+
name: "finnhub_peers",
|
|
1623
|
+
description: "Get a list of peer/comparable companies in the same industry for a given stock. Rate limit: 60 calls/min (free tier).",
|
|
1624
|
+
inputSchema: z5.object({
|
|
1625
|
+
symbol: z5.string().describe("Stock symbol (e.g. 'AAPL')")
|
|
1626
|
+
}),
|
|
1627
|
+
readOnly: true,
|
|
1628
|
+
handler: withMetadata(async (params) => {
|
|
1629
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1630
|
+
const peers = await getPeers(apiKey, symbol);
|
|
1631
|
+
return successResult(JSON.stringify({ symbol, peers }, null, 2));
|
|
1632
|
+
}, metadata3)
|
|
1633
|
+
};
|
|
1634
|
+
const marketStatusTool = {
|
|
1635
|
+
name: "finnhub_market_status",
|
|
1636
|
+
description: "Check if a stock exchange is currently open, and what session it is in (pre-market, regular, post-market). Rate limit: 60 calls/min (free tier).",
|
|
1637
|
+
inputSchema: z5.object({
|
|
1638
|
+
exchange: z5.string().default("US").describe("Exchange code. Examples: US, L (London), T (Tokyo), HK")
|
|
1639
|
+
}),
|
|
1640
|
+
readOnly: true,
|
|
1641
|
+
handler: withMetadata(async (params) => {
|
|
1642
|
+
const exchange = params.exchange;
|
|
1643
|
+
const status = await getMarketStatus(apiKey, exchange);
|
|
1644
|
+
return successResult(JSON.stringify(status, null, 2));
|
|
1645
|
+
}, metadata3)
|
|
1646
|
+
};
|
|
1647
|
+
const quoteTool = {
|
|
1648
|
+
name: "finnhub_quote",
|
|
1649
|
+
description: "Get a real-time stock quote from Finnhub (requires API key): current price, change, percent change, day high/low, open, and previous close. Preferred over tradingview_quote during market hours for live prices. Use tradingview_quote as a keyless fallback when Finnhub is unavailable. Rate limit: 60 calls/min (free tier).",
|
|
1650
|
+
inputSchema: z5.object({
|
|
1651
|
+
symbol: z5.string().describe("Stock symbol (e.g. 'AAPL')")
|
|
1652
|
+
}),
|
|
1653
|
+
readOnly: true,
|
|
1654
|
+
handler: withMetadata(async (params) => {
|
|
1655
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1656
|
+
const q = await getQuote(apiKey, symbol);
|
|
1657
|
+
return successResult(JSON.stringify({
|
|
1658
|
+
symbol,
|
|
1659
|
+
price: q.c,
|
|
1660
|
+
change: q.d,
|
|
1661
|
+
changePercent: q.dp,
|
|
1662
|
+
dayHigh: q.h,
|
|
1663
|
+
dayLow: q.l,
|
|
1664
|
+
open: q.o,
|
|
1665
|
+
previousClose: q.pc
|
|
1666
|
+
}, null, 2));
|
|
1667
|
+
}, metadata3)
|
|
1668
|
+
};
|
|
1669
|
+
const shortInterestTool = {
|
|
1670
|
+
name: "finnhub_short_interest",
|
|
1671
|
+
description: "Get short interest and other key financial metrics for a stock. Rate limit: 60 calls/min (free tier).",
|
|
1672
|
+
inputSchema: z5.object({
|
|
1673
|
+
symbol: z5.string().describe("Stock symbol (e.g. 'AAPL')")
|
|
1674
|
+
}),
|
|
1675
|
+
readOnly: true,
|
|
1676
|
+
handler: withMetadata(async (params) => {
|
|
1677
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1678
|
+
const metrics = await getShortInterest(apiKey, symbol);
|
|
1679
|
+
return successResult(JSON.stringify(metrics, null, 2));
|
|
1680
|
+
}, metadata3)
|
|
1681
|
+
};
|
|
1682
|
+
return {
|
|
1683
|
+
name: "finnhub",
|
|
1684
|
+
description: "Finnhub market data: quotes, company profiles, peers, news, earnings, analyst ratings, short interest, and market status",
|
|
1685
|
+
requiredEnvVars: ["FINNHUB_API_KEY"],
|
|
1686
|
+
tools: [
|
|
1687
|
+
quoteTool,
|
|
1688
|
+
companyProfileTool,
|
|
1689
|
+
peersTool,
|
|
1690
|
+
marketStatusTool,
|
|
1691
|
+
marketNewsTool,
|
|
1692
|
+
companyNewsTool,
|
|
1693
|
+
earningsCalendarTool,
|
|
1694
|
+
analystRatingsTool,
|
|
1695
|
+
shortInterestTool
|
|
1696
|
+
]
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// src/modules/alpha-vantage/index.ts
|
|
1701
|
+
import { z as z6 } from "zod";
|
|
1702
|
+
|
|
1703
|
+
// src/modules/alpha-vantage/client.ts
|
|
1704
|
+
var BASE_URL4 = "https://www.alphavantage.co/query";
|
|
1705
|
+
var CACHE_TTL4 = 60 * 1e3;
|
|
1706
|
+
var cache4 = new TtlCache(CACHE_TTL4);
|
|
1707
|
+
function checkAvResponse(data) {
|
|
1708
|
+
if (!data) {
|
|
1709
|
+
throw new Error("Alpha Vantage API returned empty response");
|
|
1710
|
+
}
|
|
1711
|
+
if (data["Note"]) {
|
|
1712
|
+
throw new Error(`Alpha Vantage Rate Limit: ${data["Note"]}`);
|
|
1713
|
+
}
|
|
1714
|
+
if (data["Information"]) {
|
|
1715
|
+
throw new Error(`Alpha Vantage Info: ${data["Information"]}`);
|
|
1716
|
+
}
|
|
1717
|
+
if (data["Error Message"]) {
|
|
1718
|
+
throw new Error(`Alpha Vantage Error: ${data["Error Message"]}`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
async function getQuote2(apiKey, symbol) {
|
|
1722
|
+
const cacheKey = `quote:${symbol}`;
|
|
1723
|
+
const cached = cache4.get(cacheKey);
|
|
1724
|
+
if (cached) return cached;
|
|
1725
|
+
const data = await httpGet(`${BASE_URL4}?function=GLOBAL_QUOTE&symbol=${encodeURIComponent(symbol)}&apikey=${apiKey}`);
|
|
1726
|
+
checkAvResponse(data);
|
|
1727
|
+
const gq = data["Global Quote"];
|
|
1728
|
+
if (!gq || !gq["01. symbol"]) {
|
|
1729
|
+
throw new Error(`Stock quote not found for ${symbol}`);
|
|
1730
|
+
}
|
|
1731
|
+
const quote = {
|
|
1732
|
+
symbol: gq["01. symbol"],
|
|
1733
|
+
open: parseFloat(gq["02. open"]),
|
|
1734
|
+
high: parseFloat(gq["03. high"]),
|
|
1735
|
+
low: parseFloat(gq["04. low"]),
|
|
1736
|
+
price: parseFloat(gq["05. price"]),
|
|
1737
|
+
volume: parseInt(gq["06. volume"], 10),
|
|
1738
|
+
latestTradingDay: gq["07. latest trading day"],
|
|
1739
|
+
previousClose: parseFloat(gq["08. previous close"]),
|
|
1740
|
+
change: parseFloat(gq["09. change"]),
|
|
1741
|
+
changePercent: gq["10. change percent"]
|
|
1742
|
+
};
|
|
1743
|
+
cache4.set(cacheKey, quote);
|
|
1744
|
+
return quote;
|
|
1745
|
+
}
|
|
1746
|
+
async function getDailyPrices(apiKey, symbol, limit = 30) {
|
|
1747
|
+
const cacheKey = `daily:${symbol}:${limit}`;
|
|
1748
|
+
const cached = cache4.get(cacheKey);
|
|
1749
|
+
if (cached) return cached;
|
|
1750
|
+
const data = await httpGet(`${BASE_URL4}?function=TIME_SERIES_DAILY&symbol=${encodeURIComponent(symbol)}&outputsize=compact&apikey=${apiKey}`);
|
|
1751
|
+
checkAvResponse(data);
|
|
1752
|
+
const timeSeries = data["Time Series (Daily)"];
|
|
1753
|
+
if (!timeSeries) {
|
|
1754
|
+
throw new Error(`Daily prices not found for ${symbol}`);
|
|
1755
|
+
}
|
|
1756
|
+
const prices = Object.entries(timeSeries).slice(0, limit).map(([date, values]) => ({
|
|
1757
|
+
date,
|
|
1758
|
+
open: parseFloat(values["1. open"]),
|
|
1759
|
+
high: parseFloat(values["2. high"]),
|
|
1760
|
+
low: parseFloat(values["3. low"]),
|
|
1761
|
+
close: parseFloat(values["4. close"]),
|
|
1762
|
+
volume: parseInt(values["5. volume"], 10)
|
|
1763
|
+
}));
|
|
1764
|
+
cache4.set(cacheKey, prices);
|
|
1765
|
+
return prices;
|
|
1766
|
+
}
|
|
1767
|
+
async function getOverview(apiKey, symbol) {
|
|
1768
|
+
const cacheKey = `overview:${symbol}`;
|
|
1769
|
+
const cached = cache4.get(cacheKey);
|
|
1770
|
+
if (cached) return cached;
|
|
1771
|
+
const data = await httpGet(
|
|
1772
|
+
`${BASE_URL4}?function=OVERVIEW&symbol=${encodeURIComponent(symbol)}&apikey=${apiKey}`
|
|
1773
|
+
);
|
|
1774
|
+
checkAvResponse(data);
|
|
1775
|
+
if (!data || !data.Symbol) {
|
|
1776
|
+
throw new Error(`Company overview not found for ${symbol}`);
|
|
1777
|
+
}
|
|
1778
|
+
const mcap = data.MarketCapitalization;
|
|
1779
|
+
if (!data.Name || !mcap || mcap === "None" || mcap === "-" || mcap === "0") {
|
|
1780
|
+
throw new Error(`Alpha Vantage Rate Limit: empty overview for ${symbol}`);
|
|
1781
|
+
}
|
|
1782
|
+
const overview = {
|
|
1783
|
+
symbol: data.Symbol,
|
|
1784
|
+
name: data.Name,
|
|
1785
|
+
description: (data.Description ?? "").slice(0, 500),
|
|
1786
|
+
exchange: data.Exchange,
|
|
1787
|
+
sector: data.Sector,
|
|
1788
|
+
industry: data.Industry,
|
|
1789
|
+
marketCap: parseFloat(data.MarketCapitalization) || 0,
|
|
1790
|
+
peRatio: parseFloat(data.PERatio) || 0,
|
|
1791
|
+
pegRatio: parseFloat(data.PEGRatio) || 0,
|
|
1792
|
+
bookValue: parseFloat(data.BookValue) || 0,
|
|
1793
|
+
dividendYield: parseFloat(data.DividendYield) || 0,
|
|
1794
|
+
eps: parseFloat(data.EPS) || 0,
|
|
1795
|
+
revenuePerShare: parseFloat(data.RevenuePerShareTTM) || 0,
|
|
1796
|
+
profitMargin: parseFloat(data.ProfitMargin) || 0,
|
|
1797
|
+
week52High: parseFloat(data["52WeekHigh"]) || 0,
|
|
1798
|
+
week52Low: parseFloat(data["52WeekLow"]) || 0,
|
|
1799
|
+
analystTargetPrice: parseFloat(data.AnalystTargetPrice) || 0,
|
|
1800
|
+
beta: parseFloat(data.Beta) || 0
|
|
1801
|
+
};
|
|
1802
|
+
cache4.set(cacheKey, overview);
|
|
1803
|
+
return overview;
|
|
1804
|
+
}
|
|
1805
|
+
async function getEarningsHistory(apiKey, symbol, limit = 8) {
|
|
1806
|
+
const cacheKey = `earnings-history:${symbol}:${limit}`;
|
|
1807
|
+
const cached = cache4.get(cacheKey);
|
|
1808
|
+
if (cached) return cached;
|
|
1809
|
+
const data = await httpGet(
|
|
1810
|
+
`${BASE_URL4}?function=EARNINGS&symbol=${encodeURIComponent(symbol)}&apikey=${apiKey}`
|
|
1811
|
+
);
|
|
1812
|
+
checkAvResponse(data);
|
|
1813
|
+
const result = {
|
|
1814
|
+
symbol: data.symbol,
|
|
1815
|
+
annualEarnings: (data.annualEarnings || []).slice(0, 4),
|
|
1816
|
+
quarterlyEarnings: (data.quarterlyEarnings || []).slice(0, limit)
|
|
1817
|
+
};
|
|
1818
|
+
cache4.set(cacheKey, result);
|
|
1819
|
+
return result;
|
|
1820
|
+
}
|
|
1821
|
+
async function getDividendHistory(apiKey, symbol) {
|
|
1822
|
+
const cacheKey = `dividend-history:${symbol}`;
|
|
1823
|
+
const cached = cache4.get(cacheKey);
|
|
1824
|
+
if (cached) return cached;
|
|
1825
|
+
const data = await httpGet(
|
|
1826
|
+
`${BASE_URL4}?function=DIVIDENDS&symbol=${encodeURIComponent(symbol)}&apikey=${apiKey}`
|
|
1827
|
+
);
|
|
1828
|
+
checkAvResponse(data);
|
|
1829
|
+
const result = {
|
|
1830
|
+
symbol: data.symbol,
|
|
1831
|
+
data: (data.data || []).slice(0, 20)
|
|
1832
|
+
};
|
|
1833
|
+
cache4.set(cacheKey, result);
|
|
1834
|
+
return result;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// src/modules/alpha-vantage/index.ts
|
|
1838
|
+
async function sleep(ms) {
|
|
1839
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1840
|
+
}
|
|
1841
|
+
function createAlphaVantageModule(apiKey) {
|
|
1842
|
+
const metadata3 = { source: "alpha-vantage", dataDelay: "real-time" };
|
|
1843
|
+
const quoteTool = {
|
|
1844
|
+
name: "alphavantage_quote",
|
|
1845
|
+
description: "Get real-time stock quote from Alpha Vantage. Returns price, change, volume, and day range. Rate limit: 5 calls/min on free tier.",
|
|
1846
|
+
inputSchema: z6.object({
|
|
1847
|
+
symbol: z6.string().describe("Stock ticker symbol (e.g. 'AAPL', 'MSFT')")
|
|
1848
|
+
}),
|
|
1849
|
+
readOnly: true,
|
|
1850
|
+
handler: withMetadata(async (params) => {
|
|
1851
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1852
|
+
const quote = await getQuote2(apiKey, symbol);
|
|
1853
|
+
return successResult(JSON.stringify(quote, null, 2));
|
|
1854
|
+
}, metadata3)
|
|
1855
|
+
};
|
|
1856
|
+
const dailyTool = {
|
|
1857
|
+
name: "alphavantage_daily",
|
|
1858
|
+
description: "Get daily OHLCV price history from Alpha Vantage. Returns up to 100 most recent trading days. Rate limit: 25 calls/day, 5 calls/min (free tier).",
|
|
1859
|
+
inputSchema: z6.object({
|
|
1860
|
+
symbol: z6.string().describe("Stock ticker symbol (e.g. 'AAPL')"),
|
|
1861
|
+
limit: z6.number().optional().describe("Number of days to return (default: 30, max: 100)")
|
|
1862
|
+
}),
|
|
1863
|
+
readOnly: true,
|
|
1864
|
+
handler: withMetadata(async (params) => {
|
|
1865
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1866
|
+
const prices = await getDailyPrices(
|
|
1867
|
+
apiKey,
|
|
1868
|
+
symbol,
|
|
1869
|
+
Math.min(params.limit ?? 30, 100)
|
|
1870
|
+
);
|
|
1871
|
+
return successResult(JSON.stringify(prices, null, 2));
|
|
1872
|
+
}, metadata3)
|
|
1873
|
+
};
|
|
1874
|
+
const overviewTool = {
|
|
1875
|
+
name: "alphavantage_overview",
|
|
1876
|
+
description: "Get company fundamentals from Alpha Vantage. Includes PE ratio, market cap, beta, sector, industry, earnings, and analyst target price. Supports batch requests (limit 5). Rate limit: 25 calls/day, 5 calls/min (free tier).",
|
|
1877
|
+
inputSchema: z6.object({
|
|
1878
|
+
symbols: z6.union([z6.string(), z6.array(z6.string())]).describe("One or more stock symbols (e.g. 'AAPL' or ['AAPL', 'MSFT'])")
|
|
1879
|
+
}),
|
|
1880
|
+
readOnly: true,
|
|
1881
|
+
handler: withMetadata(async (params) => {
|
|
1882
|
+
const inputSymbols = Array.isArray(params.symbols) ? params.symbols : [params.symbols];
|
|
1883
|
+
const tickers = inputSymbols.map((s) => resolveTicker(s).ticker);
|
|
1884
|
+
const results = [];
|
|
1885
|
+
for (let i = 0; i < Math.min(tickers.length, 5); i++) {
|
|
1886
|
+
if (i > 0) await sleep(12e3);
|
|
1887
|
+
const overview = await getOverview(apiKey, tickers[i]);
|
|
1888
|
+
results.push(overview);
|
|
1889
|
+
}
|
|
1890
|
+
return successResult(JSON.stringify(results.length === 1 ? results[0] : results, null, 2));
|
|
1891
|
+
}, metadata3)
|
|
1892
|
+
};
|
|
1893
|
+
const earningsHistoryTool = {
|
|
1894
|
+
name: "alphavantage_earnings_history",
|
|
1895
|
+
description: "Get historical earnings data (EPS actual vs estimate) for a specific ticker. Rate limit: 25 calls/day, 5 calls/min (free tier).",
|
|
1896
|
+
inputSchema: z6.object({
|
|
1897
|
+
symbol: z6.string().describe("Stock ticker symbol (e.g. 'AAPL')"),
|
|
1898
|
+
limit: z6.number().optional().describe("Number of quarters to return (default: 8, max: 20)")
|
|
1899
|
+
}),
|
|
1900
|
+
readOnly: true,
|
|
1901
|
+
handler: withMetadata(async (params) => {
|
|
1902
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1903
|
+
const earnings = await getEarningsHistory(
|
|
1904
|
+
apiKey,
|
|
1905
|
+
symbol,
|
|
1906
|
+
Math.min(params.limit ?? 8, 20)
|
|
1907
|
+
);
|
|
1908
|
+
return successResult(JSON.stringify(earnings, null, 2));
|
|
1909
|
+
}, metadata3)
|
|
1910
|
+
};
|
|
1911
|
+
const dividendHistoryTool = {
|
|
1912
|
+
name: "alphavantage_dividend_history",
|
|
1913
|
+
description: "Get historical dividend data for a specific ticker. Rate limit: 25 calls/day, 5 calls/min (free tier).",
|
|
1914
|
+
inputSchema: z6.object({
|
|
1915
|
+
symbol: z6.string().describe("Stock ticker symbol (e.g. 'AAPL')")
|
|
1916
|
+
}),
|
|
1917
|
+
readOnly: true,
|
|
1918
|
+
handler: withMetadata(async (params) => {
|
|
1919
|
+
const symbol = resolveTicker(params.symbol).ticker;
|
|
1920
|
+
const dividends = await getDividendHistory(apiKey, symbol);
|
|
1921
|
+
return successResult(JSON.stringify(dividends, null, 2));
|
|
1922
|
+
}, metadata3)
|
|
1923
|
+
};
|
|
1924
|
+
return {
|
|
1925
|
+
name: "alpha-vantage",
|
|
1926
|
+
description: "Alpha Vantage stock data -- quotes, daily prices, and company fundamentals",
|
|
1927
|
+
requiredEnvVars: ["ALPHA_VANTAGE_API_KEY"],
|
|
1928
|
+
tools: [quoteTool, dailyTool, overviewTool, earningsHistoryTool, dividendHistoryTool]
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// src/modules/options/index.ts
|
|
1933
|
+
import { z as z7 } from "zod";
|
|
1934
|
+
|
|
1935
|
+
// src/modules/options/greeks.ts
|
|
1936
|
+
function cnd(x) {
|
|
1937
|
+
const a1 = 0.31938153;
|
|
1938
|
+
const a2 = -0.356563782;
|
|
1939
|
+
const a3 = 1.781477937;
|
|
1940
|
+
const a4 = -1.821255978;
|
|
1941
|
+
const a5 = 1.330274429;
|
|
1942
|
+
const L = Math.abs(x);
|
|
1943
|
+
const K = 1 / (1 + 0.2316419 * L);
|
|
1944
|
+
let w = 1 - 1 / Math.sqrt(2 * Math.PI) * Math.exp(-L * L / 2) * (a1 * K + a2 * K * K + a3 * Math.pow(K, 3) + a4 * Math.pow(K, 4) + a5 * Math.pow(K, 5));
|
|
1945
|
+
if (x < 0) w = 1 - w;
|
|
1946
|
+
return w;
|
|
1947
|
+
}
|
|
1948
|
+
function pdf(x) {
|
|
1949
|
+
return Math.exp(-x * x / 2) / Math.sqrt(2 * Math.PI);
|
|
1950
|
+
}
|
|
1951
|
+
function calculateGreeks(S, K, T, r, v, isCall) {
|
|
1952
|
+
if (T <= 0 || v <= 0 || S <= 0 || K <= 0) {
|
|
1953
|
+
return { delta: 0, gamma: 0, theta: 0, vega: 0 };
|
|
1954
|
+
}
|
|
1955
|
+
const d1 = (Math.log(S / K) + (r + v * v / 2) * T) / (v * Math.sqrt(T));
|
|
1956
|
+
const d2 = d1 - v * Math.sqrt(T);
|
|
1957
|
+
let delta;
|
|
1958
|
+
let theta;
|
|
1959
|
+
if (isCall) {
|
|
1960
|
+
delta = cnd(d1);
|
|
1961
|
+
theta = (-S * pdf(d1) * v / (2 * Math.sqrt(T)) - r * K * Math.exp(-r * T) * cnd(d2)) / 365;
|
|
1962
|
+
} else {
|
|
1963
|
+
delta = cnd(d1) - 1;
|
|
1964
|
+
theta = (-S * pdf(d1) * v / (2 * Math.sqrt(T)) + r * K * Math.exp(-r * T) * cnd(-d2)) / 365;
|
|
1965
|
+
}
|
|
1966
|
+
const gamma = pdf(d1) / (S * v * Math.sqrt(T));
|
|
1967
|
+
const vega = S * Math.sqrt(T) * pdf(d1) / 100;
|
|
1968
|
+
return {
|
|
1969
|
+
delta: parseFloat(delta.toFixed(6)),
|
|
1970
|
+
gamma: parseFloat(gamma.toFixed(6)),
|
|
1971
|
+
theta: parseFloat(theta.toFixed(6)),
|
|
1972
|
+
vega: parseFloat(vega.toFixed(6))
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
function calculateMaxPain(strikes, calls, puts) {
|
|
1976
|
+
if (strikes.length === 0) return 0;
|
|
1977
|
+
let minPain = Infinity;
|
|
1978
|
+
let maxPainStrike = strikes[0];
|
|
1979
|
+
for (const candidate of strikes) {
|
|
1980
|
+
let pain = 0;
|
|
1981
|
+
for (const call of calls) {
|
|
1982
|
+
if (candidate > call.strike) {
|
|
1983
|
+
pain += (candidate - call.strike) * (call.openInterest ?? 0);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
for (const put of puts) {
|
|
1987
|
+
if (candidate < put.strike) {
|
|
1988
|
+
pain += (put.strike - candidate) * (put.openInterest ?? 0);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
if (pain < minPain) {
|
|
1992
|
+
minPain = pain;
|
|
1993
|
+
maxPainStrike = candidate;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
return maxPainStrike;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// src/modules/options/yahoo-session.ts
|
|
2000
|
+
var COOKIE_URL = "https://fc.yahoo.com/curveball";
|
|
2001
|
+
var CRUMB_URL = "https://query1.finance.yahoo.com/v1/test/getcrumb";
|
|
2002
|
+
var SESSION_TTL = 30 * 60 * 1e3;
|
|
2003
|
+
var TIMEOUT_MS = 1e4;
|
|
2004
|
+
var RATE_LIMIT_COOLDOWN = 5 * 60 * 1e3;
|
|
2005
|
+
var YAHOO_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
2006
|
+
var session = null;
|
|
2007
|
+
var sessionPromise = null;
|
|
2008
|
+
var lastRateLimitTime = 0;
|
|
2009
|
+
async function createSession() {
|
|
2010
|
+
const now = Date.now();
|
|
2011
|
+
if (now - lastRateLimitTime < RATE_LIMIT_COOLDOWN) {
|
|
2012
|
+
const remaining = Math.ceil((RATE_LIMIT_COOLDOWN - (now - lastRateLimitTime)) / 1e3);
|
|
2013
|
+
throw new Error(`Yahoo Finance rate limit cooldown active. Please wait ${remaining}s.`);
|
|
2014
|
+
}
|
|
2015
|
+
const controller1 = new AbortController();
|
|
2016
|
+
const timer1 = setTimeout(() => controller1.abort(), TIMEOUT_MS);
|
|
2017
|
+
let cookie;
|
|
2018
|
+
try {
|
|
2019
|
+
const cookieResp = await httpFetch(COOKIE_URL, {
|
|
2020
|
+
method: "GET",
|
|
2021
|
+
headers: {
|
|
2022
|
+
"User-Agent": YAHOO_USER_AGENT,
|
|
2023
|
+
"Accept": "*/*"
|
|
2024
|
+
},
|
|
2025
|
+
redirect: "manual",
|
|
2026
|
+
signal: controller1.signal
|
|
2027
|
+
});
|
|
2028
|
+
const headers = cookieResp.headers;
|
|
2029
|
+
let setCookie = [];
|
|
2030
|
+
if (headers) {
|
|
2031
|
+
if (typeof headers.getSetCookie === "function") {
|
|
2032
|
+
setCookie = headers.getSetCookie();
|
|
2033
|
+
} else if (typeof headers.get === "function") {
|
|
2034
|
+
const val = headers.get("set-cookie");
|
|
2035
|
+
if (val) setCookie = [val];
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
const a3 = setCookie.map((c) => c.split(";")[0]).find((c) => c.startsWith("A3="));
|
|
2039
|
+
if (!a3) {
|
|
2040
|
+
throw new Error("Yahoo session: failed to obtain A3 cookie");
|
|
2041
|
+
}
|
|
2042
|
+
cookie = a3;
|
|
2043
|
+
} finally {
|
|
2044
|
+
clearTimeout(timer1);
|
|
2045
|
+
}
|
|
2046
|
+
const controller2 = new AbortController();
|
|
2047
|
+
const timer2 = setTimeout(() => controller2.abort(), TIMEOUT_MS);
|
|
2048
|
+
let crumb;
|
|
2049
|
+
try {
|
|
2050
|
+
const crumbResp = await httpFetch(CRUMB_URL, {
|
|
2051
|
+
method: "GET",
|
|
2052
|
+
headers: {
|
|
2053
|
+
"User-Agent": YAHOO_USER_AGENT,
|
|
2054
|
+
"Cookie": cookie,
|
|
2055
|
+
"Referer": "https://finance.yahoo.com",
|
|
2056
|
+
"Accept": "*/*"
|
|
2057
|
+
},
|
|
2058
|
+
signal: controller2.signal
|
|
2059
|
+
});
|
|
2060
|
+
if (crumbResp.status === 429) {
|
|
2061
|
+
lastRateLimitTime = Date.now();
|
|
2062
|
+
throw new Error("Yahoo session: IP rate limited (429). Cooling down for 5 minutes.");
|
|
2063
|
+
}
|
|
2064
|
+
if (!crumbResp.ok) {
|
|
2065
|
+
throw new Error(`Yahoo session: crumb request failed (${crumbResp.status})`);
|
|
2066
|
+
}
|
|
2067
|
+
crumb = await crumbResp.text();
|
|
2068
|
+
if (!crumb || crumb.includes("Too Many") || crumb.includes("error")) {
|
|
2069
|
+
if (crumb.includes("Too Many")) lastRateLimitTime = Date.now();
|
|
2070
|
+
throw new Error(`Yahoo session: invalid crumb response: ${crumb.slice(0, 100)}`);
|
|
2071
|
+
}
|
|
2072
|
+
} finally {
|
|
2073
|
+
clearTimeout(timer2);
|
|
2074
|
+
}
|
|
2075
|
+
return { cookie, crumb, createdAt: Date.now() };
|
|
2076
|
+
}
|
|
2077
|
+
function isExpired(s) {
|
|
2078
|
+
return Date.now() - s.createdAt > SESSION_TTL;
|
|
2079
|
+
}
|
|
2080
|
+
async function getSession() {
|
|
2081
|
+
if (session && !isExpired(session)) {
|
|
2082
|
+
return session;
|
|
2083
|
+
}
|
|
2084
|
+
if (!sessionPromise) {
|
|
2085
|
+
sessionPromise = createSession().then((s) => {
|
|
2086
|
+
session = s;
|
|
2087
|
+
sessionPromise = null;
|
|
2088
|
+
return s;
|
|
2089
|
+
}).catch((err) => {
|
|
2090
|
+
sessionPromise = null;
|
|
2091
|
+
throw err;
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
return sessionPromise;
|
|
2095
|
+
}
|
|
2096
|
+
function invalidateSession() {
|
|
2097
|
+
session = null;
|
|
2098
|
+
sessionPromise = null;
|
|
2099
|
+
}
|
|
2100
|
+
async function getYahooHeaders() {
|
|
2101
|
+
const sess = await getSession();
|
|
2102
|
+
return {
|
|
2103
|
+
"User-Agent": YAHOO_USER_AGENT,
|
|
2104
|
+
"Cookie": sess.cookie,
|
|
2105
|
+
"Referer": "https://finance.yahoo.com",
|
|
2106
|
+
"Accept": "*/*"
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
async function appendCrumb(url) {
|
|
2110
|
+
const sess = await getSession();
|
|
2111
|
+
const sep = url.includes("?") ? "&" : "?";
|
|
2112
|
+
return `${url}${sep}crumb=${encodeURIComponent(sess.crumb)}`;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// src/modules/options/client.ts
|
|
2116
|
+
var BASE_URL5 = "https://query1.finance.yahoo.com/v7/finance/options";
|
|
2117
|
+
var CACHE_TTL5 = 5 * 60 * 1e3;
|
|
2118
|
+
var cache5 = new TtlCache(CACHE_TTL5);
|
|
2119
|
+
var DEFAULT_RISK_FREE_RATE = 0.045;
|
|
2120
|
+
function mapOption(raw, isCall, underlyingPrice, yearsToExpiration, riskFreeRate) {
|
|
2121
|
+
const strike = raw.strike ?? 0;
|
|
2122
|
+
const iv = raw.impliedVolatility ?? 0;
|
|
2123
|
+
let greeks = null;
|
|
2124
|
+
if (underlyingPrice > 0 && strike > 0 && iv > 0 && yearsToExpiration > 0) {
|
|
2125
|
+
greeks = calculateGreeks(underlyingPrice, strike, yearsToExpiration, riskFreeRate, iv, isCall);
|
|
2126
|
+
}
|
|
2127
|
+
return {
|
|
2128
|
+
symbol: raw.contractSymbol ?? "",
|
|
2129
|
+
strike,
|
|
2130
|
+
lastPrice: raw.lastPrice ?? 0,
|
|
2131
|
+
bid: raw.bid ?? 0,
|
|
2132
|
+
ask: raw.ask ?? 0,
|
|
2133
|
+
change: raw.change ?? 0,
|
|
2134
|
+
percentChange: raw.percentChange ?? 0,
|
|
2135
|
+
volume: raw.volume ?? 0,
|
|
2136
|
+
openInterest: raw.openInterest ?? 0,
|
|
2137
|
+
impliedVolatility: iv,
|
|
2138
|
+
inTheMoney: raw.inTheMoney ?? false,
|
|
2139
|
+
delta: greeks?.delta ?? null,
|
|
2140
|
+
gamma: greeks?.gamma ?? null,
|
|
2141
|
+
theta: greeks?.theta ?? null,
|
|
2142
|
+
vega: greeks?.vega ?? null
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
async function yahooGet(url) {
|
|
2146
|
+
try {
|
|
2147
|
+
const headers = await getYahooHeaders();
|
|
2148
|
+
const finalUrl = await appendCrumb(url);
|
|
2149
|
+
return await httpGet(finalUrl, { headers });
|
|
2150
|
+
} catch (err) {
|
|
2151
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2152
|
+
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
|
2153
|
+
invalidateSession();
|
|
2154
|
+
const headers = await getYahooHeaders();
|
|
2155
|
+
const finalUrl = await appendCrumb(url);
|
|
2156
|
+
return await httpGet(finalUrl, { headers });
|
|
2157
|
+
}
|
|
2158
|
+
throw err;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
async function fetchOptionChain(rawSymbol, expiration, riskFreeRate = DEFAULT_RISK_FREE_RATE) {
|
|
2162
|
+
const { ticker } = resolveTicker(rawSymbol);
|
|
2163
|
+
const cacheKey = `options:${ticker}:${expiration ?? "latest"}`;
|
|
2164
|
+
const cached = cache5.get(cacheKey);
|
|
2165
|
+
if (cached) return cached;
|
|
2166
|
+
const baseUrl = `${BASE_URL5}/${encodeURIComponent(ticker)}${expiration ? `?date=${expiration}` : ""}`;
|
|
2167
|
+
const response = await yahooGet(baseUrl);
|
|
2168
|
+
const result = response?.optionChain?.result?.[0];
|
|
2169
|
+
if (!result) {
|
|
2170
|
+
throw new Error(`No options data found for symbol: ${ticker}`);
|
|
2171
|
+
}
|
|
2172
|
+
const underlyingPrice = result.quote?.regularMarketPrice;
|
|
2173
|
+
if (!underlyingPrice) {
|
|
2174
|
+
throw new Error(`No price data available for symbol: ${ticker}`);
|
|
2175
|
+
}
|
|
2176
|
+
const chainData = result.options?.[0];
|
|
2177
|
+
if (!chainData) {
|
|
2178
|
+
throw new Error(`No option contracts found for symbol: ${ticker}`);
|
|
2179
|
+
}
|
|
2180
|
+
const expirationTimestamp = chainData.expirationDate ?? 0;
|
|
2181
|
+
const now = Date.now() / 1e3;
|
|
2182
|
+
const yearsToExpiration = Math.max((expirationTimestamp - now) / (365 * 24 * 60 * 60), 0);
|
|
2183
|
+
const calls = (chainData.calls ?? []).map((opt) => mapOption(opt, true, underlyingPrice, yearsToExpiration, riskFreeRate));
|
|
2184
|
+
const puts = (chainData.puts ?? []).map((opt) => mapOption(opt, false, underlyingPrice, yearsToExpiration, riskFreeRate));
|
|
2185
|
+
const strikes = result.strikes ?? [];
|
|
2186
|
+
const processedChain = {
|
|
2187
|
+
underlyingSymbol: result.underlyingSymbol ?? ticker,
|
|
2188
|
+
underlyingPrice,
|
|
2189
|
+
expirationDates: result.expirationDates ?? [],
|
|
2190
|
+
strikes,
|
|
2191
|
+
calls,
|
|
2192
|
+
puts,
|
|
2193
|
+
maxPain: calculateMaxPain(strikes, calls, puts)
|
|
2194
|
+
};
|
|
2195
|
+
cache5.set(cacheKey, processedChain);
|
|
2196
|
+
return processedChain;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// src/modules/options/index.ts
|
|
2200
|
+
var metadata = { source: "yahoo-finance", dataDelay: "15min" };
|
|
2201
|
+
var symbolSchema = z7.string().min(1).max(10).describe("Stock ticker symbol (e.g. 'AAPL', 'NYSE:GM')");
|
|
2202
|
+
function parseExpiration(value) {
|
|
2203
|
+
if (!value) return void 0;
|
|
2204
|
+
const ts = Math.floor((/* @__PURE__ */ new Date(value + "T00:00:00Z")).getTime() / 1e3);
|
|
2205
|
+
if (isNaN(ts)) return void 0;
|
|
2206
|
+
return ts;
|
|
2207
|
+
}
|
|
2208
|
+
var expirationsTool = {
|
|
2209
|
+
name: "options_expirations",
|
|
2210
|
+
description: "Get all available option expiration dates for a stock ticker. Call this first to discover valid dates, then pass one to options_chain or options_max_pain.",
|
|
2211
|
+
inputSchema: z7.object({
|
|
2212
|
+
symbol: symbolSchema
|
|
2213
|
+
}),
|
|
2214
|
+
readOnly: true,
|
|
2215
|
+
handler: withMetadata(async (params) => {
|
|
2216
|
+
const chain = await fetchOptionChain(params.symbol);
|
|
2217
|
+
const dates = chain.expirationDates.map(
|
|
2218
|
+
(d) => new Date(d * 1e3).toISOString().split("T")[0]
|
|
2219
|
+
);
|
|
2220
|
+
return successResult(JSON.stringify({
|
|
2221
|
+
symbol: chain.underlyingSymbol,
|
|
2222
|
+
underlyingPrice: chain.underlyingPrice,
|
|
2223
|
+
expirations: dates
|
|
2224
|
+
}, null, 2));
|
|
2225
|
+
}, metadata)
|
|
2226
|
+
};
|
|
2227
|
+
var chainTool = {
|
|
2228
|
+
name: "options_chain",
|
|
2229
|
+
description: "Get the full option chain (calls and puts) with calculated Greeks (Delta, Gamma, Theta, Vega). Use options_expirations first to find valid dates. If expiration is omitted, uses nearest date. By default, returns strikes within \xB120% of current price to save tokens. Use strike_min/strike_max for custom range, or all_strikes=true for everything.",
|
|
2230
|
+
inputSchema: z7.object({
|
|
2231
|
+
symbol: symbolSchema,
|
|
2232
|
+
expiration: z7.string().optional().describe("Expiration date as YYYY-MM-DD (e.g. '2026-04-17'). If omitted, uses nearest."),
|
|
2233
|
+
side: z7.enum(["call", "put", "both"]).optional().describe("Filter by option type (default: both)"),
|
|
2234
|
+
limit: z7.number().optional().describe("Max contracts per side (default: 50, max: 200)"),
|
|
2235
|
+
strike_min: z7.number().optional().describe("Minimum strike price filter (e.g. 25.0)"),
|
|
2236
|
+
strike_max: z7.number().optional().describe("Maximum strike price filter (e.g. 35.0)"),
|
|
2237
|
+
all_strikes: z7.boolean().optional().describe("Return all strikes instead of centering around ATM (default: false)")
|
|
2238
|
+
}),
|
|
2239
|
+
readOnly: true,
|
|
2240
|
+
handler: withMetadata(async (params) => {
|
|
2241
|
+
const expUnix = parseExpiration(params.expiration);
|
|
2242
|
+
const chain = await fetchOptionChain(params.symbol, expUnix);
|
|
2243
|
+
let { calls, puts } = chain;
|
|
2244
|
+
const allStrikes = params.all_strikes ?? false;
|
|
2245
|
+
const hasExplicitRange = params.strike_min != null || params.strike_max != null;
|
|
2246
|
+
if (!allStrikes) {
|
|
2247
|
+
let minStrike;
|
|
2248
|
+
let maxStrike;
|
|
2249
|
+
if (hasExplicitRange) {
|
|
2250
|
+
minStrike = params.strike_min ?? 0;
|
|
2251
|
+
maxStrike = params.strike_max ?? Infinity;
|
|
2252
|
+
} else {
|
|
2253
|
+
minStrike = chain.underlyingPrice * 0.8;
|
|
2254
|
+
maxStrike = chain.underlyingPrice * 1.2;
|
|
2255
|
+
}
|
|
2256
|
+
calls = calls.filter((c) => c.strike >= minStrike && c.strike <= maxStrike);
|
|
2257
|
+
puts = puts.filter((c) => c.strike >= minStrike && c.strike <= maxStrike);
|
|
2258
|
+
}
|
|
2259
|
+
if (params.side === "call") {
|
|
2260
|
+
puts = [];
|
|
2261
|
+
} else if (params.side === "put") {
|
|
2262
|
+
calls = [];
|
|
2263
|
+
}
|
|
2264
|
+
const limit = Math.min(params.limit ?? 50, 200);
|
|
2265
|
+
calls = calls.slice(0, limit);
|
|
2266
|
+
puts = puts.slice(0, limit);
|
|
2267
|
+
return successResult(JSON.stringify({
|
|
2268
|
+
...chain,
|
|
2269
|
+
calls,
|
|
2270
|
+
puts
|
|
2271
|
+
}, null, 2));
|
|
2272
|
+
}, metadata)
|
|
2273
|
+
};
|
|
2274
|
+
var unusualActivityTool = {
|
|
2275
|
+
name: "options_unusual_activity",
|
|
2276
|
+
description: "Find options contracts with unusually high volume relative to open interest (a common 'smart money' signal). Scans the nearest expiration and flags contracts where volume/OI exceeds a threshold.",
|
|
2277
|
+
inputSchema: z7.object({
|
|
2278
|
+
symbol: symbolSchema,
|
|
2279
|
+
volume_oi_ratio: z7.number().optional().describe("Min volume/OI ratio to flag as unusual (default: 3.0)"),
|
|
2280
|
+
min_volume: z7.number().optional().describe("Min absolute volume (default: 100)"),
|
|
2281
|
+
side: z7.enum(["call", "put", "both"]).optional().describe("Filter by option type (default: both)")
|
|
2282
|
+
}),
|
|
2283
|
+
readOnly: true,
|
|
2284
|
+
handler: withMetadata(async (params) => {
|
|
2285
|
+
const chain = await fetchOptionChain(params.symbol);
|
|
2286
|
+
const minRatio = params.volume_oi_ratio ?? 3;
|
|
2287
|
+
const minVol = params.min_volume ?? 100;
|
|
2288
|
+
const minOI = 10;
|
|
2289
|
+
const allContracts = [
|
|
2290
|
+
...chain.calls.map((c) => ({ ...c, side: "call" })),
|
|
2291
|
+
...chain.puts.map((c) => ({ ...c, side: "put" }))
|
|
2292
|
+
];
|
|
2293
|
+
let unusual = allContracts.filter((c) => c.volume >= minVol && c.openInterest >= minOI).map((c) => ({
|
|
2294
|
+
...c,
|
|
2295
|
+
volumeOiRatio: Math.round(c.volume / c.openInterest * 100) / 100
|
|
2296
|
+
})).filter((c) => c.volumeOiRatio >= minRatio);
|
|
2297
|
+
if (params.side && params.side !== "both") {
|
|
2298
|
+
unusual = unusual.filter((c) => c.side === params.side);
|
|
2299
|
+
}
|
|
2300
|
+
unusual.sort((a, b) => b.volume - a.volume);
|
|
2301
|
+
unusual = unusual.slice(0, 20);
|
|
2302
|
+
return successResult(JSON.stringify({
|
|
2303
|
+
symbol: chain.underlyingSymbol,
|
|
2304
|
+
underlyingPrice: chain.underlyingPrice,
|
|
2305
|
+
unusual
|
|
2306
|
+
}, null, 2));
|
|
2307
|
+
}, metadata)
|
|
2308
|
+
};
|
|
2309
|
+
var maxPainTool = {
|
|
2310
|
+
name: "options_max_pain",
|
|
2311
|
+
description: "Calculate the max pain strike price \u2014 where cumulative option open interest expires worthless. This level often acts as a support/resistance zone near expiration.",
|
|
2312
|
+
inputSchema: z7.object({
|
|
2313
|
+
symbol: symbolSchema,
|
|
2314
|
+
expiration: z7.string().optional().describe("Expiration date as YYYY-MM-DD. If omitted, uses nearest.")
|
|
2315
|
+
}),
|
|
2316
|
+
readOnly: true,
|
|
2317
|
+
handler: withMetadata(async (params) => {
|
|
2318
|
+
const expUnix = parseExpiration(params.expiration);
|
|
2319
|
+
const chain = await fetchOptionChain(params.symbol, expUnix);
|
|
2320
|
+
const expDate = chain.expirationDates[0] ? new Date(chain.expirationDates[0] * 1e3).toISOString().split("T")[0] : "unknown";
|
|
2321
|
+
return successResult(JSON.stringify({
|
|
2322
|
+
symbol: chain.underlyingSymbol,
|
|
2323
|
+
underlyingPrice: chain.underlyingPrice,
|
|
2324
|
+
expiration: expDate,
|
|
2325
|
+
maxPain: chain.maxPain
|
|
2326
|
+
}, null, 2));
|
|
2327
|
+
}, metadata)
|
|
2328
|
+
};
|
|
2329
|
+
var impliedMoveTool = {
|
|
2330
|
+
name: "options_implied_move",
|
|
2331
|
+
description: "Calculate the expected move implied by options pricing (ATM straddle). Essential for earnings plays \u2014 shows how much the market expects the stock to move. Compare implied vs historical moves to assess if premium is cheap or expensive.",
|
|
2332
|
+
inputSchema: z7.object({
|
|
2333
|
+
symbol: symbolSchema,
|
|
2334
|
+
expiration: z7.string().optional().describe("Expiration date as YYYY-MM-DD. If omitted, uses nearest.")
|
|
2335
|
+
}),
|
|
2336
|
+
readOnly: true,
|
|
2337
|
+
handler: withMetadata(async (params) => {
|
|
2338
|
+
const expUnix = parseExpiration(params.expiration);
|
|
2339
|
+
const chain = await fetchOptionChain(params.symbol, expUnix);
|
|
2340
|
+
const price = chain.underlyingPrice;
|
|
2341
|
+
const expDate = chain.expirationDates[0] ? new Date(chain.expirationDates[0] * 1e3).toISOString().split("T")[0] : "unknown";
|
|
2342
|
+
const atmCall = chain.calls.reduce(
|
|
2343
|
+
(best, c) => Math.abs(c.strike - price) < Math.abs(best.strike - price) ? c : best,
|
|
2344
|
+
chain.calls[0]
|
|
2345
|
+
);
|
|
2346
|
+
const atmPut = chain.puts.reduce(
|
|
2347
|
+
(best, p) => Math.abs(p.strike - price) < Math.abs(best.strike - price) ? p : best,
|
|
2348
|
+
chain.puts[0]
|
|
2349
|
+
);
|
|
2350
|
+
if (!atmCall || !atmPut) {
|
|
2351
|
+
return successResult(JSON.stringify({
|
|
2352
|
+
symbol: chain.underlyingSymbol,
|
|
2353
|
+
underlyingPrice: price,
|
|
2354
|
+
error: "Insufficient options data to calculate implied move"
|
|
2355
|
+
}, null, 2));
|
|
2356
|
+
}
|
|
2357
|
+
const callPrice = atmCall.lastPrice || (atmCall.bid + atmCall.ask) / 2 || 0;
|
|
2358
|
+
const putPrice = atmPut.lastPrice || (atmPut.bid + atmPut.ask) / 2 || 0;
|
|
2359
|
+
const straddlePrice = callPrice + putPrice;
|
|
2360
|
+
const impliedMoveAbs = straddlePrice;
|
|
2361
|
+
const impliedMovePct = price > 0 ? straddlePrice / price * 100 : 0;
|
|
2362
|
+
const avgIv = ((atmCall.impliedVolatility || 0) + (atmPut.impliedVolatility || 0)) / 2;
|
|
2363
|
+
return successResult(JSON.stringify({
|
|
2364
|
+
symbol: chain.underlyingSymbol,
|
|
2365
|
+
underlyingPrice: price,
|
|
2366
|
+
expiration: expDate,
|
|
2367
|
+
atmCallStrike: atmCall.strike,
|
|
2368
|
+
atmCallPrice: callPrice,
|
|
2369
|
+
atmPutStrike: atmPut.strike,
|
|
2370
|
+
atmPutPrice: putPrice,
|
|
2371
|
+
straddlePrice,
|
|
2372
|
+
impliedMove: Math.round(impliedMovePct * 100) / 100,
|
|
2373
|
+
impliedMoveAbsolute: Math.round(impliedMoveAbs * 100) / 100,
|
|
2374
|
+
impliedVolatility: Math.round(avgIv * 1e4) / 100,
|
|
2375
|
+
expectedRange: {
|
|
2376
|
+
low: Math.round((price - impliedMoveAbs) * 100) / 100,
|
|
2377
|
+
high: Math.round((price + impliedMoveAbs) * 100) / 100
|
|
2378
|
+
}
|
|
2379
|
+
}, null, 2));
|
|
2380
|
+
}, metadata)
|
|
2381
|
+
};
|
|
2382
|
+
function createOptionsModule() {
|
|
2383
|
+
return {
|
|
2384
|
+
name: "options",
|
|
2385
|
+
description: "Stock options chains, Greeks, unusual activity, implied move, and max pain from Yahoo Finance (no API key required)",
|
|
2386
|
+
requiredEnvVars: [],
|
|
2387
|
+
tools: [expirationsTool, chainTool, unusualActivityTool, maxPainTool, impliedMoveTool]
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// src/modules/options-cboe/index.ts
|
|
2392
|
+
import { z as z8 } from "zod";
|
|
2393
|
+
|
|
2394
|
+
// src/modules/options-cboe/cboe.ts
|
|
2395
|
+
var BASE_URL6 = "https://cdn.cboe.com/data/us/options/market_statistics/daily";
|
|
2396
|
+
var CACHE_TTL6 = 30 * 60 * 1e3;
|
|
2397
|
+
var cache6 = new TtlCache(CACHE_TTL6);
|
|
2398
|
+
var TYPE_MAP = {
|
|
2399
|
+
total: { ratioName: "TOTAL PUT/CALL RATIO", volumeKey: "SUM OF ALL PRODUCTS" },
|
|
2400
|
+
equity: { ratioName: "EQUITY PUT/CALL RATIO", volumeKey: "EQUITY OPTIONS" },
|
|
2401
|
+
index: { ratioName: "INDEX PUT/CALL RATIO", volumeKey: "INDEX OPTIONS" }
|
|
2402
|
+
};
|
|
2403
|
+
function formatDate(d) {
|
|
2404
|
+
const yyyy = d.getFullYear();
|
|
2405
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
2406
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
2407
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
2408
|
+
}
|
|
2409
|
+
function isWeekend(d) {
|
|
2410
|
+
const day = d.getDay();
|
|
2411
|
+
return day === 0 || day === 6;
|
|
2412
|
+
}
|
|
2413
|
+
function parseDailyJson(json, date, type) {
|
|
2414
|
+
const mapping = TYPE_MAP[type];
|
|
2415
|
+
if (!mapping) return null;
|
|
2416
|
+
const ratioObj = json.ratios?.find((r) => r.name === mapping.ratioName);
|
|
2417
|
+
if (!ratioObj) return null;
|
|
2418
|
+
const putCallRatio = Number(ratioObj.value);
|
|
2419
|
+
if (isNaN(putCallRatio)) return null;
|
|
2420
|
+
const volumeSection = json[mapping.volumeKey];
|
|
2421
|
+
const volumeRow = volumeSection?.find((v) => v.name === "VOLUME");
|
|
2422
|
+
return {
|
|
2423
|
+
date,
|
|
2424
|
+
callVolume: volumeRow?.call ?? 0,
|
|
2425
|
+
putVolume: volumeRow?.put ?? 0,
|
|
2426
|
+
totalVolume: volumeRow?.total ?? 0,
|
|
2427
|
+
putCallRatio
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
async function fetchDay(date) {
|
|
2431
|
+
const url = `${BASE_URL6}/${date}_daily_options`;
|
|
2432
|
+
try {
|
|
2433
|
+
return await httpGet(url);
|
|
2434
|
+
} catch (err) {
|
|
2435
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2436
|
+
if (msg.includes("HTTP 403") || msg.includes("HTTP 404")) {
|
|
2437
|
+
return null;
|
|
2438
|
+
}
|
|
2439
|
+
throw err;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
async function fetchDays(type, days) {
|
|
2443
|
+
const mapping = TYPE_MAP[type];
|
|
2444
|
+
if (!mapping) {
|
|
2445
|
+
throw new Error(`Unknown put/call ratio type: ${type}`);
|
|
2446
|
+
}
|
|
2447
|
+
const entries = [];
|
|
2448
|
+
const cursor = /* @__PURE__ */ new Date();
|
|
2449
|
+
cursor.setDate(cursor.getDate() - 1);
|
|
2450
|
+
const maxAttempts = days * 2 + 10;
|
|
2451
|
+
let attempts = 0;
|
|
2452
|
+
while (entries.length < days && attempts < maxAttempts) {
|
|
2453
|
+
attempts++;
|
|
2454
|
+
if (isWeekend(cursor)) {
|
|
2455
|
+
cursor.setDate(cursor.getDate() - 1);
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
const dateStr = formatDate(cursor);
|
|
2459
|
+
const json = await fetchDay(dateStr);
|
|
2460
|
+
if (json) {
|
|
2461
|
+
const entry = parseDailyJson(json, dateStr, type);
|
|
2462
|
+
if (entry) {
|
|
2463
|
+
entries.push(entry);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
cursor.setDate(cursor.getDate() - 1);
|
|
2467
|
+
}
|
|
2468
|
+
return entries;
|
|
2469
|
+
}
|
|
2470
|
+
async function getPutCallRatio(type, days) {
|
|
2471
|
+
return cache6.getOrFetch(`pcr:${type}:${days}`, () => fetchDays(type, days));
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// src/modules/options-cboe/index.ts
|
|
2475
|
+
function createOptionsCboeModule() {
|
|
2476
|
+
const metadata3 = { source: "cboe", dataDelay: "end-of-day" };
|
|
2477
|
+
return {
|
|
2478
|
+
name: "options-cboe",
|
|
2479
|
+
description: "CBOE market-wide put/call ratio data \u2014 daily sentiment indicator from options volume",
|
|
2480
|
+
requiredEnvVars: [],
|
|
2481
|
+
tools: [{
|
|
2482
|
+
name: "options_put_call_ratio",
|
|
2483
|
+
description: "Get historical put/call ratio from CBOE (market-wide sentiment indicator). Ratio > 1.0 = more puts (bearish sentiment), < 0.7 = more calls (bullish/complacent). Types: 'total' (all options), 'equity' (stock options only), 'index' (index options only).",
|
|
2484
|
+
inputSchema: z8.object({
|
|
2485
|
+
type: z8.enum(["total", "equity", "index"]).optional().describe("Ratio type (default: total)"),
|
|
2486
|
+
days: z8.number().optional().describe("Number of recent trading days to return (default: 30, max: 252)")
|
|
2487
|
+
}),
|
|
2488
|
+
readOnly: true,
|
|
2489
|
+
handler: withMetadata(async (params) => {
|
|
2490
|
+
const days = Math.min(Math.max(params.days ?? 30, 1), 252);
|
|
2491
|
+
const data = await getPutCallRatio(params.type ?? "total", days);
|
|
2492
|
+
return successResult(JSON.stringify(data, null, 2));
|
|
2493
|
+
}, metadata3)
|
|
2494
|
+
}]
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// src/modules/fred/index.ts
|
|
2499
|
+
import { z as z9 } from "zod";
|
|
2500
|
+
|
|
2501
|
+
// src/modules/fred/client.ts
|
|
2502
|
+
var BASE_URL7 = "https://api.stlouisfed.org/fred";
|
|
2503
|
+
var CACHE_TTL7 = 30 * 60 * 1e3;
|
|
2504
|
+
var cache7 = new TtlCache(CACHE_TTL7);
|
|
2505
|
+
var HIGH_IMPACT_RELEASES = {
|
|
2506
|
+
10: "Consumer Price Index",
|
|
2507
|
+
46: "Producer Price Index",
|
|
2508
|
+
50: "Employment Situation",
|
|
2509
|
+
53: "Gross Domestic Product",
|
|
2510
|
+
54: "Personal Income and Outlays",
|
|
2511
|
+
101: "Federal Funds Rate",
|
|
2512
|
+
7: "Advance Retail Sales",
|
|
2513
|
+
9: "Industrial Production and Capacity Utilization",
|
|
2514
|
+
18: "Housing Starts",
|
|
2515
|
+
32: "ISM Manufacturing"
|
|
2516
|
+
};
|
|
2517
|
+
var COMMON_SERIES = {
|
|
2518
|
+
fed_funds: "DFF",
|
|
2519
|
+
cpi: "CPIAUCSL",
|
|
2520
|
+
core_cpi: "CPILFESL",
|
|
2521
|
+
ppi: "PPIFIS",
|
|
2522
|
+
gdp: "GDPC1",
|
|
2523
|
+
unemployment: "UNRATE",
|
|
2524
|
+
nonfarm_payrolls: "PAYEMS",
|
|
2525
|
+
treasury_10y: "DGS10",
|
|
2526
|
+
treasury_2y: "DGS2",
|
|
2527
|
+
initial_claims: "ICSA",
|
|
2528
|
+
core_pce: "PCEPILFE"
|
|
2529
|
+
};
|
|
2530
|
+
function resolveSeriesId(seriesIdOrAlias) {
|
|
2531
|
+
const lower = seriesIdOrAlias.toLowerCase();
|
|
2532
|
+
return COMMON_SERIES[lower] || seriesIdOrAlias.toUpperCase();
|
|
2533
|
+
}
|
|
2534
|
+
async function getEconomicCalendar(apiKey, limit = 60) {
|
|
2535
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2536
|
+
const cacheKey = `calendar:${today}:${limit}`;
|
|
2537
|
+
const cached = cache7.get(cacheKey);
|
|
2538
|
+
if (cached) return cached;
|
|
2539
|
+
const highImpactIds = new Set(Object.keys(HIGH_IMPACT_RELEASES).map(Number));
|
|
2540
|
+
const url = `${BASE_URL7}/releases/dates?api_key=${encodeURIComponent(apiKey)}&file_type=json&realtime_start=${today}&include_release_dates_with_no_data=true&sort_order=asc&limit=${limit}`;
|
|
2541
|
+
const data = await httpGet(url);
|
|
2542
|
+
const filtered = (data.release_dates || []).filter((r) => highImpactIds.has(r.release_id)).map((r) => ({
|
|
2543
|
+
release_id: r.release_id,
|
|
2544
|
+
release_name: r.release_name,
|
|
2545
|
+
date: r.date
|
|
2546
|
+
}));
|
|
2547
|
+
cache7.set(cacheKey, filtered);
|
|
2548
|
+
return filtered;
|
|
2549
|
+
}
|
|
2550
|
+
async function getIndicator(apiKey, seriesIdOrAlias) {
|
|
2551
|
+
const seriesId = resolveSeriesId(seriesIdOrAlias);
|
|
2552
|
+
const cacheKey = `indicator:${seriesId}`;
|
|
2553
|
+
const cached = cache7.get(cacheKey);
|
|
2554
|
+
if (cached) return cached;
|
|
2555
|
+
const baseParams = `api_key=${encodeURIComponent(apiKey)}&file_type=json`;
|
|
2556
|
+
const seriesUrl = `${BASE_URL7}/series?series_id=${encodeURIComponent(seriesId)}&${baseParams}`;
|
|
2557
|
+
const seriesData = await httpGet(seriesUrl);
|
|
2558
|
+
const s = seriesData.seriess[0];
|
|
2559
|
+
const obsUrl = `${BASE_URL7}/series/observations?series_id=${encodeURIComponent(seriesId)}&${baseParams}&sort_order=desc&limit=1`;
|
|
2560
|
+
const obsData = await httpGet(obsUrl);
|
|
2561
|
+
const obs = obsData.observations[0];
|
|
2562
|
+
const result = {
|
|
2563
|
+
series: {
|
|
2564
|
+
id: s.id,
|
|
2565
|
+
title: s.title,
|
|
2566
|
+
frequency: s.frequency,
|
|
2567
|
+
units: s.units,
|
|
2568
|
+
seasonal_adjustment: s.seasonal_adjustment,
|
|
2569
|
+
last_updated: s.last_updated
|
|
2570
|
+
},
|
|
2571
|
+
latest: {
|
|
2572
|
+
date: obs?.date || "",
|
|
2573
|
+
value: obs?.value === "." ? null : obs?.value || null
|
|
2574
|
+
}
|
|
2575
|
+
};
|
|
2576
|
+
cache7.set(cacheKey, result);
|
|
2577
|
+
return result;
|
|
2578
|
+
}
|
|
2579
|
+
async function getIndicatorHistory(apiKey, seriesIdOrAlias, startDate, endDate, units = "lin") {
|
|
2580
|
+
const seriesId = resolveSeriesId(seriesIdOrAlias);
|
|
2581
|
+
const cacheKey = `history:${seriesId}:${startDate}:${endDate}:${units}`;
|
|
2582
|
+
const cached = cache7.get(cacheKey);
|
|
2583
|
+
if (cached) return cached;
|
|
2584
|
+
const url = `${BASE_URL7}/series/observations?series_id=${encodeURIComponent(seriesId)}&api_key=${encodeURIComponent(apiKey)}&file_type=json&observation_start=${encodeURIComponent(startDate)}&observation_end=${encodeURIComponent(endDate)}&units=${encodeURIComponent(units)}&sort_order=asc`;
|
|
2585
|
+
const data = await httpGet(url);
|
|
2586
|
+
const result = (data.observations || []).filter((o) => o.value !== ".").map((o) => ({
|
|
2587
|
+
date: o.date,
|
|
2588
|
+
value: o.value
|
|
2589
|
+
}));
|
|
2590
|
+
cache7.set(cacheKey, result);
|
|
2591
|
+
return result;
|
|
2592
|
+
}
|
|
2593
|
+
async function searchSeries(apiKey, query, limit = 10) {
|
|
2594
|
+
const cacheKey = `search:${query}:${limit}`;
|
|
2595
|
+
const cached = cache7.get(cacheKey);
|
|
2596
|
+
if (cached) return cached;
|
|
2597
|
+
const url = `${BASE_URL7}/series/search?search_text=${encodeURIComponent(query)}&api_key=${encodeURIComponent(apiKey)}&file_type=json&limit=${limit}&order_by=search_rank`;
|
|
2598
|
+
const data = await httpGet(url);
|
|
2599
|
+
const result = (data.seriess || []).map((s) => ({
|
|
2600
|
+
id: s.id,
|
|
2601
|
+
title: s.title,
|
|
2602
|
+
frequency: s.frequency,
|
|
2603
|
+
units: s.units,
|
|
2604
|
+
popularity: s.popularity,
|
|
2605
|
+
last_updated: s.last_updated
|
|
2606
|
+
}));
|
|
2607
|
+
cache7.set(cacheKey, result);
|
|
2608
|
+
return result;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// src/modules/fred/index.ts
|
|
2612
|
+
function createFredModule(apiKey) {
|
|
2613
|
+
const metadata3 = { source: "fred", dataDelay: "varies by indicator" };
|
|
2614
|
+
const calendarTool = {
|
|
2615
|
+
name: "fred_economic_calendar",
|
|
2616
|
+
description: "Get upcoming US economic release dates (FOMC, CPI, PPI, NFP, GDP, PCE, jobless claims, retail sales, ISM, treasury rates). Filters to high-impact releases only. Use this to identify macro catalysts that could move markets. Rate limit: 120 calls/min (free tier).",
|
|
2617
|
+
inputSchema: z9.object({
|
|
2618
|
+
limit: z9.number().optional().default(60).describe("Max raw results to fetch before filtering (default: 60)")
|
|
2619
|
+
}),
|
|
2620
|
+
readOnly: true,
|
|
2621
|
+
handler: withMetadata(async (params) => {
|
|
2622
|
+
const releases = await getEconomicCalendar(apiKey, params.limit);
|
|
2623
|
+
return successResult(JSON.stringify(releases, null, 2));
|
|
2624
|
+
}, metadata3)
|
|
2625
|
+
};
|
|
2626
|
+
const indicatorTool = {
|
|
2627
|
+
name: "fred_indicator",
|
|
2628
|
+
description: "Get the latest value of a US economic indicator from FRED. Accepts FRED series IDs (e.g. 'CPIAUCSL', 'DFF', 'UNRATE') or common aliases: cpi, core_cpi, ppi, gdp, unemployment, nonfarm_payrolls, fed_funds, treasury_10y, treasury_2y, initial_claims, core_pce. Returns the indicator metadata (title, frequency, units) and latest observation. Rate limit: 120 calls/min (free tier).",
|
|
2629
|
+
inputSchema: z9.object({
|
|
2630
|
+
series_id: z9.string().describe("FRED series ID or alias (e.g. 'cpi', 'UNRATE', 'treasury_10y')")
|
|
2631
|
+
}),
|
|
2632
|
+
readOnly: true,
|
|
2633
|
+
handler: withMetadata(async (params) => {
|
|
2634
|
+
const result = await getIndicator(apiKey, params.series_id);
|
|
2635
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2636
|
+
}, metadata3)
|
|
2637
|
+
};
|
|
2638
|
+
const historyTool = {
|
|
2639
|
+
name: "fred_indicator_history",
|
|
2640
|
+
description: "Get historical values for a US economic indicator from FRED. Accepts same series IDs/aliases as fred_indicator. Supports units transformation: 'lin' (raw level), 'chg' (change), 'pc1' (% change from year ago \u2014 useful for YoY inflation), 'pch' (% change from prior period). Use 'pc1' with CPI/PPI to get YoY inflation rates directly. Rate limit: 120 calls/min (free tier).",
|
|
2641
|
+
inputSchema: z9.object({
|
|
2642
|
+
series_id: z9.string().describe("FRED series ID or alias"),
|
|
2643
|
+
start_date: z9.string().describe("Start date YYYY-MM-DD"),
|
|
2644
|
+
end_date: z9.string().describe("End date YYYY-MM-DD"),
|
|
2645
|
+
units: z9.enum(["lin", "chg", "ch1", "pch", "pc1", "pca"]).optional().default("lin").describe("Units: lin=level, pch=% change, pc1=YoY % change (default: lin)")
|
|
2646
|
+
}),
|
|
2647
|
+
readOnly: true,
|
|
2648
|
+
handler: withMetadata(async (params) => {
|
|
2649
|
+
const result = await getIndicatorHistory(
|
|
2650
|
+
apiKey,
|
|
2651
|
+
params.series_id,
|
|
2652
|
+
params.start_date,
|
|
2653
|
+
params.end_date,
|
|
2654
|
+
params.units
|
|
2655
|
+
);
|
|
2656
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2657
|
+
}, metadata3)
|
|
2658
|
+
};
|
|
2659
|
+
const searchTool = {
|
|
2660
|
+
name: "fred_search",
|
|
2661
|
+
description: "Search FRED for economic data series by keyword. Returns series IDs that can be used with fred_indicator and fred_indicator_history. Results sorted by relevance. Use to discover series IDs for niche indicators. Rate limit: 120 calls/min (free tier).",
|
|
2662
|
+
inputSchema: z9.object({
|
|
2663
|
+
query: z9.string().describe("Search keywords (e.g. 'consumer price index', 'housing starts')"),
|
|
2664
|
+
limit: z9.number().optional().default(10).describe("Max results (default: 10, max: 50)")
|
|
2665
|
+
}),
|
|
2666
|
+
readOnly: true,
|
|
2667
|
+
handler: withMetadata(async (params) => {
|
|
2668
|
+
const results = await searchSeries(apiKey, params.query, params.limit);
|
|
2669
|
+
return successResult(JSON.stringify(results, null, 2));
|
|
2670
|
+
}, metadata3)
|
|
2671
|
+
};
|
|
2672
|
+
return {
|
|
2673
|
+
name: "fred",
|
|
2674
|
+
description: "FRED economic data: calendar, indicators, interest rates, inflation",
|
|
2675
|
+
requiredEnvVars: ["FRED_API_KEY"],
|
|
2676
|
+
tools: [calendarTool, indicatorTool, historyTool, searchTool]
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// src/modules/sentiment/index.ts
|
|
2681
|
+
import { z as z10 } from "zod";
|
|
2682
|
+
|
|
2683
|
+
// src/modules/sentiment/client.ts
|
|
2684
|
+
var CNN_URL = "https://production.dataviz.cnn.io/index/fearandgreed/graphdata";
|
|
2685
|
+
var ALTERNATIVE_ME_URL = "https://api.alternative.me/fng/";
|
|
2686
|
+
var CNN_HEADERS = {
|
|
2687
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
2688
|
+
"Referer": "https://www.cnn.com/markets/fear-and-greed"
|
|
2689
|
+
};
|
|
2690
|
+
var CACHE_TTL8 = 60 * 60 * 1e3;
|
|
2691
|
+
var cache8 = new TtlCache(CACHE_TTL8);
|
|
2692
|
+
var INDICATOR_LABELS = {
|
|
2693
|
+
market_momentum_sp500: "S&P 500 Momentum",
|
|
2694
|
+
stock_price_strength: "Stock Price Strength (52w highs vs lows)",
|
|
2695
|
+
stock_price_breadth: "Stock Price Breadth (McClellan Volume)",
|
|
2696
|
+
put_call_options: "Put/Call Ratio",
|
|
2697
|
+
market_volatility_vix: "Market Volatility (VIX)",
|
|
2698
|
+
junk_bond_demand: "Junk Bond Demand (yield spread)",
|
|
2699
|
+
safe_haven_demand: "Safe Haven Demand (stocks vs bonds)"
|
|
2700
|
+
};
|
|
2701
|
+
async function getFearAndGreed() {
|
|
2702
|
+
const cacheKey = "sentiment:fear-greed";
|
|
2703
|
+
const cached = cache8.get(cacheKey);
|
|
2704
|
+
if (cached) return cached;
|
|
2705
|
+
const data = await httpGet(CNN_URL, {
|
|
2706
|
+
headers: CNN_HEADERS
|
|
2707
|
+
});
|
|
2708
|
+
const fg = data.fear_and_greed;
|
|
2709
|
+
if (!fg) throw new Error("CNN Fear & Greed API response missing 'fear_and_greed' field");
|
|
2710
|
+
const indicatorKeys = Object.keys(INDICATOR_LABELS);
|
|
2711
|
+
const indicators = indicatorKeys.map((key) => {
|
|
2712
|
+
const ind = data[key];
|
|
2713
|
+
return {
|
|
2714
|
+
name: INDICATOR_LABELS[key],
|
|
2715
|
+
score: Math.round(ind?.score ?? 0),
|
|
2716
|
+
rating: ind?.rating ?? "unknown"
|
|
2717
|
+
};
|
|
2718
|
+
});
|
|
2719
|
+
const result = {
|
|
2720
|
+
score: Math.round(fg.score),
|
|
2721
|
+
rating: fg.rating,
|
|
2722
|
+
previousClose: Math.round(fg.previous_close),
|
|
2723
|
+
previous1Week: Math.round(fg.previous_1_week),
|
|
2724
|
+
previous1Month: Math.round(fg.previous_1_month),
|
|
2725
|
+
previous1Year: Math.round(fg.previous_1_year),
|
|
2726
|
+
indicators
|
|
2727
|
+
};
|
|
2728
|
+
cache8.set(cacheKey, result);
|
|
2729
|
+
return result;
|
|
2730
|
+
}
|
|
2731
|
+
async function getCryptoFearAndGreed() {
|
|
2732
|
+
const cacheKey = "sentiment:crypto-fear-greed";
|
|
2733
|
+
const cached = cache8.get(cacheKey);
|
|
2734
|
+
if (cached) return cached;
|
|
2735
|
+
const data = await httpGet(ALTERNATIVE_ME_URL);
|
|
2736
|
+
const entry = data.data[0];
|
|
2737
|
+
if (!entry) throw new Error("No crypto Fear & Greed data available");
|
|
2738
|
+
const result = {
|
|
2739
|
+
score: parseInt(entry.value, 10),
|
|
2740
|
+
rating: entry.value_classification.toLowerCase(),
|
|
2741
|
+
timestamp: new Date(parseInt(entry.timestamp, 10) * 1e3).toISOString()
|
|
2742
|
+
};
|
|
2743
|
+
cache8.set(cacheKey, result);
|
|
2744
|
+
return result;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// src/modules/sentiment/index.ts
|
|
2748
|
+
var fearGreedTool = {
|
|
2749
|
+
name: "sentiment_fear_greed",
|
|
2750
|
+
description: "Get the CNN Fear & Greed Index for the US stock market. Returns a composite score (0-100) with rating (extreme fear/fear/neutral/greed/extreme greed) and 7 sub-indicators: S&P 500 momentum, stock price strength (52w highs vs lows), stock price breadth (McClellan), put/call ratio, VIX, junk bond demand, safe haven demand. Also includes previous close, 1-week, 1-month, and 1-year scores for trend context. Use this to gauge overall market sentiment before analyzing individual stocks.",
|
|
2751
|
+
inputSchema: z10.object({}),
|
|
2752
|
+
readOnly: true,
|
|
2753
|
+
handler: withMetadata(async () => {
|
|
2754
|
+
const result = await getFearAndGreed();
|
|
2755
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2756
|
+
}, { source: "cnn-fear-greed", dataDelay: "end of day" })
|
|
2757
|
+
};
|
|
2758
|
+
var cryptoFearGreedTool = {
|
|
2759
|
+
name: "sentiment_crypto_fear_greed",
|
|
2760
|
+
description: "Get the Crypto Fear & Greed Index from Alternative.me. Returns a score (0-100) with rating (extreme fear/fear/neutral/greed/extreme greed). Based on Bitcoin volatility, market volume, social media, surveys, dominance, and trends. Use this alongside coingecko tools for crypto market context.",
|
|
2761
|
+
inputSchema: z10.object({}),
|
|
2762
|
+
readOnly: true,
|
|
2763
|
+
handler: withMetadata(async () => {
|
|
2764
|
+
const result = await getCryptoFearAndGreed();
|
|
2765
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2766
|
+
}, { source: "alternative-me", dataDelay: "daily" })
|
|
2767
|
+
};
|
|
2768
|
+
function createSentimentModule() {
|
|
2769
|
+
return {
|
|
2770
|
+
name: "sentiment",
|
|
2771
|
+
description: "Market sentiment \u2014 CNN Fear & Greed Index and crypto sentiment",
|
|
2772
|
+
requiredEnvVars: [],
|
|
2773
|
+
tools: [fearGreedTool, cryptoFearGreedTool]
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// src/modules/frankfurter/index.ts
|
|
2778
|
+
import { z as z11 } from "zod";
|
|
2779
|
+
|
|
2780
|
+
// src/modules/frankfurter/client.ts
|
|
2781
|
+
var BASE_URL8 = "https://api.frankfurter.dev/v1";
|
|
2782
|
+
var CACHE_TTL_LATEST = 60 * 60 * 1e3;
|
|
2783
|
+
var CACHE_TTL_STATIC = 24 * 60 * 60 * 1e3;
|
|
2784
|
+
var MAX_TIMESERIES_DAYS = 90;
|
|
2785
|
+
var latestCache = new TtlCache(CACHE_TTL_LATEST);
|
|
2786
|
+
var staticCache = new TtlCache(CACHE_TTL_STATIC);
|
|
2787
|
+
function buildUrl(path3, params) {
|
|
2788
|
+
const url = new URL(`${BASE_URL8}${path3}`);
|
|
2789
|
+
for (const [key, value] of Object.entries(params)) {
|
|
2790
|
+
if (value !== void 0) {
|
|
2791
|
+
url.searchParams.set(key, value);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
return url.toString();
|
|
2795
|
+
}
|
|
2796
|
+
function capEndDate(startDate, endDate) {
|
|
2797
|
+
const start = new Date(startDate);
|
|
2798
|
+
const maxEnd = new Date(start.getTime() + MAX_TIMESERIES_DAYS * 24 * 60 * 60 * 1e3);
|
|
2799
|
+
const today = /* @__PURE__ */ new Date();
|
|
2800
|
+
today.setHours(0, 0, 0, 0);
|
|
2801
|
+
const upperBound = maxEnd < today ? maxEnd : today;
|
|
2802
|
+
if (!endDate) return upperBound.toISOString().split("T")[0];
|
|
2803
|
+
const requested = new Date(endDate);
|
|
2804
|
+
const capped = requested < upperBound ? requested : upperBound;
|
|
2805
|
+
return capped.toISOString().split("T")[0];
|
|
2806
|
+
}
|
|
2807
|
+
async function getLatestRates(base, symbols) {
|
|
2808
|
+
const cacheKey = `latest:${base}:${symbols ?? "all"}`;
|
|
2809
|
+
const cached = latestCache.get(cacheKey);
|
|
2810
|
+
if (cached) return cached;
|
|
2811
|
+
const url = buildUrl("/latest", { base, symbols });
|
|
2812
|
+
const data = await httpGet(url);
|
|
2813
|
+
latestCache.set(cacheKey, data);
|
|
2814
|
+
return data;
|
|
2815
|
+
}
|
|
2816
|
+
async function getHistoricalRates(base, date, symbols) {
|
|
2817
|
+
const cacheKey = `historical:${base}:${date}:${symbols ?? "all"}`;
|
|
2818
|
+
const cached = staticCache.get(cacheKey);
|
|
2819
|
+
if (cached) return cached;
|
|
2820
|
+
const url = buildUrl(`/${encodeURIComponent(date)}`, { base, symbols });
|
|
2821
|
+
const data = await httpGet(url);
|
|
2822
|
+
staticCache.set(cacheKey, data);
|
|
2823
|
+
return data;
|
|
2824
|
+
}
|
|
2825
|
+
async function getTimeSeries(base, symbols, startDate, endDate) {
|
|
2826
|
+
const cappedEnd = capEndDate(startDate, endDate);
|
|
2827
|
+
const cacheKey = `timeseries:${base}:${symbols}:${startDate}:${cappedEnd}`;
|
|
2828
|
+
const cached = latestCache.get(cacheKey);
|
|
2829
|
+
if (cached) return cached;
|
|
2830
|
+
const path3 = `/${encodeURIComponent(startDate)}..${encodeURIComponent(cappedEnd)}`;
|
|
2831
|
+
const url = buildUrl(path3, { base, symbols });
|
|
2832
|
+
const data = await httpGet(url);
|
|
2833
|
+
latestCache.set(cacheKey, data);
|
|
2834
|
+
return data;
|
|
2835
|
+
}
|
|
2836
|
+
async function convertCurrency(amount, from, to) {
|
|
2837
|
+
const cacheKey = `convert:${amount}:${from}:${to}`;
|
|
2838
|
+
const cached = latestCache.get(cacheKey);
|
|
2839
|
+
if (cached) return cached;
|
|
2840
|
+
const url = buildUrl("/latest", {
|
|
2841
|
+
amount: String(amount),
|
|
2842
|
+
base: from,
|
|
2843
|
+
symbols: to
|
|
2844
|
+
});
|
|
2845
|
+
const data = await httpGet(url);
|
|
2846
|
+
latestCache.set(cacheKey, data);
|
|
2847
|
+
return data;
|
|
2848
|
+
}
|
|
2849
|
+
async function getCurrencies() {
|
|
2850
|
+
const cacheKey = "currencies";
|
|
2851
|
+
const cached = staticCache.get(cacheKey);
|
|
2852
|
+
if (cached) return cached;
|
|
2853
|
+
const url = `${BASE_URL8}/currencies`;
|
|
2854
|
+
const data = await httpGet(url);
|
|
2855
|
+
staticCache.set(cacheKey, data);
|
|
2856
|
+
return data;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
// src/modules/frankfurter/index.ts
|
|
2860
|
+
var metadata2 = { source: "frankfurter", dataDelay: "end-of-day" };
|
|
2861
|
+
var currencyCode = z11.string().min(3).max(3).describe("ISO 4217 currency code (e.g. 'USD', 'EUR', 'GBP', 'JPY')");
|
|
2862
|
+
var latestTool = {
|
|
2863
|
+
name: "frankfurter_latest",
|
|
2864
|
+
description: "Get latest forex exchange rates from the European Central Bank. Returns daily reference rates for 31 major currencies. Updated once per business day at ~16:00 CET. These are reference rates, not real-time trading rates.",
|
|
2865
|
+
inputSchema: z11.object({
|
|
2866
|
+
base: currencyCode.default("USD").describe("Base currency"),
|
|
2867
|
+
symbols: z11.string().optional().describe(
|
|
2868
|
+
"Comma-separated target currencies (e.g. 'EUR,GBP,JPY'). Omit for all 31."
|
|
2869
|
+
)
|
|
2870
|
+
}),
|
|
2871
|
+
readOnly: true,
|
|
2872
|
+
handler: withMetadata(async (params) => {
|
|
2873
|
+
const result = await getLatestRates(
|
|
2874
|
+
params.base,
|
|
2875
|
+
params.symbols
|
|
2876
|
+
);
|
|
2877
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2878
|
+
}, metadata2)
|
|
2879
|
+
};
|
|
2880
|
+
var historicalTool = {
|
|
2881
|
+
name: "frankfurter_historical",
|
|
2882
|
+
description: "Get ECB forex exchange rates for a specific past date. Daily reference rates, not real-time trading rates. If the date is a weekend or holiday, returns the previous business day's rates. Data available from 1999-01-04 (Euro inception).",
|
|
2883
|
+
inputSchema: z11.object({
|
|
2884
|
+
date: z11.string().describe("Date in YYYY-MM-DD format"),
|
|
2885
|
+
base: currencyCode.default("USD").describe("Base currency"),
|
|
2886
|
+
symbols: z11.string().optional().describe("Comma-separated target currencies. Omit for all.")
|
|
2887
|
+
}),
|
|
2888
|
+
readOnly: true,
|
|
2889
|
+
handler: withMetadata(async (params) => {
|
|
2890
|
+
const result = await getHistoricalRates(
|
|
2891
|
+
params.base,
|
|
2892
|
+
params.date,
|
|
2893
|
+
params.symbols
|
|
2894
|
+
);
|
|
2895
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2896
|
+
}, metadata2)
|
|
2897
|
+
};
|
|
2898
|
+
var timeseriesTool = {
|
|
2899
|
+
name: "frankfurter_timeseries",
|
|
2900
|
+
description: "Get daily ECB forex rate history for a date range (max 90 days). Daily reference rates, not real-time. Use for currency trend analysis. Only business days included (weekends/holidays omitted). Requires symbols filter to control response size.",
|
|
2901
|
+
inputSchema: z11.object({
|
|
2902
|
+
start_date: z11.string().describe("Start date (YYYY-MM-DD)"),
|
|
2903
|
+
end_date: z11.string().optional().describe(
|
|
2904
|
+
"End date (YYYY-MM-DD, defaults to today, max 90 days from start)"
|
|
2905
|
+
),
|
|
2906
|
+
base: currencyCode.default("USD").describe("Base currency"),
|
|
2907
|
+
symbols: z11.string().describe(
|
|
2908
|
+
"Required: comma-separated target currencies (e.g. 'EUR,GBP')"
|
|
2909
|
+
)
|
|
2910
|
+
}),
|
|
2911
|
+
readOnly: true,
|
|
2912
|
+
handler: withMetadata(async (params) => {
|
|
2913
|
+
const result = await getTimeSeries(
|
|
2914
|
+
params.base,
|
|
2915
|
+
params.symbols,
|
|
2916
|
+
params.start_date,
|
|
2917
|
+
params.end_date
|
|
2918
|
+
);
|
|
2919
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2920
|
+
}, metadata2)
|
|
2921
|
+
};
|
|
2922
|
+
var convertTool = {
|
|
2923
|
+
name: "frankfurter_convert",
|
|
2924
|
+
description: "Convert an amount between two currencies at the latest ECB daily reference rate. Updated once per business day \u2014 not suitable for intraday trading rates. Useful for cross-border stock valuation and currency exposure calculations.",
|
|
2925
|
+
inputSchema: z11.object({
|
|
2926
|
+
amount: z11.number().describe("Amount to convert"),
|
|
2927
|
+
from: currencyCode.describe("Source currency (e.g. 'USD')"),
|
|
2928
|
+
to: currencyCode.describe("Target currency (e.g. 'EUR')")
|
|
2929
|
+
}),
|
|
2930
|
+
readOnly: true,
|
|
2931
|
+
handler: withMetadata(async (params) => {
|
|
2932
|
+
const result = await convertCurrency(
|
|
2933
|
+
params.amount,
|
|
2934
|
+
params.from,
|
|
2935
|
+
params.to
|
|
2936
|
+
);
|
|
2937
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2938
|
+
}, metadata2)
|
|
2939
|
+
};
|
|
2940
|
+
var currenciesTool = {
|
|
2941
|
+
name: "frankfurter_currencies",
|
|
2942
|
+
description: "List all 31 currencies supported by the Frankfurter API with their full names. Use to look up valid currency codes before calling other frankfurter tools.",
|
|
2943
|
+
inputSchema: z11.object({}),
|
|
2944
|
+
readOnly: true,
|
|
2945
|
+
handler: withMetadata(async () => {
|
|
2946
|
+
const result = await getCurrencies();
|
|
2947
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
2948
|
+
}, metadata2)
|
|
2949
|
+
};
|
|
2950
|
+
function createFrankfurterModule() {
|
|
2951
|
+
return {
|
|
2952
|
+
name: "frankfurter",
|
|
2953
|
+
description: "Forex exchange rates from ECB \u2014 daily reference rates for 31 currencies, no API key required",
|
|
2954
|
+
requiredEnvVars: [],
|
|
2955
|
+
tools: [
|
|
2956
|
+
latestTool,
|
|
2957
|
+
historicalTool,
|
|
2958
|
+
timeseriesTool,
|
|
2959
|
+
convertTool,
|
|
2960
|
+
currenciesTool
|
|
2961
|
+
]
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
// src/modules/reddit/index.ts
|
|
2966
|
+
import { z as z12 } from "zod";
|
|
2967
|
+
|
|
2968
|
+
// src/modules/reddit/client.ts
|
|
2969
|
+
var REDDIT_BASE = "https://www.reddit.com";
|
|
2970
|
+
var REDDIT_HEADERS = {
|
|
2971
|
+
"User-Agent": "stock-scanner-mcp/1.0 (by /u/stock-scanner-bot)"
|
|
2972
|
+
};
|
|
2973
|
+
var DEFAULT_SUBREDDITS = [
|
|
2974
|
+
"wallstreetbets",
|
|
2975
|
+
"stocks",
|
|
2976
|
+
"investing",
|
|
2977
|
+
"options"
|
|
2978
|
+
];
|
|
2979
|
+
var trendingCache = new TtlCache(5 * 60 * 1e3);
|
|
2980
|
+
var mentionCache = new TtlCache(2 * 60 * 1e3);
|
|
2981
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
2982
|
+
// 1-2 letter
|
|
2983
|
+
"I",
|
|
2984
|
+
"A",
|
|
2985
|
+
"AM",
|
|
2986
|
+
"AN",
|
|
2987
|
+
"AS",
|
|
2988
|
+
"AT",
|
|
2989
|
+
"BE",
|
|
2990
|
+
"BY",
|
|
2991
|
+
"DO",
|
|
2992
|
+
"GO",
|
|
2993
|
+
"HE",
|
|
2994
|
+
"IF",
|
|
2995
|
+
"IN",
|
|
2996
|
+
"IS",
|
|
2997
|
+
"IT",
|
|
2998
|
+
"ME",
|
|
2999
|
+
"MY",
|
|
3000
|
+
"NO",
|
|
3001
|
+
"OF",
|
|
3002
|
+
"OK",
|
|
3003
|
+
"ON",
|
|
3004
|
+
"OR",
|
|
3005
|
+
"SO",
|
|
3006
|
+
"TO",
|
|
3007
|
+
"UP",
|
|
3008
|
+
"US",
|
|
3009
|
+
"WE",
|
|
3010
|
+
// 3 letter common English
|
|
3011
|
+
"ALL",
|
|
3012
|
+
"AND",
|
|
3013
|
+
"ANY",
|
|
3014
|
+
"ARE",
|
|
3015
|
+
"BAD",
|
|
3016
|
+
"BIG",
|
|
3017
|
+
"BUT",
|
|
3018
|
+
"CAN",
|
|
3019
|
+
"DAY",
|
|
3020
|
+
"DID",
|
|
3021
|
+
"END",
|
|
3022
|
+
"FAR",
|
|
3023
|
+
"FEW",
|
|
3024
|
+
"FOR",
|
|
3025
|
+
"GET",
|
|
3026
|
+
"GOD",
|
|
3027
|
+
"GOT",
|
|
3028
|
+
"GUY",
|
|
3029
|
+
"HAS",
|
|
3030
|
+
"HAD",
|
|
3031
|
+
"HER",
|
|
3032
|
+
"HIM",
|
|
3033
|
+
"HIS",
|
|
3034
|
+
"HOW",
|
|
3035
|
+
"ITS",
|
|
3036
|
+
"JOB",
|
|
3037
|
+
"LET",
|
|
3038
|
+
"LOT",
|
|
3039
|
+
"MAN",
|
|
3040
|
+
"MAY",
|
|
3041
|
+
"MEN",
|
|
3042
|
+
"MOM",
|
|
3043
|
+
"NEW",
|
|
3044
|
+
"NOT",
|
|
3045
|
+
"NOW",
|
|
3046
|
+
"OLD",
|
|
3047
|
+
"ONE",
|
|
3048
|
+
"OUR",
|
|
3049
|
+
"OUT",
|
|
3050
|
+
"OWN",
|
|
3051
|
+
"PUT",
|
|
3052
|
+
"RAN",
|
|
3053
|
+
"RUN",
|
|
3054
|
+
"SAT",
|
|
3055
|
+
"SAY",
|
|
3056
|
+
"SET",
|
|
3057
|
+
"SHE",
|
|
3058
|
+
"SIT",
|
|
3059
|
+
"THE",
|
|
3060
|
+
"TOO",
|
|
3061
|
+
"TOP",
|
|
3062
|
+
"TRY",
|
|
3063
|
+
"TWO",
|
|
3064
|
+
"USE",
|
|
3065
|
+
"WAR",
|
|
3066
|
+
"WAS",
|
|
3067
|
+
"WAY",
|
|
3068
|
+
"WHO",
|
|
3069
|
+
"WHY",
|
|
3070
|
+
"WIN",
|
|
3071
|
+
"WON",
|
|
3072
|
+
"YES",
|
|
3073
|
+
"YET",
|
|
3074
|
+
"YOU",
|
|
3075
|
+
// 4 letter common English
|
|
3076
|
+
"ALSO",
|
|
3077
|
+
"BACK",
|
|
3078
|
+
"BEEN",
|
|
3079
|
+
"BEST",
|
|
3080
|
+
"BODY",
|
|
3081
|
+
"BOTH",
|
|
3082
|
+
"CALL",
|
|
3083
|
+
"CAME",
|
|
3084
|
+
"COME",
|
|
3085
|
+
"DARK",
|
|
3086
|
+
"DAYS",
|
|
3087
|
+
"DEAD",
|
|
3088
|
+
"DOES",
|
|
3089
|
+
"DONE",
|
|
3090
|
+
"DOWN",
|
|
3091
|
+
"EACH",
|
|
3092
|
+
"EVEN",
|
|
3093
|
+
"EVER",
|
|
3094
|
+
"EYES",
|
|
3095
|
+
"FACE",
|
|
3096
|
+
"FACT",
|
|
3097
|
+
"FEEL",
|
|
3098
|
+
"FIND",
|
|
3099
|
+
"FOUR",
|
|
3100
|
+
"FROM",
|
|
3101
|
+
"FULL",
|
|
3102
|
+
"GAVE",
|
|
3103
|
+
"GIVE",
|
|
3104
|
+
"GOOD",
|
|
3105
|
+
"HALF",
|
|
3106
|
+
"HAND",
|
|
3107
|
+
"HARD",
|
|
3108
|
+
"HAVE",
|
|
3109
|
+
"HEAD",
|
|
3110
|
+
"HEAR",
|
|
3111
|
+
"HELP",
|
|
3112
|
+
"HERE",
|
|
3113
|
+
"HIGH",
|
|
3114
|
+
"HOME",
|
|
3115
|
+
"HOPE",
|
|
3116
|
+
"IDEA",
|
|
3117
|
+
"INTO",
|
|
3118
|
+
"JUST",
|
|
3119
|
+
"KEEP",
|
|
3120
|
+
"KIND",
|
|
3121
|
+
"KNEW",
|
|
3122
|
+
"KNOW",
|
|
3123
|
+
"LAND",
|
|
3124
|
+
"LAST",
|
|
3125
|
+
"LEFT",
|
|
3126
|
+
"LESS",
|
|
3127
|
+
"LIFE",
|
|
3128
|
+
"LIKE",
|
|
3129
|
+
"LINE",
|
|
3130
|
+
"LIST",
|
|
3131
|
+
"LIVE",
|
|
3132
|
+
"LONG",
|
|
3133
|
+
"LOOK",
|
|
3134
|
+
"LOST",
|
|
3135
|
+
"LOVE",
|
|
3136
|
+
"MADE",
|
|
3137
|
+
"MAKE",
|
|
3138
|
+
"MANY",
|
|
3139
|
+
"MIND",
|
|
3140
|
+
"MORE",
|
|
3141
|
+
"MOST",
|
|
3142
|
+
"MUCH",
|
|
3143
|
+
"MUST",
|
|
3144
|
+
"NAME",
|
|
3145
|
+
"NEED",
|
|
3146
|
+
"NEWS",
|
|
3147
|
+
"NEXT",
|
|
3148
|
+
"NICE",
|
|
3149
|
+
"NONE",
|
|
3150
|
+
"ONCE",
|
|
3151
|
+
"ONLY",
|
|
3152
|
+
"OPEN",
|
|
3153
|
+
"OVER",
|
|
3154
|
+
"PAID",
|
|
3155
|
+
"PART",
|
|
3156
|
+
"PAST",
|
|
3157
|
+
"PLAN",
|
|
3158
|
+
"PLAY",
|
|
3159
|
+
"POST",
|
|
3160
|
+
"PULL",
|
|
3161
|
+
"PUSH",
|
|
3162
|
+
"RATE",
|
|
3163
|
+
"READ",
|
|
3164
|
+
"REAL",
|
|
3165
|
+
"REST",
|
|
3166
|
+
"RISK",
|
|
3167
|
+
"ROOM",
|
|
3168
|
+
"SAFE",
|
|
3169
|
+
"SAID",
|
|
3170
|
+
"SAME",
|
|
3171
|
+
"SEEN",
|
|
3172
|
+
"SHOW",
|
|
3173
|
+
"SIDE",
|
|
3174
|
+
"SOME",
|
|
3175
|
+
"SOON",
|
|
3176
|
+
"STOP",
|
|
3177
|
+
"SUCH",
|
|
3178
|
+
"SURE",
|
|
3179
|
+
"TAKE",
|
|
3180
|
+
"TALK",
|
|
3181
|
+
"TELL",
|
|
3182
|
+
"THAN",
|
|
3183
|
+
"THAT",
|
|
3184
|
+
"THEM",
|
|
3185
|
+
"THEN",
|
|
3186
|
+
"THEY",
|
|
3187
|
+
"THIS",
|
|
3188
|
+
"TIME",
|
|
3189
|
+
"TOOK",
|
|
3190
|
+
"TRUE",
|
|
3191
|
+
"TURN",
|
|
3192
|
+
"UPON",
|
|
3193
|
+
"VERY",
|
|
3194
|
+
"WANT",
|
|
3195
|
+
"WEEK",
|
|
3196
|
+
"WELL",
|
|
3197
|
+
"WENT",
|
|
3198
|
+
"WERE",
|
|
3199
|
+
"WHAT",
|
|
3200
|
+
"WHEN",
|
|
3201
|
+
"WILL",
|
|
3202
|
+
"WITH",
|
|
3203
|
+
"WORD",
|
|
3204
|
+
"WORK",
|
|
3205
|
+
"YEAR",
|
|
3206
|
+
"YOUR",
|
|
3207
|
+
// 5 letter common English
|
|
3208
|
+
"ABOUT",
|
|
3209
|
+
"AFTER",
|
|
3210
|
+
"AGAIN",
|
|
3211
|
+
"BEING",
|
|
3212
|
+
"BELOW",
|
|
3213
|
+
"BLACK",
|
|
3214
|
+
"BRING",
|
|
3215
|
+
"COULD",
|
|
3216
|
+
"EARLY",
|
|
3217
|
+
"EVERY",
|
|
3218
|
+
"FIRST",
|
|
3219
|
+
"GREAT",
|
|
3220
|
+
"GREEN",
|
|
3221
|
+
"HEARD",
|
|
3222
|
+
"HOUSE",
|
|
3223
|
+
"LARGE",
|
|
3224
|
+
"LATER",
|
|
3225
|
+
"LEARN",
|
|
3226
|
+
"LEVEL",
|
|
3227
|
+
"LIGHT",
|
|
3228
|
+
"MIGHT",
|
|
3229
|
+
"MONEY",
|
|
3230
|
+
"MONTH",
|
|
3231
|
+
"NEVER",
|
|
3232
|
+
"NIGHT",
|
|
3233
|
+
"OTHER",
|
|
3234
|
+
"PLACE",
|
|
3235
|
+
"POINT",
|
|
3236
|
+
"POWER",
|
|
3237
|
+
"PRICE",
|
|
3238
|
+
"RIGHT",
|
|
3239
|
+
"SHALL",
|
|
3240
|
+
"SINCE",
|
|
3241
|
+
"SMALL",
|
|
3242
|
+
"SORRY",
|
|
3243
|
+
"STAND",
|
|
3244
|
+
"START",
|
|
3245
|
+
"STATE",
|
|
3246
|
+
"STILL",
|
|
3247
|
+
"STUDY",
|
|
3248
|
+
"THEIR",
|
|
3249
|
+
"THERE",
|
|
3250
|
+
"THESE",
|
|
3251
|
+
"THING",
|
|
3252
|
+
"THINK",
|
|
3253
|
+
"THREE",
|
|
3254
|
+
"TODAY",
|
|
3255
|
+
"UNDER",
|
|
3256
|
+
"UNTIL",
|
|
3257
|
+
"WATER",
|
|
3258
|
+
"WHERE",
|
|
3259
|
+
"WHICH",
|
|
3260
|
+
"WHITE",
|
|
3261
|
+
"WHILE",
|
|
3262
|
+
"WHOLE",
|
|
3263
|
+
"WORLD",
|
|
3264
|
+
"WOULD",
|
|
3265
|
+
"WRITE",
|
|
3266
|
+
"YOUNG",
|
|
3267
|
+
// Finance acronyms that aren't tickers
|
|
3268
|
+
"ETF",
|
|
3269
|
+
"IPO",
|
|
3270
|
+
"SEC",
|
|
3271
|
+
"GDP",
|
|
3272
|
+
"FDA",
|
|
3273
|
+
"FED",
|
|
3274
|
+
"CEO",
|
|
3275
|
+
"CFO",
|
|
3276
|
+
"COO",
|
|
3277
|
+
"CTO",
|
|
3278
|
+
"ATH",
|
|
3279
|
+
"ATL",
|
|
3280
|
+
"EOD",
|
|
3281
|
+
"WSB",
|
|
3282
|
+
"OTC",
|
|
3283
|
+
"ITM",
|
|
3284
|
+
"OTM",
|
|
3285
|
+
"IMO",
|
|
3286
|
+
"LOL",
|
|
3287
|
+
"OMG",
|
|
3288
|
+
"USA",
|
|
3289
|
+
"NYSE",
|
|
3290
|
+
"YOLO",
|
|
3291
|
+
"FOMO",
|
|
3292
|
+
"HODL",
|
|
3293
|
+
"MOASS",
|
|
3294
|
+
"TLDR",
|
|
3295
|
+
"IMHO",
|
|
3296
|
+
"LMAO",
|
|
3297
|
+
"EDIT",
|
|
3298
|
+
"TLDR",
|
|
3299
|
+
"INFO",
|
|
3300
|
+
"HUGE",
|
|
3301
|
+
"GAIN",
|
|
3302
|
+
"HOLD",
|
|
3303
|
+
"SELL",
|
|
3304
|
+
"MOON",
|
|
3305
|
+
"CASH",
|
|
3306
|
+
"DEBT",
|
|
3307
|
+
"FUND",
|
|
3308
|
+
"BEAR",
|
|
3309
|
+
"BULL",
|
|
3310
|
+
"BOND",
|
|
3311
|
+
"LOAN",
|
|
3312
|
+
"LOSS",
|
|
3313
|
+
"PUMP",
|
|
3314
|
+
"DUMP",
|
|
3315
|
+
"LONG",
|
|
3316
|
+
"MAMA",
|
|
3317
|
+
"PAPA",
|
|
3318
|
+
"FREE",
|
|
3319
|
+
"ZERO",
|
|
3320
|
+
"PLUS"
|
|
3321
|
+
]);
|
|
3322
|
+
var CASHTAG_RE = /\$([A-Z]{2,5})\b/g;
|
|
3323
|
+
var BARE_TICKER_RE = /\b([A-Z]{2,5})\b/g;
|
|
3324
|
+
function extractTickers(text) {
|
|
3325
|
+
const tickers = /* @__PURE__ */ new Set();
|
|
3326
|
+
let match;
|
|
3327
|
+
CASHTAG_RE.lastIndex = 0;
|
|
3328
|
+
while ((match = CASHTAG_RE.exec(text)) !== null) {
|
|
3329
|
+
const ticker = match[1];
|
|
3330
|
+
if (!STOP_WORDS.has(ticker)) {
|
|
3331
|
+
tickers.add(ticker);
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
BARE_TICKER_RE.lastIndex = 0;
|
|
3335
|
+
while ((match = BARE_TICKER_RE.exec(text)) !== null) {
|
|
3336
|
+
const word = match[1];
|
|
3337
|
+
if (!STOP_WORDS.has(word)) {
|
|
3338
|
+
tickers.add(word);
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
return [...tickers];
|
|
3342
|
+
}
|
|
3343
|
+
var BULLISH_TERMS = [
|
|
3344
|
+
"bull",
|
|
3345
|
+
"calls",
|
|
3346
|
+
"long",
|
|
3347
|
+
"moon",
|
|
3348
|
+
"rocket",
|
|
3349
|
+
"tendies",
|
|
3350
|
+
"diamond hands",
|
|
3351
|
+
"buy",
|
|
3352
|
+
"buying",
|
|
3353
|
+
"bought",
|
|
3354
|
+
"dip",
|
|
3355
|
+
"undervalued",
|
|
3356
|
+
"breakout",
|
|
3357
|
+
"squeeze",
|
|
3358
|
+
"gamma",
|
|
3359
|
+
"rip",
|
|
3360
|
+
"printing",
|
|
3361
|
+
"lambo",
|
|
3362
|
+
"gains",
|
|
3363
|
+
"green",
|
|
3364
|
+
"upside"
|
|
3365
|
+
];
|
|
3366
|
+
var BEARISH_TERMS = [
|
|
3367
|
+
"bear",
|
|
3368
|
+
"puts",
|
|
3369
|
+
"short",
|
|
3370
|
+
"dump",
|
|
3371
|
+
"crash",
|
|
3372
|
+
"sell",
|
|
3373
|
+
"selling",
|
|
3374
|
+
"sold",
|
|
3375
|
+
"overvalued",
|
|
3376
|
+
"bubble",
|
|
3377
|
+
"bag",
|
|
3378
|
+
"bagholder",
|
|
3379
|
+
"red",
|
|
3380
|
+
"loss",
|
|
3381
|
+
"losses",
|
|
3382
|
+
"downside",
|
|
3383
|
+
"drill",
|
|
3384
|
+
"tank",
|
|
3385
|
+
"fade",
|
|
3386
|
+
"rug",
|
|
3387
|
+
"rekt",
|
|
3388
|
+
"worthless"
|
|
3389
|
+
];
|
|
3390
|
+
function scoreSentiment(text) {
|
|
3391
|
+
const lower = text.toLowerCase();
|
|
3392
|
+
let score = 0;
|
|
3393
|
+
for (const term of BULLISH_TERMS) {
|
|
3394
|
+
if (lower.includes(term)) score += 1;
|
|
3395
|
+
}
|
|
3396
|
+
for (const term of BEARISH_TERMS) {
|
|
3397
|
+
if (lower.includes(term)) score -= 1;
|
|
3398
|
+
}
|
|
3399
|
+
return score;
|
|
3400
|
+
}
|
|
3401
|
+
async function fetchSubredditPosts(subreddit, query, timeFilter, limit = 25) {
|
|
3402
|
+
const url = `${REDDIT_BASE}/r/${encodeURIComponent(subreddit)}/search.json?q=${encodeURIComponent(query)}&sort=new&restrict_sr=on&t=${encodeURIComponent(timeFilter)}&limit=${encodeURIComponent(String(limit))}`;
|
|
3403
|
+
const listing = await httpGet(url, {
|
|
3404
|
+
headers: REDDIT_HEADERS
|
|
3405
|
+
});
|
|
3406
|
+
return listing.data.children.map((child) => ({
|
|
3407
|
+
title: child.data.title,
|
|
3408
|
+
selftext: child.data.selftext,
|
|
3409
|
+
score: child.data.score,
|
|
3410
|
+
numComments: child.data.num_comments,
|
|
3411
|
+
createdUtc: child.data.created_utc,
|
|
3412
|
+
permalink: child.data.permalink,
|
|
3413
|
+
subreddit: child.data.subreddit
|
|
3414
|
+
}));
|
|
3415
|
+
}
|
|
3416
|
+
async function getTrendingTickers(subreddits = DEFAULT_SUBREDDITS, limit = 20) {
|
|
3417
|
+
const cacheKey = `trending:${[...subreddits].sort().join(",")}:${limit}`;
|
|
3418
|
+
const cached = trendingCache.get(cacheKey);
|
|
3419
|
+
if (cached) return cached;
|
|
3420
|
+
const tickerMap = /* @__PURE__ */ new Map();
|
|
3421
|
+
for (const sub of subreddits) {
|
|
3422
|
+
let posts;
|
|
3423
|
+
try {
|
|
3424
|
+
posts = await fetchSubredditPosts(sub, "stocks OR market OR trading", "week");
|
|
3425
|
+
} catch {
|
|
3426
|
+
continue;
|
|
3427
|
+
}
|
|
3428
|
+
for (const post of posts) {
|
|
3429
|
+
const text = `${post.title} ${post.selftext}`;
|
|
3430
|
+
const tickers = extractTickers(text);
|
|
3431
|
+
for (const ticker of tickers) {
|
|
3432
|
+
const existing = tickerMap.get(ticker) ?? { mentions: 0, subreddits: {} };
|
|
3433
|
+
existing.mentions += 1;
|
|
3434
|
+
existing.subreddits[sub] = (existing.subreddits[sub] ?? 0) + 1;
|
|
3435
|
+
tickerMap.set(ticker, existing);
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
const result = [...tickerMap.entries()].map(([symbol, data]) => ({ symbol, mentions: data.mentions, subreddits: data.subreddits })).sort((a, b) => b.mentions - a.mentions).slice(0, limit);
|
|
3440
|
+
trendingCache.set(cacheKey, result);
|
|
3441
|
+
return result;
|
|
3442
|
+
}
|
|
3443
|
+
async function getTickerMentions(symbol, period = "week") {
|
|
3444
|
+
const cacheKey = `mentions:${symbol}:${period}`;
|
|
3445
|
+
const cached = mentionCache.get(cacheKey);
|
|
3446
|
+
if (cached) return cached;
|
|
3447
|
+
const subredditCounts = {};
|
|
3448
|
+
const allPosts = [];
|
|
3449
|
+
for (const sub of DEFAULT_SUBREDDITS) {
|
|
3450
|
+
let posts;
|
|
3451
|
+
try {
|
|
3452
|
+
posts = await fetchSubredditPosts(sub, symbol, period);
|
|
3453
|
+
} catch {
|
|
3454
|
+
continue;
|
|
3455
|
+
}
|
|
3456
|
+
subredditCounts[sub] = posts.length;
|
|
3457
|
+
allPosts.push(...posts);
|
|
3458
|
+
}
|
|
3459
|
+
const topPosts = allPosts.sort((a, b) => b.score - a.score).slice(0, 10);
|
|
3460
|
+
const result = {
|
|
3461
|
+
symbol,
|
|
3462
|
+
totalMentions: allPosts.length,
|
|
3463
|
+
subreddits: subredditCounts,
|
|
3464
|
+
topPosts
|
|
3465
|
+
};
|
|
3466
|
+
mentionCache.set(cacheKey, result);
|
|
3467
|
+
return result;
|
|
3468
|
+
}
|
|
3469
|
+
async function getTickerSentiment(symbol, limit = 50) {
|
|
3470
|
+
const cacheKey = `sentiment:${symbol}:${limit}`;
|
|
3471
|
+
const cached = mentionCache.get(cacheKey);
|
|
3472
|
+
if (cached) return cached;
|
|
3473
|
+
const allPosts = [];
|
|
3474
|
+
for (const sub of DEFAULT_SUBREDDITS) {
|
|
3475
|
+
let posts;
|
|
3476
|
+
try {
|
|
3477
|
+
posts = await fetchSubredditPosts(sub, symbol, "week", limit);
|
|
3478
|
+
} catch {
|
|
3479
|
+
continue;
|
|
3480
|
+
}
|
|
3481
|
+
allPosts.push(...posts);
|
|
3482
|
+
}
|
|
3483
|
+
let bullishCount = 0;
|
|
3484
|
+
let bearishCount = 0;
|
|
3485
|
+
let neutralCount = 0;
|
|
3486
|
+
let totalScore = 0;
|
|
3487
|
+
const scored = allPosts.map((post) => {
|
|
3488
|
+
const text = `${post.title} ${post.selftext}`;
|
|
3489
|
+
const sentimentScore = scoreSentiment(text);
|
|
3490
|
+
totalScore += sentimentScore;
|
|
3491
|
+
if (sentimentScore > 0) bullishCount += 1;
|
|
3492
|
+
else if (sentimentScore < 0) bearishCount += 1;
|
|
3493
|
+
else neutralCount += 1;
|
|
3494
|
+
return { ...post, sentimentScore };
|
|
3495
|
+
});
|
|
3496
|
+
const samplePosts = scored.sort((a, b) => Math.abs(b.sentimentScore) - Math.abs(a.sentimentScore)).slice(0, 10);
|
|
3497
|
+
const result = {
|
|
3498
|
+
symbol,
|
|
3499
|
+
bullishCount,
|
|
3500
|
+
bearishCount,
|
|
3501
|
+
neutralCount,
|
|
3502
|
+
averageScore: allPosts.length > 0 ? totalScore / allPosts.length : 0,
|
|
3503
|
+
samplePosts
|
|
3504
|
+
};
|
|
3505
|
+
mentionCache.set(cacheKey, result);
|
|
3506
|
+
return result;
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
// src/modules/reddit/index.ts
|
|
3510
|
+
var trendingTool2 = {
|
|
3511
|
+
name: "reddit_trending",
|
|
3512
|
+
description: "Get trending stock tickers from Reddit based on mention frequency. Scans r/wallstreetbets, r/stocks, r/investing, and r/options for posts mentioning tickers. Returns tickers sorted by mention count with per-subreddit breakdown. Limitation: uses keyword extraction (cashtags + uppercase words), not NLP \u2014 some false positives possible. Best for gauging retail buzz, not precise sentiment.",
|
|
3513
|
+
inputSchema: z12.object({
|
|
3514
|
+
subreddits: z12.array(z12.string()).max(10).optional().describe("Subreddits to scan (default: wallstreetbets, stocks, investing, options)"),
|
|
3515
|
+
limit: z12.number().min(1).max(50).default(20).describe("Maximum number of trending tickers to return (default: 20)")
|
|
3516
|
+
}),
|
|
3517
|
+
readOnly: true,
|
|
3518
|
+
handler: withMetadata(async (args) => {
|
|
3519
|
+
const result = await getTrendingTickers(args.subreddits ?? [...DEFAULT_SUBREDDITS], args.limit ?? 20);
|
|
3520
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
3521
|
+
}, { source: "reddit", dataDelay: "real-time" })
|
|
3522
|
+
};
|
|
3523
|
+
var mentionsTool = {
|
|
3524
|
+
name: "reddit_mentions",
|
|
3525
|
+
description: "Get mention count and top posts for a specific stock ticker across Reddit. Searches r/wallstreetbets, r/stocks, r/investing, and r/options. Returns total mentions, per-subreddit breakdown, and top 10 posts by score. Use this to check how much retail attention a ticker is getting.",
|
|
3526
|
+
inputSchema: z12.object({
|
|
3527
|
+
symbol: z12.string().max(10).describe("Stock ticker symbol (e.g. AAPL, TSLA, GME)"),
|
|
3528
|
+
period: z12.enum(["hour", "day", "week"]).default("day").describe("Time period to search (default: day)")
|
|
3529
|
+
}),
|
|
3530
|
+
readOnly: true,
|
|
3531
|
+
handler: withMetadata(async (args) => {
|
|
3532
|
+
const result = await getTickerMentions(args.symbol.toUpperCase(), args.period ?? "day");
|
|
3533
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
3534
|
+
}, { source: "reddit", dataDelay: "real-time" })
|
|
3535
|
+
};
|
|
3536
|
+
var sentimentTool = {
|
|
3537
|
+
name: "reddit_sentiment",
|
|
3538
|
+
description: "Get sentiment analysis for a stock ticker from Reddit discussions. Searches r/wallstreetbets, r/stocks, r/investing, and r/options, then scores each post using keyword matching (bullish terms like 'moon', 'calls', 'breakout' vs bearish terms like 'crash', 'puts', 'dump'). Returns bullish/bearish/neutral counts, average sentiment score, and sample posts. Limitation: keyword-based scoring, not NLP \u2014 sarcasm and context may be missed.",
|
|
3539
|
+
inputSchema: z12.object({
|
|
3540
|
+
symbol: z12.string().max(10).describe("Stock ticker symbol (e.g. AAPL, TSLA, GME)"),
|
|
3541
|
+
limit: z12.number().min(1).max(100).default(50).describe("Maximum posts to analyze per subreddit (default: 50)")
|
|
3542
|
+
}),
|
|
3543
|
+
readOnly: true,
|
|
3544
|
+
handler: withMetadata(async (args) => {
|
|
3545
|
+
const result = await getTickerSentiment(args.symbol.toUpperCase(), args.limit ?? 50);
|
|
3546
|
+
return successResult(JSON.stringify(result, null, 2));
|
|
3547
|
+
}, { source: "reddit", dataDelay: "real-time" })
|
|
3548
|
+
};
|
|
3549
|
+
function createRedditModule() {
|
|
3550
|
+
return {
|
|
3551
|
+
name: "reddit",
|
|
3552
|
+
description: "Reddit sentiment \u2014 trending tickers, mention tracking, and sentiment analysis from popular investing subreddits",
|
|
3553
|
+
requiredEnvVars: [],
|
|
3554
|
+
tools: [trendingTool2, mentionsTool, sentimentTool]
|
|
3555
|
+
};
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
// src/modules/workspace/index.ts
|
|
3559
|
+
import { z as z14 } from "zod";
|
|
3560
|
+
|
|
3561
|
+
// src/modules/workspace/storage.ts
|
|
3562
|
+
import * as fs from "fs/promises";
|
|
3563
|
+
import * as path from "path";
|
|
3564
|
+
import { lock } from "proper-lockfile";
|
|
3565
|
+
|
|
3566
|
+
// src/modules/workspace/types.ts
|
|
3567
|
+
import { z as z13 } from "zod";
|
|
3568
|
+
var RESERVED_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype", "toString", "valueOf", "hasOwnProperty"]);
|
|
3569
|
+
var ProfileSchema = z13.object({
|
|
3570
|
+
defaultExchange: z13.string().max(20).default("NASDAQ"),
|
|
3571
|
+
tradingStyle: z13.string().max(100).optional(),
|
|
3572
|
+
assetFocus: z13.array(z13.string().max(50)).max(20).default([]),
|
|
3573
|
+
preferredTimeframe: z13.string().max(50).optional(),
|
|
3574
|
+
workflowCadence: z13.enum(["daily", "weekly"]).default("daily"),
|
|
3575
|
+
updatedAt: z13.string().default(() => (/* @__PURE__ */ new Date(0)).toISOString())
|
|
3576
|
+
});
|
|
3577
|
+
var InstrumentSchema = z13.object({
|
|
3578
|
+
full: z13.string().max(80),
|
|
3579
|
+
ticker: z13.string().max(50),
|
|
3580
|
+
exchange: z13.string().optional(),
|
|
3581
|
+
isCrypto: z13.boolean(),
|
|
3582
|
+
input: z13.string().max(50),
|
|
3583
|
+
note: z13.string().max(500).optional(),
|
|
3584
|
+
addedAt: z13.string()
|
|
3585
|
+
});
|
|
3586
|
+
var WatchlistSchema = z13.object({
|
|
3587
|
+
id: z13.string().max(100),
|
|
3588
|
+
name: z13.string().max(100),
|
|
3589
|
+
instruments: z13.array(InstrumentSchema).max(200).default([]),
|
|
3590
|
+
createdAt: z13.string(),
|
|
3591
|
+
updatedAt: z13.string()
|
|
3592
|
+
});
|
|
3593
|
+
var ThesisSchema = z13.object({
|
|
3594
|
+
full: z13.string().max(80),
|
|
3595
|
+
ticker: z13.string().max(50),
|
|
3596
|
+
exchange: z13.string().optional(),
|
|
3597
|
+
isCrypto: z13.boolean(),
|
|
3598
|
+
input: z13.string().max(50),
|
|
3599
|
+
summary: z13.string().max(5e3),
|
|
3600
|
+
bullCase: z13.string().max(2e3).optional(),
|
|
3601
|
+
bearCase: z13.string().max(2e3).optional(),
|
|
3602
|
+
catalyst: z13.string().max(2e3).optional(),
|
|
3603
|
+
invalidation: z13.string().max(2e3).optional(),
|
|
3604
|
+
timeframe: z13.string().max(100).optional(),
|
|
3605
|
+
nextReviewDate: z13.string().optional(),
|
|
3606
|
+
confidence: z13.number().min(0).max(5).optional(),
|
|
3607
|
+
updatedAt: z13.string(),
|
|
3608
|
+
archivedAt: z13.string().optional()
|
|
3609
|
+
});
|
|
3610
|
+
var WorkspaceSchema = z13.object({
|
|
3611
|
+
schemaVersion: z13.number().default(1),
|
|
3612
|
+
profile: ProfileSchema.default(() => ProfileSchema.parse({})),
|
|
3613
|
+
watchlists: z13.record(z13.string(), WatchlistSchema).refine(
|
|
3614
|
+
(rec) => Object.keys(rec).every((k) => !RESERVED_KEYS.has(k)),
|
|
3615
|
+
{ message: "Workspace contains a reserved key in watchlists" }
|
|
3616
|
+
).default({}),
|
|
3617
|
+
theses: z13.record(z13.string(), ThesisSchema).refine(
|
|
3618
|
+
(rec) => Object.keys(rec).every((k) => !RESERVED_KEYS.has(k)),
|
|
3619
|
+
{ message: "Workspace contains a reserved key in theses" }
|
|
3620
|
+
).default({})
|
|
3621
|
+
});
|
|
3622
|
+
|
|
3623
|
+
// src/modules/workspace/storage.ts
|
|
3624
|
+
var StorageManager = class {
|
|
3625
|
+
filePath;
|
|
3626
|
+
lockPath;
|
|
3627
|
+
defaultExchange;
|
|
3628
|
+
constructor(dataDir, defaultExchange = "NASDAQ") {
|
|
3629
|
+
this.filePath = path.join(dataDir, "workspace.json");
|
|
3630
|
+
this.lockPath = path.join(dataDir, ".workspace.lock");
|
|
3631
|
+
this.defaultExchange = defaultExchange;
|
|
3632
|
+
}
|
|
3633
|
+
async assertNotSymlink(filePath) {
|
|
3634
|
+
try {
|
|
3635
|
+
const stat2 = await fs.lstat(filePath);
|
|
3636
|
+
if (stat2.isSymbolicLink()) {
|
|
3637
|
+
throw new Error(`Refusing to operate on symlink: ${filePath}`);
|
|
3638
|
+
}
|
|
3639
|
+
} catch (e) {
|
|
3640
|
+
if (e instanceof Error && "code" in e && e.code === "ENOENT") {
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3643
|
+
throw e;
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
async exists() {
|
|
3647
|
+
try {
|
|
3648
|
+
await fs.access(this.filePath);
|
|
3649
|
+
return true;
|
|
3650
|
+
} catch {
|
|
3651
|
+
return false;
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
async load() {
|
|
3655
|
+
await this.assertNotSymlink(this.filePath);
|
|
3656
|
+
if (!await this.exists()) {
|
|
3657
|
+
const defaultData = WorkspaceSchema.parse({
|
|
3658
|
+
profile: { defaultExchange: this.defaultExchange }
|
|
3659
|
+
});
|
|
3660
|
+
return { data: defaultData, lastModified: 0 };
|
|
3661
|
+
}
|
|
3662
|
+
const fh = await fs.open(this.filePath, "r");
|
|
3663
|
+
try {
|
|
3664
|
+
const [raw, stat2] = await Promise.all([
|
|
3665
|
+
fh.readFile("utf-8"),
|
|
3666
|
+
fh.stat()
|
|
3667
|
+
]);
|
|
3668
|
+
let parsed;
|
|
3669
|
+
try {
|
|
3670
|
+
parsed = JSON.parse(raw);
|
|
3671
|
+
} catch (e) {
|
|
3672
|
+
throw new Error(`Workspace file corrupted: ${e instanceof Error ? e.message : String(e)}`);
|
|
3673
|
+
}
|
|
3674
|
+
const data = WorkspaceSchema.parse(parsed);
|
|
3675
|
+
return { data, lastModified: stat2.mtimeMs };
|
|
3676
|
+
} finally {
|
|
3677
|
+
await fh.close();
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
async save(data, expectedLastModified) {
|
|
3681
|
+
const dir = path.dirname(this.filePath);
|
|
3682
|
+
await fs.mkdir(dir, { recursive: true });
|
|
3683
|
+
await this.assertNotSymlink(this.lockPath);
|
|
3684
|
+
await fs.writeFile(this.lockPath, "", "utf-8");
|
|
3685
|
+
let release;
|
|
3686
|
+
let tmpPath;
|
|
3687
|
+
try {
|
|
3688
|
+
release = await lock(this.lockPath, {
|
|
3689
|
+
retries: {
|
|
3690
|
+
retries: 5,
|
|
3691
|
+
minTimeout: 100,
|
|
3692
|
+
maxTimeout: 1e3
|
|
3693
|
+
}
|
|
3694
|
+
});
|
|
3695
|
+
await this.assertNotSymlink(this.filePath);
|
|
3696
|
+
const fileExists = await this.exists();
|
|
3697
|
+
if (expectedLastModified === 0 && fileExists) {
|
|
3698
|
+
throw new Error("Conflict: The workspace was already initialized by another process. Please reload.");
|
|
3699
|
+
}
|
|
3700
|
+
if (expectedLastModified > 0) {
|
|
3701
|
+
if (!fileExists) {
|
|
3702
|
+
throw new Error("Conflict: The workspace file has been deleted. Please reload.");
|
|
3703
|
+
}
|
|
3704
|
+
const stat2 = await fs.stat(this.filePath);
|
|
3705
|
+
if (stat2.mtimeMs > expectedLastModified) {
|
|
3706
|
+
throw new Error("Conflict: The workspace has been modified by another process. Please reload and try again.");
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
tmpPath = `${this.filePath}.tmp`;
|
|
3710
|
+
const bakPath = `${this.filePath}.bak`;
|
|
3711
|
+
const content = JSON.stringify(data, null, 2);
|
|
3712
|
+
await this.assertNotSymlink(tmpPath);
|
|
3713
|
+
await this.assertNotSymlink(bakPath);
|
|
3714
|
+
await fs.writeFile(tmpPath, content, "utf-8");
|
|
3715
|
+
if (fileExists) {
|
|
3716
|
+
await fs.copyFile(this.filePath, bakPath);
|
|
3717
|
+
}
|
|
3718
|
+
await fs.rename(tmpPath, this.filePath);
|
|
3719
|
+
const newStat = await fs.stat(this.filePath);
|
|
3720
|
+
return newStat.mtimeMs;
|
|
3721
|
+
} finally {
|
|
3722
|
+
if (release) {
|
|
3723
|
+
await release();
|
|
3724
|
+
}
|
|
3725
|
+
if (tmpPath) {
|
|
3726
|
+
try {
|
|
3727
|
+
await fs.unlink(tmpPath);
|
|
3728
|
+
} catch {
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
};
|
|
3734
|
+
|
|
3735
|
+
// src/modules/workspace/index.ts
|
|
3736
|
+
function createWorkspaceModule(dataDir, defaultExchange = "NASDAQ") {
|
|
3737
|
+
const storage = new StorageManager(dataDir, defaultExchange);
|
|
3738
|
+
return {
|
|
3739
|
+
name: "workspace",
|
|
3740
|
+
description: "Stateful market workspace for watchlists, profiles, and theses.",
|
|
3741
|
+
requiredEnvVars: [],
|
|
3742
|
+
// Response shape convention: all tools use successResult(JSON.stringify(data, null, 2)).
|
|
3743
|
+
// Read tools return domain data directly (profile object, watchlists record, thesis lookup).
|
|
3744
|
+
// Write tools return { success, message, ...context } envelopes so the LLM confirms the operation.
|
|
3745
|
+
tools: [
|
|
3746
|
+
{
|
|
3747
|
+
name: "workspace_get_profile",
|
|
3748
|
+
description: "Get the current user's trading profile and workspace settings.",
|
|
3749
|
+
inputSchema: z14.object({}),
|
|
3750
|
+
readOnly: true,
|
|
3751
|
+
openWorld: false,
|
|
3752
|
+
handler: withMetadata(async () => {
|
|
3753
|
+
const { data } = await storage.load();
|
|
3754
|
+
return successResult(JSON.stringify(data.profile, null, 2));
|
|
3755
|
+
}, { source: "workspace" })
|
|
3756
|
+
},
|
|
3757
|
+
{
|
|
3758
|
+
name: "workspace_update_profile",
|
|
3759
|
+
description: "Update the user's trading profile (style, asset focus, review cadence).",
|
|
3760
|
+
inputSchema: z14.object({
|
|
3761
|
+
tradingStyle: z14.string().max(100).optional().describe("E.g., 'options', 'swing', 'day'"),
|
|
3762
|
+
assetFocus: z14.array(z14.string().max(50)).max(20).optional().describe("Asset classes like 'equities', 'crypto'"),
|
|
3763
|
+
workflowCadence: z14.enum(["daily", "weekly"]).optional().describe("How often you review the market")
|
|
3764
|
+
}),
|
|
3765
|
+
readOnly: false,
|
|
3766
|
+
openWorld: false,
|
|
3767
|
+
handler: withMetadata(async ({ tradingStyle, assetFocus, workflowCadence }) => {
|
|
3768
|
+
const { data, lastModified } = await storage.load();
|
|
3769
|
+
if (tradingStyle !== void 0) data.profile.tradingStyle = tradingStyle;
|
|
3770
|
+
if (assetFocus !== void 0) data.profile.assetFocus = assetFocus;
|
|
3771
|
+
if (workflowCadence !== void 0) data.profile.workflowCadence = workflowCadence;
|
|
3772
|
+
data.profile.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3773
|
+
try {
|
|
3774
|
+
await storage.save(data, lastModified);
|
|
3775
|
+
} catch (e) {
|
|
3776
|
+
if (e instanceof Error && e.message.startsWith("Conflict:")) {
|
|
3777
|
+
return errorResult(e.message, "CONFLICT");
|
|
3778
|
+
}
|
|
3779
|
+
throw e;
|
|
3780
|
+
}
|
|
3781
|
+
return successResult(JSON.stringify({
|
|
3782
|
+
success: true,
|
|
3783
|
+
message: "Profile updated successfully.",
|
|
3784
|
+
profile: data.profile
|
|
3785
|
+
}, null, 2));
|
|
3786
|
+
}, { source: "workspace" })
|
|
3787
|
+
},
|
|
3788
|
+
{
|
|
3789
|
+
name: "workspace_list_watchlists",
|
|
3790
|
+
description: "List all watchlists and their instruments.",
|
|
3791
|
+
inputSchema: z14.object({}),
|
|
3792
|
+
readOnly: true,
|
|
3793
|
+
openWorld: false,
|
|
3794
|
+
handler: withMetadata(async () => {
|
|
3795
|
+
const { data } = await storage.load();
|
|
3796
|
+
return successResult(JSON.stringify(data.watchlists, null, 2));
|
|
3797
|
+
}, { source: "workspace" })
|
|
3798
|
+
},
|
|
3799
|
+
{
|
|
3800
|
+
name: "workspace_create_watchlist",
|
|
3801
|
+
description: "Create a new empty watchlist (max 50 per workspace).",
|
|
3802
|
+
inputSchema: z14.object({
|
|
3803
|
+
name: z14.string().max(100).describe("The ID/name of the watchlist (e.g., 'core', 'swing')")
|
|
3804
|
+
}),
|
|
3805
|
+
readOnly: false,
|
|
3806
|
+
openWorld: false,
|
|
3807
|
+
handler: withMetadata(async ({ name }) => {
|
|
3808
|
+
if (RESERVED_KEYS.has(name)) {
|
|
3809
|
+
return errorResult(`Invalid watchlist name: '${name}' is a reserved keyword.`);
|
|
3810
|
+
}
|
|
3811
|
+
const { data, lastModified } = await storage.load();
|
|
3812
|
+
if (Object.keys(data.watchlists).length >= 50) {
|
|
3813
|
+
return errorResult("Maximum of 50 watchlists reached.");
|
|
3814
|
+
}
|
|
3815
|
+
if (data.watchlists[name]) {
|
|
3816
|
+
return errorResult(`Watchlist '${name}' already exists.`);
|
|
3817
|
+
}
|
|
3818
|
+
data.watchlists[name] = {
|
|
3819
|
+
id: name,
|
|
3820
|
+
name,
|
|
3821
|
+
instruments: [],
|
|
3822
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3823
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3824
|
+
};
|
|
3825
|
+
try {
|
|
3826
|
+
await storage.save(data, lastModified);
|
|
3827
|
+
} catch (e) {
|
|
3828
|
+
if (e instanceof Error && e.message.startsWith("Conflict:")) {
|
|
3829
|
+
return errorResult(e.message, "CONFLICT");
|
|
3830
|
+
}
|
|
3831
|
+
throw e;
|
|
3832
|
+
}
|
|
3833
|
+
return successResult(JSON.stringify({
|
|
3834
|
+
success: true,
|
|
3835
|
+
message: `Watchlist '${name}' created successfully.`
|
|
3836
|
+
}, null, 2));
|
|
3837
|
+
}, { source: "workspace" })
|
|
3838
|
+
},
|
|
3839
|
+
{
|
|
3840
|
+
name: "workspace_update_watchlist",
|
|
3841
|
+
description: "Replace the instruments in a specific watchlist.",
|
|
3842
|
+
inputSchema: z14.object({
|
|
3843
|
+
name: z14.string().max(100).describe("The ID of the watchlist to update"),
|
|
3844
|
+
symbols: z14.array(z14.string().max(50)).max(200).describe("Raw symbols to track (e.g., ['AAPL', 'BTC'])")
|
|
3845
|
+
}),
|
|
3846
|
+
readOnly: false,
|
|
3847
|
+
openWorld: false,
|
|
3848
|
+
handler: withMetadata(async ({ name, symbols }) => {
|
|
3849
|
+
if (RESERVED_KEYS.has(name)) {
|
|
3850
|
+
return errorResult(`Invalid watchlist name: '${name}' is a reserved keyword.`);
|
|
3851
|
+
}
|
|
3852
|
+
const { data, lastModified } = await storage.load();
|
|
3853
|
+
if (!data.watchlists[name]) {
|
|
3854
|
+
return errorResult(`Watchlist '${name}' does not exist.`);
|
|
3855
|
+
}
|
|
3856
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3857
|
+
const instruments = [];
|
|
3858
|
+
for (const sym of symbols) {
|
|
3859
|
+
const resolved = resolveTicker(sym, data.profile.defaultExchange);
|
|
3860
|
+
if (seen.has(resolved.full)) continue;
|
|
3861
|
+
seen.add(resolved.full);
|
|
3862
|
+
instruments.push({
|
|
3863
|
+
full: resolved.full,
|
|
3864
|
+
ticker: resolved.ticker,
|
|
3865
|
+
exchange: resolved.exchange,
|
|
3866
|
+
isCrypto: resolved.isCrypto,
|
|
3867
|
+
input: sym,
|
|
3868
|
+
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3869
|
+
});
|
|
3870
|
+
}
|
|
3871
|
+
data.watchlists[name].instruments = instruments;
|
|
3872
|
+
data.watchlists[name].updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3873
|
+
try {
|
|
3874
|
+
await storage.save(data, lastModified);
|
|
3875
|
+
} catch (e) {
|
|
3876
|
+
if (e instanceof Error && e.message.startsWith("Conflict:")) {
|
|
3877
|
+
return errorResult(e.message, "CONFLICT");
|
|
3878
|
+
}
|
|
3879
|
+
throw e;
|
|
3880
|
+
}
|
|
3881
|
+
return successResult(JSON.stringify({
|
|
3882
|
+
success: true,
|
|
3883
|
+
message: `Watchlist '${name}' updated with ${instruments.length} instruments (deduplicated).`,
|
|
3884
|
+
instrumentCount: instruments.length
|
|
3885
|
+
}, null, 2));
|
|
3886
|
+
}, { source: "workspace" })
|
|
3887
|
+
},
|
|
3888
|
+
{
|
|
3889
|
+
name: "workspace_get_thesis",
|
|
3890
|
+
description: "Get the saved global investment thesis for a specific symbol. Returns a stable JSON shape with a 'found' flag.",
|
|
3891
|
+
inputSchema: z14.object({
|
|
3892
|
+
symbol: z14.string().max(50).describe("The raw symbol (e.g., 'AAPL')")
|
|
3893
|
+
}),
|
|
3894
|
+
readOnly: true,
|
|
3895
|
+
openWorld: false,
|
|
3896
|
+
handler: withMetadata(async ({ symbol }) => {
|
|
3897
|
+
const { data } = await storage.load();
|
|
3898
|
+
const resolved = resolveTicker(symbol, data.profile.defaultExchange);
|
|
3899
|
+
const thesis = data.theses[resolved.full];
|
|
3900
|
+
return successResult(JSON.stringify({
|
|
3901
|
+
found: !!thesis,
|
|
3902
|
+
symbol: resolved.full,
|
|
3903
|
+
thesis: thesis || null
|
|
3904
|
+
}, null, 2));
|
|
3905
|
+
}, { source: "workspace" })
|
|
3906
|
+
},
|
|
3907
|
+
{
|
|
3908
|
+
name: "workspace_save_thesis",
|
|
3909
|
+
description: "Save or update the global investment thesis for a specific symbol (max 200 per workspace).",
|
|
3910
|
+
inputSchema: z14.object({
|
|
3911
|
+
symbol: z14.string().max(50).describe("The raw symbol (e.g., 'AAPL')"),
|
|
3912
|
+
summary: z14.string().max(5e3).describe("Brief summary of your investment view"),
|
|
3913
|
+
bullCase: z14.string().max(2e3).optional().describe("Arguments for price going up"),
|
|
3914
|
+
bearCase: z14.string().max(2e3).optional().describe("Arguments for price going down"),
|
|
3915
|
+
catalyst: z14.string().max(2e3).optional().describe("Upcoming events that might move the price"),
|
|
3916
|
+
timeframe: z14.string().max(100).optional().describe("Investment horizon (e.g., '3-6 months')")
|
|
3917
|
+
}),
|
|
3918
|
+
readOnly: false,
|
|
3919
|
+
openWorld: false,
|
|
3920
|
+
handler: withMetadata(async ({ symbol, summary, bullCase, bearCase, catalyst, timeframe }) => {
|
|
3921
|
+
if (RESERVED_KEYS.has(symbol) || RESERVED_KEYS.has(symbol.toLowerCase())) {
|
|
3922
|
+
return errorResult(`Invalid symbol: '${symbol}' resolves to a reserved keyword.`);
|
|
3923
|
+
}
|
|
3924
|
+
const { data, lastModified } = await storage.load();
|
|
3925
|
+
const resolved = resolveTicker(symbol, data.profile.defaultExchange);
|
|
3926
|
+
const existing = data.theses[resolved.full];
|
|
3927
|
+
if (!existing && Object.keys(data.theses).length >= 200) {
|
|
3928
|
+
return errorResult("Maximum of 200 theses reached.");
|
|
3929
|
+
}
|
|
3930
|
+
data.theses[resolved.full] = {
|
|
3931
|
+
...existing,
|
|
3932
|
+
full: resolved.full,
|
|
3933
|
+
ticker: resolved.ticker,
|
|
3934
|
+
exchange: resolved.exchange,
|
|
3935
|
+
isCrypto: resolved.isCrypto,
|
|
3936
|
+
input: symbol,
|
|
3937
|
+
summary,
|
|
3938
|
+
bullCase: bullCase ?? existing?.bullCase,
|
|
3939
|
+
bearCase: bearCase ?? existing?.bearCase,
|
|
3940
|
+
catalyst: catalyst ?? existing?.catalyst,
|
|
3941
|
+
timeframe: timeframe ?? existing?.timeframe,
|
|
3942
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3943
|
+
};
|
|
3944
|
+
try {
|
|
3945
|
+
await storage.save(data, lastModified);
|
|
3946
|
+
} catch (e) {
|
|
3947
|
+
if (e instanceof Error && e.message.startsWith("Conflict:")) {
|
|
3948
|
+
return errorResult(e.message, "CONFLICT");
|
|
3949
|
+
}
|
|
3950
|
+
throw e;
|
|
3951
|
+
}
|
|
3952
|
+
return successResult(JSON.stringify({
|
|
3953
|
+
success: true,
|
|
3954
|
+
message: `Thesis saved for ${resolved.full}.`,
|
|
3955
|
+
ticker: resolved.full
|
|
3956
|
+
}, null, 2));
|
|
3957
|
+
}, { source: "workspace" })
|
|
3958
|
+
}
|
|
3959
|
+
]
|
|
3960
|
+
};
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
// src/registry.ts
|
|
3964
|
+
import * as path2 from "path";
|
|
3965
|
+
import * as os from "os";
|
|
3966
|
+
var MODULE_CATALOG = [
|
|
3967
|
+
{ name: "tradingview", envVar: null, toolCount: 10, factory: () => createTradingviewModule() },
|
|
3968
|
+
{ name: "tradingview-crypto", envVar: null, toolCount: 4, factory: () => createTradingviewCryptoModule() },
|
|
3969
|
+
{ name: "sec-edgar", envVar: null, toolCount: 6, factory: () => createSecEdgarModule() },
|
|
3970
|
+
{ name: "coingecko", envVar: null, toolCount: 3, factory: () => createCoingeckoModule() },
|
|
3971
|
+
{ name: "options", envVar: null, toolCount: 5, factory: () => createOptionsModule() },
|
|
3972
|
+
{ name: "options-cboe", envVar: null, toolCount: 1, factory: () => createOptionsCboeModule() },
|
|
3973
|
+
{ name: "finnhub", envVar: "FINNHUB_API_KEY", toolCount: 9, factory: (config) => config.env.FINNHUB_API_KEY ? createFinnhubModule(config.env.FINNHUB_API_KEY) : null },
|
|
3974
|
+
{ name: "alpha-vantage", envVar: "ALPHA_VANTAGE_API_KEY", toolCount: 5, factory: (config) => config.env.ALPHA_VANTAGE_API_KEY ? createAlphaVantageModule(config.env.ALPHA_VANTAGE_API_KEY) : null },
|
|
3975
|
+
{ name: "fred", envVar: "FRED_API_KEY", toolCount: 4, factory: (config) => config.env.FRED_API_KEY ? createFredModule(config.env.FRED_API_KEY) : null },
|
|
3976
|
+
{ name: "sentiment", envVar: null, toolCount: 2, factory: () => createSentimentModule() },
|
|
3977
|
+
{ name: "frankfurter", envVar: null, toolCount: 5, factory: () => createFrankfurterModule() },
|
|
3978
|
+
{ name: "reddit", envVar: null, toolCount: 3, factory: () => createRedditModule() },
|
|
3979
|
+
{ name: "workspace", envVar: null, toolCount: 7, factory: (config) => config.enableWorkspace ? createWorkspaceModule(config.dataDir || path2.join(os.homedir(), ".stock-scanner-mcp"), config.defaultExchange) : null }
|
|
3980
|
+
];
|
|
3981
|
+
function resolveEnabledModules(allModules, env, filter) {
|
|
3982
|
+
return allModules.filter((mod) => {
|
|
3983
|
+
if (filter && !filter.includes(mod.name)) return false;
|
|
3984
|
+
const hasKeys = mod.requiredEnvVars.every((key) => env[key]);
|
|
3985
|
+
if (!hasKeys) {
|
|
3986
|
+
return false;
|
|
3987
|
+
}
|
|
3988
|
+
return true;
|
|
3989
|
+
});
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
export {
|
|
3993
|
+
MODULE_CATALOG,
|
|
3994
|
+
resolveEnabledModules
|
|
3995
|
+
};
|