UNPKG

finmap-mcp

Version:

MCP server providing financial market data from finmap.org

552 lines (551 loc) 21.5 kB
import { z } from "zod"; const BASE_URL = "https://finmap.org"; const DATA_BASE_URL = "https://raw.githubusercontent.com/finmap-org"; const INFO = { provider: "finmap.org", description: "Discover interactive stock charts and curated news at finmap.org", github: "https://github.com/finmap-org", donate: { patreon: "https://patreon.com/finmap", boosty: "https://boosty.to/finmap", }, issues: "https://github.com/finmap-org/mcp-server/issues", feedback: "contact@finmap.org", }; const STOCK_EXCHANGES = [ "amex", "nasdaq", "nyse", "us-all", "lse", "moex", "bist", "hkex", ]; const US_EXCHANGES = ["amex", "nasdaq", "nyse"]; const SORT_FIELDS = [ "priceChangePct", "marketCap", "value", "volume", "numTrades", ]; const SORT_ORDERS = ["asc", "desc"]; const INDICES = { EXCHANGE: 0, COUNTRY: 1, TYPE: 2, SECTOR: 3, INDUSTRY: 4, CURRENCY_ID: 5, TICKER: 6, NAME_ENG: 7, NAME_ENG_SHORT: 8, NAME_ORIGINAL: 9, NAME_ORIGINAL_SHORT: 10, PRICE_OPEN: 11, PRICE_LAST_SALE: 12, PRICE_CHANGE_PCT: 13, VOLUME: 14, VALUE: 15, NUM_TRADES: 16, MARKET_CAP: 17, LISTED_FROM: 18, LISTED_TILL: 19, WIKI_PAGE_ID_ENG: 20, WIKI_PAGE_ID_ORIGINAL: 21, ITEMS_PER_SECTOR: 22, }; const EXCHANGE_TO_COUNTRY_MAP = { amex: "us", nasdaq: "us", nyse: "us", "us-all": "us", lse: "uk", moex: "russia", bist: "turkey", hkex: "hongkong", }; function createResponse(data) { return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }; } function createErrorResponse(error) { return createResponse(`ERROR: ${error instanceof Error ? error.message : String(error)}`); } function createCharts(exchange, date) { return { histogram: `${BASE_URL}/?chart=histogram&data=marketcap&currency=USD&exchange=${exchange}`, treemap: `${BASE_URL}/?chart=treemap&data=marketcap&currency=USD&exchange=${exchange}${date ? `&date=${date.replaceAll("-", "/")}` : ""}`, }; } function calculateMatchScore(ticker, name, searchTerm) { const tickerLower = ticker.toLowerCase(); const nameLower = name.toLowerCase(); if (tickerLower === searchTerm) return 100; if (tickerLower.startsWith(searchTerm)) return 90; if (tickerLower.includes(searchTerm)) return 80; if (nameLower.includes(searchTerm)) return 70; return 0; } const EXCHANGE_INFO = { amex: { name: "American Stock Exchange", country: "United States", currency: "USD", availableSince: "2024-12-09", updateFrequency: "Hourly (weekdays)", }, nasdaq: { name: "NASDAQ Stock Market", country: "United States", currency: "USD", availableSince: "2024-12-09", updateFrequency: "Hourly (weekdays)", }, nyse: { name: "New York Stock Exchange", country: "United States", currency: "USD", availableSince: "2024-12-09", updateFrequency: "Hourly (weekdays)", }, "us-all": { name: "US Combined (AMEX + NASDAQ + NYSE)", country: "United States", currency: "USD", availableSince: "2024-12-09", updateFrequency: "Hourly (weekdays)", }, lse: { name: "London Stock Exchange", country: "United Kingdom", currency: "GBP", availableSince: "2025-02-07", updateFrequency: "Hourly (weekdays)", }, moex: { name: "Moscow Exchange", country: "Russia", currency: "RUB", availableSince: "2011-12-19", updateFrequency: "Every 15 minutes (weekdays)", }, bist: { name: "Borsa Istanbul", country: "Turkey", currency: "TRY", availableSince: "2015-11-30", updateFrequency: "Every two months", }, hkex: { name: "Hong Kong Stock Exchange", country: "Hong Kong", currency: "HKD", availableSince: "2025-09-29", updateFrequency: "Every 30 minutes (weekdays)", }, }; const exchangeSchema = z .enum(STOCK_EXCHANGES) .describe("Stock exchange: amex, nasdaq, nyse, us-all, lse, moex, bist, hkex"); const dateSchema = { year: z.number().int().min(2012).optional(), month: z.number().int().min(1).max(12).optional(), day: z.number().int().min(1).max(31).optional(), }; function buildDateString(year, month, day) { const currentDate = new Date(); const y = year ?? currentDate.getFullYear(); const m = month ?? currentDate.getMonth() + 1; const d = day ?? currentDate.getDate(); return `${y.toString()}/${m.toString().padStart(2, "0")}/${d.toString().padStart(2, "0")}`; } function validateAndFormatDate(dateString) { const date = dateString.replaceAll("/", "-"); z.string().date().parse(date); const dayOfWeek = new Date(date).getDay(); if (dayOfWeek === 0 || dayOfWeek === 6) { throw new Error("Data is only available for work days (Monday to Friday)"); } return date; } function getDate(year, month, day) { const dateString = buildDateString(year, month, day); return validateAndFormatDate(dateString); } async function fetchMarketData(stockExchange, formattedDate) { const country = EXCHANGE_TO_COUNTRY_MAP[stockExchange]; const date = formattedDate.replaceAll("-", "/"); const url = `${DATA_BASE_URL}/data-${country}/refs/heads/main/marketdata/${date}/${stockExchange}.json`; const response = await fetch(url); if (response.status === 404) { throw new Error(`Not found, try another date. The date must be on or after ${EXCHANGE_INFO[stockExchange].availableSince} for ${stockExchange}`); } return response.json(); } async function fetchSecurityInfo(exchange, ticker) { const firstLetter = ticker.charAt(0).toUpperCase(); const url = `${DATA_BASE_URL}/data-us/refs/heads/main/securities/${exchange}/${firstLetter}/${ticker}.json`; const response = await fetch(url); if (response.status === 404) { throw new Error(`Security ${ticker} not found on ${exchange}`); } const data = (await response.json()); return data; } export function registerFinmapTools(server) { server.registerTool("list_exchanges", { title: "List exchanges", description: "Return supported exchanges with IDs, names, country, currency, earliest available date, and update frequency.", inputSchema: {}, }, async () => { try { const exchanges = Object.entries(EXCHANGE_INFO).map(([id, info]) => ({ id, ...info, })); return createResponse({ info: INFO, exchanges }); } catch (error) { return createErrorResponse(error); } }); server.registerTool("list_sectors", { title: "List sectors", description: "List available business sectors for an exchange on a specific date, including item counts.", inputSchema: { stockExchange: exchangeSchema, ...dateSchema }, }, async ({ stockExchange, year, month, day, }) => { try { const formattedDate = getDate(year, month, day); const marketDataResponse = await fetchMarketData(stockExchange, formattedDate); const sectorCounts = {}; marketDataResponse.securities.data.forEach((item) => { if (item[INDICES.TYPE] !== "sector" && item[INDICES.SECTOR]) { sectorCounts[item[INDICES.SECTOR]] = (sectorCounts[item[INDICES.SECTOR]] || 0) + 1; } }); const sectors = Object.entries(sectorCounts).map(([name, count]) => ({ name, itemsPerSector: count, })); return createResponse({ info: INFO, date: formattedDate, exchange: stockExchange.toUpperCase(), currency: EXCHANGE_INFO[stockExchange].currency, sectors, }); } catch (error) { return createErrorResponse(error); } }); server.registerTool("list_tickers", { title: "List tickers by sector", description: "Return company tickers and names for an exchange on a specific date, grouped by sector.", inputSchema: { stockExchange: exchangeSchema, ...dateSchema, sector: z.string().optional().describe("Filter by specific sector"), englishNames: z .boolean() .default(true) .describe("Use English names if available"), }, }, async ({ stockExchange, year, month, day, sector, englishNames, }) => { try { const formattedDate = getDate(year, month, day); const marketDataResponse = await fetchMarketData(stockExchange, formattedDate); const sectorGroups = {}; marketDataResponse.securities.data.forEach((item) => { if (item[INDICES.TYPE] !== "sector" && item[INDICES.SECTOR] && (!sector || item[INDICES.SECTOR] === sector)) { const sectorName = item[INDICES.SECTOR]; const ticker = item[INDICES.TICKER]; const name = englishNames ? item[INDICES.NAME_ENG] : item[INDICES.NAME_ORIGINAL_SHORT] || item[INDICES.NAME_ENG]; if (ticker && name) { if (!sectorGroups[sectorName]) sectorGroups[sectorName] = []; sectorGroups[sectorName].push({ ticker, name }); } } }); Object.values(sectorGroups).forEach((companies) => { companies.sort((a, b) => a.ticker.localeCompare(b.ticker)); }); return createResponse({ info: INFO, date: formattedDate, exchange: stockExchange.toUpperCase(), currency: EXCHANGE_INFO[stockExchange].currency, sectors: sectorGroups, }); } catch (error) { return createErrorResponse(error); } }); server.registerTool("search_companies", { title: "Search companies", description: "Find companies by partial name or ticker on an exchange and return best matches", inputSchema: { stockExchange: exchangeSchema, ...dateSchema, query: z .string() .describe("Search term (partial ticker or company name)"), limit: z .number() .int() .min(1) .max(50) .default(10) .describe("Maximum results"), }, }, async ({ stockExchange, year, month, day, query, limit, }) => { try { const formattedDate = getDate(year, month, day); const marketDataResponse = await fetchMarketData(stockExchange, formattedDate); const searchTerm = query.toLowerCase(); const matches = marketDataResponse.securities.data .filter((item) => item[INDICES.TYPE] !== "sector" && item[INDICES.SECTOR]) .map((item) => ({ ticker: item[INDICES.TICKER], name: item[INDICES.NAME_ENG], sector: item[INDICES.SECTOR], score: calculateMatchScore(item[INDICES.TICKER], item[INDICES.NAME_ENG], searchTerm), })) .filter((match) => match.score > 0) .sort((a, b) => b.score - a.score) .slice(0, limit); return createResponse({ info: INFO, date: formattedDate, exchange: stockExchange.toUpperCase(), currency: EXCHANGE_INFO[stockExchange].currency, query, matches, }); } catch (error) { return createErrorResponse(error); } }); server.registerTool("get_market_overview", { title: "Market overview", description: "Get total market cap, volume, value, and performance for an exchange on a specific date with a sector breakdown.", inputSchema: { stockExchange: exchangeSchema, ...dateSchema }, }, async ({ stockExchange, year, month, day, }) => { try { const formattedDate = getDate(year, month, day); const marketDataResponse = await fetchMarketData(stockExchange, formattedDate); const sectorItems = marketDataResponse.securities.data.filter((item) => item[INDICES.TYPE] === "sector"); let marketTotal = {}; const sectors = []; sectorItems.forEach((item) => { const sectorData = { name: item[INDICES.TICKER], marketCap: item[INDICES.MARKET_CAP], marketCapChangePct: item[INDICES.PRICE_CHANGE_PCT], volume: item[INDICES.VOLUME], value: item[INDICES.VALUE], numTrades: item[INDICES.NUM_TRADES], itemsPerSector: item[INDICES.ITEMS_PER_SECTOR], }; if (item[INDICES.SECTOR] === "") { marketTotal = sectorData; } else { sectors.push(sectorData); } }); return createResponse({ info: INFO, charts: createCharts(stockExchange, formattedDate), date: formattedDate, exchange: stockExchange.toUpperCase(), currency: EXCHANGE_INFO[stockExchange].currency, marketTotal, sectors, }); } catch (error) { return createErrorResponse(error); } }); server.registerTool("get_sectors_overview", { title: "Sector performance", description: "Get aggregated performance metrics by sector for an exchange on a specific date.", inputSchema: { stockExchange: exchangeSchema, ...dateSchema, sector: z .string() .optional() .describe("Get data for specific sector only"), }, }, async ({ stockExchange, year, month, day, sector, }) => { try { const formattedDate = getDate(year, month, day); const marketDataResponse = await fetchMarketData(stockExchange, formattedDate); const sectors = marketDataResponse.securities.data .filter((item) => item[INDICES.TYPE] === "sector" && item[INDICES.SECTOR] !== "") .filter((item) => !sector || item[INDICES.TICKER] === sector) .map((item) => ({ name: item[INDICES.TICKER], marketCap: item[INDICES.MARKET_CAP], marketCapChangePct: item[INDICES.PRICE_CHANGE_PCT], volume: item[INDICES.VOLUME], value: item[INDICES.VALUE], numTrades: item[INDICES.NUM_TRADES], itemsPerSector: item[INDICES.ITEMS_PER_SECTOR], })); return createResponse({ info: INFO, charts: createCharts(stockExchange, formattedDate), date: formattedDate, exchange: stockExchange.toUpperCase(), currency: EXCHANGE_INFO[stockExchange].currency, sectors, }); } catch (error) { return createErrorResponse(error); } }); server.registerTool("get_stock_data", { title: "Stock data by ticker", description: "Get detailed market data for a specific ticker on an exchange and date, including price, change, volume, value, market cap, and trades.", inputSchema: { stockExchange: exchangeSchema, ...dateSchema, ticker: z.string().describe("Stock ticker symbol (case-sensitive)"), }, }, async ({ stockExchange, year, month, day, ticker, }) => { try { const formattedDate = getDate(year, month, day); const marketDataResponse = await fetchMarketData(stockExchange, formattedDate); const stockData = marketDataResponse.securities.data.find((item) => item[INDICES.TYPE] !== "sector" && item[INDICES.TICKER] === ticker); if (!stockData) { throw new Error(`Ticker ${ticker} not found on ${stockExchange} for date ${formattedDate}`); } return createResponse({ info: INFO, charts: createCharts(stockExchange, formattedDate), exchange: stockData[INDICES.EXCHANGE], country: stockData[INDICES.COUNTRY], currency: EXCHANGE_INFO[stockExchange].currency, sector: stockData[INDICES.SECTOR], ticker: stockData[INDICES.TICKER], nameEng: stockData[INDICES.NAME_ENG], nameOriginal: stockData[INDICES.NAME_ORIGINAL], priceOpen: stockData[INDICES.PRICE_OPEN], priceLastSale: stockData[INDICES.PRICE_LAST_SALE], priceChangePct: stockData[INDICES.PRICE_CHANGE_PCT], volume: stockData[INDICES.VOLUME], value: stockData[INDICES.VALUE], numTrades: stockData[INDICES.NUM_TRADES], marketCap: stockData[INDICES.MARKET_CAP], listedFrom: stockData[INDICES.LISTED_FROM], listedTill: stockData[INDICES.LISTED_TILL], }); } catch (error) { return createErrorResponse(error); } }); server.registerTool("rank_stocks", { title: "Rank stocks", description: "Rank stocks on an exchange by a chosen metric (marketCap, priceChangePct, volume, value, numTrades) for a specific date with order and limit.", inputSchema: { stockExchange: exchangeSchema, ...dateSchema, sortBy: z .enum(SORT_FIELDS) .describe("Sort by: marketCap, priceChangePct, volume, value, numTrades"), order: z .enum(SORT_ORDERS) .default("desc") .describe("Sort order: asc or desc"), limit: z .number() .int() .min(1) .max(500) .default(10) .describe("Number of results"), sector: z.string().optional().describe("Filter by specific sector"), }, }, async ({ stockExchange, year, month, day, sortBy, order, limit, sector, }) => { try { const formattedDate = getDate(year, month, day); const marketDataResponse = await fetchMarketData(stockExchange, formattedDate); const stocks = marketDataResponse.securities.data .filter((item) => item[INDICES.TYPE] !== "sector" && item[INDICES.SECTOR] !== "") .filter((item) => !sector || item[INDICES.SECTOR] === sector) .map((item) => ({ ticker: item[INDICES.TICKER], name: item[INDICES.NAME_ENG], sector: item[INDICES.SECTOR], priceLastSale: item[INDICES.PRICE_LAST_SALE], priceChangePct: item[INDICES.PRICE_CHANGE_PCT], marketCap: item[INDICES.MARKET_CAP], volume: item[INDICES.VOLUME], value: item[INDICES.VALUE], numTrades: item[INDICES.NUM_TRADES], })) .sort((a, b) => { const aVal = a[sortBy], bVal = b[sortBy]; return order === "desc" ? bVal - aVal : aVal - bVal; }) .slice(0, limit); return createResponse({ info: INFO, charts: createCharts(stockExchange, formattedDate), date: formattedDate, exchange: stockExchange.toUpperCase(), currency: EXCHANGE_INFO[stockExchange].currency, sortBy, order, limit, count: stocks.length, stocks, }); } catch (error) { return createErrorResponse(error); } }); server.registerTool("get_company_profile", { title: "Company profile (US)", description: "Get business description, industry, and background for a US-listed company by ticker.", inputSchema: { exchange: z .enum(US_EXCHANGES) .describe("US exchange: amex, nasdaq, nyse"), ticker: z.string().describe("Stock ticker symbol (case-sensitive)"), }, }, async ({ exchange, ticker }) => { try { const securityInfo = await fetchSecurityInfo(exchange, ticker); return createResponse({ info: INFO, charts: createCharts(exchange), ...securityInfo, }); } catch (error) { return createErrorResponse(error); } }); }