Skip to main content
Glama

Financial News and Notes MCP Server

MIT License
129
199
  • Apple
  • Linux
stockDataMinutes.ts14 kB
import { TUSHARE_CONFIG } from '../config.js'; import { resolveStockCodes } from '../utils/stockCodeResolver.js'; /** * 分钟K线数据工具(A股/加密) * 说明: * - market_type = 'cn' 走 Tushare 分钟线接口(stk_mins) * - market_type = 'crypto' 走 Binance 分钟线接口(/api/v3/klines) * 按指定时间范围与频率返回分钟级K线。 */ export const stockDataMinutes = { name: 'stock_data_minutes', description: '获取分钟K线数据:A股/加密。支持1MIN/5MIN/15MIN/30MIN/60MIN,时间范围需提供起止日期时间', parameters: { type: 'object', properties: { code: { type: 'string', description: "股票代码,如 '600519.SH' 或 '000001.SZ'" }, market_type: { type: 'string', description: "市场类型:'cn'(A股,Tushare)、'crypto'(加密币对,Binance)" }, start_datetime: { type: 'string', description: "起始日期时间,支持 'YYYYMMDDHHmmss' 或 'YYYY-MM-DD HH:mm:ss'" }, end_datetime: { type: 'string', description: "结束日期时间,支持 'YYYYMMDDHHmmss' 或 'YYYY-MM-DD HH:mm:ss'" }, freq: { type: 'string', description: "分钟周期:1MIN/5MIN/15MIN/30MIN/60MIN(不区分大小写)" } }, required: ['code', 'market_type', 'start_datetime', 'end_datetime', 'freq'] }, async run(args: { code: string; market_type: string; start_datetime: string; end_datetime: string; freq: string }) { try { const TUSHARE_API_KEY = TUSHARE_CONFIG.API_TOKEN; const TUSHARE_API_URL = TUSHARE_CONFIG.API_URL; // 归一化时间:转为 YYYYMMDDHHmmss(剔除非数字) const normalizeDT = (v: string) => v.replace(/[^0-9]/g, '').padEnd(14, '0').slice(0, 14); const startTime = normalizeDT(args.start_datetime); const endTime = normalizeDT(args.end_datetime); if (startTime.length !== 14 || endTime.length !== 14) { throw new Error('起止时间格式不正确,请使用 YYYYMMDDHHmmss 或 YYYY-MM-DD HH:mm:ss'); } if (endTime <= startTime) { throw new Error('结束时间必须大于起始时间'); } // 归一化频率 const rawFreq = String(args.freq || '').trim().toLowerCase(); const freqMap: Record<string, string> = { '1min': '1min', '1m': '1min', '5min': '5min', '5m': '5min', '15min': '15min', '15m': '15min', '30min': '30min', '30m': '30min', '60min': '60min', '60m': '60min', '1h': '60min' }; // 兼容 1MIN/5MIN 等写法(统一转小写再映射) const normalizedFreq = freqMap[rawFreq] || freqMap[rawFreq.replace('min', 'min')] || freqMap[rawFreq.replace('minute', 'min')] || rawFreq; const freq = normalizedFreq; const allowed = new Set(['1min', '5min', '15min', '30min', '60min']); if (!allowed.has(freq)) { throw new Error(`不支持的频率:${args.freq},允许值:1MIN/5MIN/15MIN/30MIN/60MIN`); } // 市场分支 const market = String(args.market_type || '').trim().toLowerCase(); if (!['cn', 'crypto'].includes(market)) { throw new Error(`不支持的 market_type: ${args.market_type},仅支持 'cn' 或 'crypto'`); } if (market === 'cn') { if (!TUSHARE_API_KEY) { throw new Error('缺少 Tushare Token:请在请求头 X-Tushare-Token 或环境变量 TUSHARE_TOKEN 中提供'); } // 组装请求(Tushare 分钟线:stk_mins) const requestBody: any = { api_name: 'stk_mins', token: TUSHARE_API_KEY, params: { ts_code: args.code, start_time: startTime, end_time: endTime, freq: freq } // 不指定 fields,默认返回全部 }; // 超时控制 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), TUSHARE_CONFIG.TIMEOUT); try { const resp = await fetch(TUSHARE_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), signal: controller.signal }); clearTimeout(timeoutId); if (!resp.ok) { throw new Error(`Tushare HTTP ${resp.status}`); } const data = await resp.json(); if (data.code !== 0) { throw new Error(`Tushare API错误: ${data.msg || 'unknown error'}`); } const fields: string[] = data.data?.fields || []; const items: any[] = data.data?.items || []; if (!fields.length || !items.length) { return { content: [{ type: 'text', text: `# ${args.code} 分钟K线(${args.freq})\n\n区间: ${startTime} - ${endTime}\n\n暂无数据。` }] }; } // 转对象数组 const rows = items.map(row => { const obj: Record<string, any> = {}; fields.forEach((f: string, i: number) => (obj[f] = row[i])); return obj; }); // 常见字段名兼容:trade_time/time/datetime const timeKey = ['trade_time', 'time', 'datetime'].find(k => fields.includes(k)) || 'trade_time'; const openKey = ['open'].find(k => fields.includes(k)) || 'open'; const highKey = ['high'].find(k => fields.includes(k)) || 'high'; const lowKey = ['low'].find(k => fields.includes(k)) || 'low'; const closeKey = ['close'].find(k => fields.includes(k)) || 'close'; const volKey = ['vol', 'volume'].find(k => fields.includes(k)) || 'vol'; const amountKey = ['amount', 'amt'].find(k => fields.includes(k)) || 'amount'; // 按时间倒序(最新在上) rows.sort((a, b) => String(b[timeKey] || '').localeCompare(String(a[timeKey] || ''))); // 构造表格 let out = `# ${args.code} 分钟K线(${freq})\n\n`; out += `查询区间: ${startTime} - ${endTime}\n`; out += `返回条数: ${rows.length}\n\n`; const headers = ['时间', '开盘', '最高', '最低', '收盘', '成交量', '成交额(万元)']; out += `| ${headers.join(' | ')} |\n`; out += `|${headers.map(() => '--------').join('|')}|\n`; for (const r of rows) { const t = r[timeKey] ?? 'N/A'; const o = safeNum(r[openKey]); const h = safeNum(r[highKey]); const l = safeNum(r[lowKey]); const c = safeNum(r[closeKey]); const v = r[volKey] == null ? 'N/A' : String(r[volKey]); const amt = r[amountKey] == null ? 'N/A' : String(r[amountKey]); out += `| ${t} | ${fmt(o)} | ${fmt(h)} | ${fmt(l)} | ${fmt(c)} | ${v} | ${amt} |\n`; } // 收集股票代码并生成说明(仅对A股) let stockExplanation = ''; if (market === 'cn') { stockExplanation = await resolveStockCodes([args.code]); } return { content: [{ type: 'text', text: out + stockExplanation }] }; } finally { // 兜底清理 // clearTimeout 在 try 内处理;此处确保未走到前面时也释放 } } // ===== crypto 分支(Binance 分钟线)===== // 频率映射到 Binance interval const binanceIntervalMap: Record<string, string> = { '1min': '1m', '5min': '5m', '15min': '15m', '30min': '30m', '60min': '1h' }; const interval = binanceIntervalMap[freq]; if (!interval) { throw new Error(`不支持的频率:${args.freq}(Binance)`); } // 时间转换为毫秒(UTC) const toMs = (s: string): number => { const y = parseInt(s.slice(0, 4)); const m = parseInt(s.slice(4, 6)); const d = parseInt(s.slice(6, 8)); const hh = parseInt(s.slice(8, 10)); const mm = parseInt(s.slice(10, 12)); const ss = parseInt(s.slice(12, 14)); return Date.UTC(y, m - 1, d, hh, mm, ss, 0); }; const startMs = toMs(startTime); const endMs = toMs(endTime); // 交易对解析(兼容 BTCUSDT / BTC-USDT / BTC/USDT / coinid.USDT),USD 自动映射到 USDT const idToTicker: Record<string, string> = { 'bitcoin': 'BTC', 'ethereum': 'ETH', 'tether': 'USDT', 'usd-coin': 'USDC', 'solana': 'SOL', 'binancecoin': 'BNB', 'ripple': 'XRP', 'cardano': 'ADA', 'polkadot': 'DOT', 'chainlink': 'LINK', 'litecoin': 'LTC', 'shiba-inu': 'SHIB', 'tron': 'TRX', 'toncoin': 'TON', 'bitcoin-cash': 'BCH', 'ethereum-classic': 'ETC' }; const parseBinanceSymbol = (raw: string): string => { const trimmed = String(raw || '').trim(); const upper = trimmed.toUpperCase(); const validQuotes = new Set(['USDT','USDC','FDUSD','TUSD','BUSD','BTC','ETH']); if (!upper.includes('-') && !upper.includes('/') && !upper.includes('.')) { return upper; } let base = ''; let quote = ''; if (upper.includes('-') || upper.includes('/')) { const sep = upper.includes('-') ? '-' : '/'; const [b, q] = upper.split(sep); base = b; quote = q; } else if (upper.includes('.')) { const [id, vs] = upper.split('.'); base = idToTicker[id.toLowerCase()] || id; quote = vs; } if (quote === 'USD') quote = 'USDT'; if (!validQuotes.has(quote)) { throw new Error(`不支持的报价资产: ${quote}。支持: ${Array.from(validQuotes).join(', ')}`); } return `${base}${quote}`; }; const symbol = parseBinanceSymbol(args.code); // 分页请求 const allKlines: any[] = []; let cursor = startMs; let pageIndex = 0; const maxPages = 500; while (cursor < endMs && pageIndex < maxPages) { const url = `https://api.binance.com/api/v3/klines?symbol=${encodeURIComponent(symbol)}&interval=${encodeURIComponent(interval)}&startTime=${cursor}&endTime=${endMs}&limit=1000`; const resp = await fetch(url); if (!resp.ok) { try { const ct = resp.headers.get('content-type') || ''; if (ct.includes('application/json')) { const err = await resp.json(); const msg = err?.msg || `HTTP ${resp.status}`; if (Number(err?.code) === -1121 || /invalid symbol/i.test(String(msg))) { throw new Error(`Binance 无效交易对: ${symbol}。该币对在 Binance 不存在或已下线,请更换有效币对(例如:BTCUSDT、ETHUSDT、SOLUSDT)。也支持 BTC-USDT、BTC/USDT、或 coinid.USDT 写法。`); } throw new Error(`Binance K线请求失败: ${resp.status} - ${msg}`); } else { const text = await resp.text(); throw new Error(`Binance K线请求失败: ${resp.status}${text ? ` - ${text}` : ''}`); } } catch (e) { if (e instanceof Error) throw e; throw new Error(`Binance K线请求失败: ${resp.status}`); } } const kl = await resp.json(); if (!Array.isArray(kl)) throw new Error('Binance 返回的 K 线数据格式异常'); if (kl.length === 0) break; allKlines.push(...kl); const lastOpenTime = Number(kl[kl.length - 1][0]); if (!(lastOpenTime > cursor)) break; cursor = lastOpenTime + 1; pageIndex += 1; if (kl.length < 1000) break; } // 转换为统一对象 const toYMDHMS = (ms: number): string => { const d = new Date(ms); const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, '0'); const dd = String(d.getUTCDate()).padStart(2, '0'); const hh = String(d.getUTCHours()).padStart(2, '0'); const mm = String(d.getUTCMinutes()).padStart(2, '0'); const ss = String(d.getUTCSeconds()).padStart(2, '0'); return `${y}-${m}-${dd} ${hh}:${mm}:${ss}`; }; const rows = allKlines.map(row => { const openTime = Number(row[0]); return { trade_time: toYMDHMS(openTime), open: Number(row[1]), high: Number(row[2]), low: Number(row[3]), close: Number(row[4]), vol: Number(row[5]) } as Record<string, any>; }); // 严格过滤在用户区间内(Binance 已按毫秒过滤,此处再按显示字段兜底) const filtered = rows.filter(r => true); filtered.sort((a, b) => String(b.trade_time).localeCompare(String(a.trade_time))); // 输出 let out = `# ${args.code} 分钟K线(${freq},Binance)\n\n`; out += `查询区间: ${startTime} - ${endTime}\n`; out += `返回条数: ${filtered.length}\n\n`; const headers = ['时间', '开盘', '最高', '最低', '收盘', '成交量']; out += `| ${headers.join(' | ')} |\n`; out += `|${headers.map(() => '--------').join('|')}|\n`; for (const r of filtered) { out += `| ${r.trade_time} | ${fmt(safeNum(r.open))} | ${fmt(safeNum(r.high))} | ${fmt(safeNum(r.low))} | ${fmt(safeNum(r.close))} | ${r.vol == null ? 'N/A' : String(r.vol)} |\n`; } return { content: [{ type: 'text', text: out }] }; } catch (err) { return { content: [{ type: 'text', text: `获取分钟K线失败:${err instanceof Error ? err.message : String(err)}` }], isError: true }; } } }; function safeNum(v: any): number | null { const n = Number(v); return Number.isFinite(n) ? n : null; } function fmt(n: number | null, d = 2): string { return n == null ? 'N/A' : n.toFixed(d); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/guangxiangdebizi/my-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server