// 股票资金流向数据代理 Edge Function // 数据源:东方财富妙想MX API (mkapi2.dfcfs.com) // 每日150次调用限制 // 提供日K线资金流向、累计趋势、主力vs散户对比分析 function corsHeaders() { return { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'content-type', }; } // 根据代码推断市场标识(东方财富格式) function getEastMoneyMarket(code: string): string { if (code.startsWith('688')) return '6'; if (code.startsWith('60')) return '1'; return '0'; } // 根据代码推断市场前缀(腾讯格式) function getMarketPrefix(code: string): string { if (code.startsWith('688') || code.startsWith('60')) return 'sh'; if (code.startsWith('920') || code.startsWith('8') || code.startsWith('4')) return 'bj'; return 'sz'; } interface FundFlowData { date: string; mainNetInflow: number; // 主力净流入 superLargeInflow: number; // 超大单流入 superLargeOutflow: number; // 超大单流出 largeInflow: number; // 大单流入 largeOutflow: number; // 大单流出 mediumInflow: number; // 中单流入 mediumOutflow: number; // 中单流出 smallInflow: number; // 小单流入 smallOutflow: number; // 小单流出 mainNetInflowPercent: number; // 主力净流入占比 superLargeInflowPercent: number; // 超大单流入占比 superLargeOutflowPercent: number; // 超大单流出占比 largeInflowPercent: number; // 大单流入占比 largeOutflowPercent: number; // 大单流出占比 mediumInflowPercent: number; // 中单流入占比 mediumOutflowPercent: number; // 中单流出占比 smallInflowPercent: number; // 小单流入占比 smallOutflowPercent: number; // 小单流出占比 turnover: number; // 成交额 // 计算字段 mainForceNet: number; // 主力净额(超大单+大单净流入) retailNet: number; // 散户净额(中单+小单净流入) cumulativeMainNet: number; // 累计主力净流入 cumulativeRetailNet: number; // 累计散户净流入 changePercent: number; // 当日涨幅% closePrice: number; // 收盘价 } // 解析金额文本(支持 "1.432亿元", "-9915万元", "0元" 等格式) function parseAmount(text: string | number): number { if (typeof text === 'number') return text; if (!text || typeof text !== 'string') return 0; const trimmed = text.trim(); if (!trimmed) return 0; const sign = trimmed.startsWith('-') ? -1 : 1; const cleanText = trimmed.replace(/^[+-]/, '').trim(); const match = cleanText.match(/^([\d.]+)\s*(亿|万|元)?$/); if (!match) return 0; const value = parseFloat(match[1]) || 0; const unit = match[2] || '元'; if (unit === '亿') return sign * value * 100000000; if (unit === '万') return sign * value * 10000; return sign * value; } // 调用东方财富妙想MX API获取多日资金流向 async function fetchMXApi(name: string, days: number): Promise { const apiKey = Deno.env.get('MX_APIKEY'); if (!apiKey) { console.warn('[stock-fund-flow] 未配置 MX_APIKEY 环境变量'); return null; } const url = 'https://mkapi2.dfcfs.com/finskillshub/api/claw/query'; const payload = { toolQuery: `${name}最近${days}天主力资金流向和成交额` }; console.log(`[stock-fund-flow] MX API 请求: ${name}, ${days}天`); try { const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': apiKey, }, body: JSON.stringify(payload), }); if (!resp.ok) { console.error(`[stock-fund-flow] MX API 返回错误: ${resp.status}`); return null; } const result = await resp.json(); console.log(`[stock-fund-flow] MX API 返回状态: ${result?.status}`); if (result?.status !== 0) { console.error(`[stock-fund-flow] MX API 业务错误: ${result?.message}`); return null; } // 解析数据 const dtoList = result?.data?.data?.searchDataResultDTO?.dataTableDTOList || []; if (!dtoList || dtoList.length === 0) { console.warn('[stock-fund-flow] MX API 无数据'); return null; } const dto = dtoList[0]; const nameMap = dto.nameMap || {}; const table = dto.table || {}; const rawTable = dto.rawTable || {}; const dates = table.headName || []; if (!dates || dates.length === 0) { console.warn('[stock-fund-flow] MX API 无日期数据'); return null; } // 查找列ID const nameToId: Record = {}; for (const [kid, v] of Object.entries(nameMap)) { if (typeof v === 'string') { nameToId[v] = kid; } } // 打印所有可用的列名,便于调试 console.log('[stock-fund-flow] MX API 可用列名:', Object.values(nameToId)); const mainNetColId = nameToId['(区间)主力净流入资金']; const amountColId = nameToId['区间成交额']; const dataList: any[] = []; for (let i = 0; i < dates.length; i++) { let dateStr = String(dates[i]); // 标准化日期格式为 YYYY-MM-DD // 处理 "2026-07-04"、"2026/07/04"、"07-04" 等格式 dateStr = dateStr.trim(); if (/^\d{4}[-\/]\d{1,2}[-\/]\d{1,2}/.test(dateStr)) { // 完整日期格式,统一分隔符 dateStr = dateStr.replace(/\//g, '-'); // 补全月份和日期 const parts = dateStr.split('-'); if (parts.length >= 3) { dateStr = `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`; } } else if (/^\d{1,2}[-\/]\d{1,2}/.test(dateStr)) { // 只有月-日,补当前年份 const now = new Date(); const parts = dateStr.split(/[-\/]/); dateStr = `${now.getFullYear()}-${parts[0].padStart(2, '0')}-${parts[1].padStart(2, '0')}`; } // 主力净流入 let mainNetInflow = 0; if (mainNetColId && rawTable[mainNetColId] && i < rawTable[mainNetColId].length) { const rawVal = rawTable[mainNetColId][i]; if (typeof rawVal === 'number') { mainNetInflow = rawVal; } else if (typeof rawVal === 'string') { mainNetInflow = parseAmount(rawVal); } } // 成交额 let amount = 0; if (amountColId && rawTable[amountColId] && i < rawTable[amountColId].length) { const rawVal = rawTable[amountColId][i]; if (typeof rawVal === 'number') { amount = rawVal; } else if (typeof rawVal === 'string') { amount = parseAmount(rawVal); } } dataList.push({ date: dateStr, mainNetInflow, amount, }); } console.log(`[stock-fund-flow] MX API 解析完成,共 ${dataList.length} 条数据`); if (dataList.length > 0) { console.log(`[stock-fund-flow] 首条数据:`, JSON.stringify(dataList[0])); console.log(`[stock-fund-flow] 末条数据:`, JSON.stringify(dataList[dataList.length - 1])); } return dataList; } catch (err) { console.error('[stock-fund-flow] MX API 请求失败:', err); return null; } } Deno.serve(async (req) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders() }); } const url = new URL(req.url); const code = url.searchParams.get('code') || ''; const name = url.searchParams.get('name') || ''; const days = parseInt(url.searchParams.get('days') || '30'); if (!/^\d{6}$/.test(code)) { return new Response(JSON.stringify({ error: '股票代码格式错误,需为6位数字' }), { status: 400, headers: corsHeaders(), }); } const market = getEastMoneyMarket(code); const secid = `${market}.${code}`; try { console.log(`[stock-fund-flow] 请求股票: ${secid}, 天数: ${days}`); // 首先尝试获取股票名称(如果未提供) let stockName = name; if (!stockName) { try { const quoteUrl = `https://qt.gtimg.cn/q=${getMarketPrefix(code)}${code}`; const quoteResp = await fetch(quoteUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } }); if (quoteResp.ok) { const quoteText = await quoteResp.text(); const eqIdx = quoteText.indexOf('="'); if (eqIdx !== -1) { const content = quoteText.substring(eqIdx + 2, quoteText.lastIndexOf('"')); const parts = content.split('~'); stockName = parts[1] || ''; } } } catch (err) { console.warn('[stock-fund-flow] 获取股票名称失败:', err); } } // 数据源1:东方财富妙想MX API let mxData: any[] | null = null; if (stockName) { mxData = await fetchMXApi(stockName, days); } // 如果MX API失败,回退到原有东方财富API let fflowKlines: string[] = []; if (!mxData || mxData.length === 0) { console.log('[stock-fund-flow] MX API 失败,回退到原有东方财富API'); const fflowUrl = `https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get?lmt=${days}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63,f64,f65,f66,f67,f68,f69&ut=b2884a393a59ad64002292a3e90d46a5&secid=${secid}`; const fflowResp = await fetch(fflowUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://quote.eastmoney.com/', }, }); if (fflowResp.ok) { const fflowResult = await fflowResp.json(); fflowKlines = fflowResult?.data?.klines || []; } } // 数据源2:腾讯K线API(提供收盘价、涨跌幅、成交额) const marketPrefix = getMarketPrefix(code); const stockCode = `${marketPrefix}${code}`; const klineUrl = `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${stockCode},day,,,${days + 5},qfq`; const klineMap: Map = new Map(); try { const klineResp = await fetch(klineUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://stockapp.finance.qq.com/', }, }); if (klineResp.ok) { const klineResult = await klineResp.json(); const stockData = klineResult?.data?.[stockCode]; const rawKlines = stockData?.qfqday || stockData?.day || []; console.log(`[stock-fund-flow] 腾讯K线数据条数: ${rawKlines.length}`); if (Array.isArray(rawKlines)) { let prevClose = 0; rawKlines.forEach((record: any[]) => { if (record && record.length >= 7) { const date = record[0]; const close = parseFloat(record[2]) || 0; const turnover = parseFloat(record[6]) || 0; let changePercent = 0; if (prevClose > 0) { changePercent = ((close - prevClose) / prevClose) * 100; } klineMap.set(date, { close, changePercent, turnover }); prevClose = close; } }); } } } catch (err) { console.warn('[stock-fund-flow] 获取腾讯K线数据失败:', err); } // 解析数据 let parsedData: FundFlowData[] = []; if (mxData && mxData.length > 0) { // MX API 返回数据按日期降序(最新在前),需反转为正序以正确计算累计值 const sortedMxData = [...mxData].sort((a, b) => { return String(a.date).localeCompare(String(b.date)); }); let cumulativeMain = 0; let cumulativeRetail = 0; parsedData = sortedMxData.map((item) => { const mainNetInflow = item.mainNetInflow || 0; const turnover = item.amount || 0; // MX API 只返回主力净流入,没有散户数据 // 主力 = 主力净流入 // 散户 = 数据不可用(显示为0) const mainForceNet = mainNetInflow; const retailNet = 0; // MX API 不提供散户数据 // 按比例估算各档位(仅用于展示分布) const superLargeNet = mainNetInflow * 0.4; const largeNet = mainNetInflow * 0.6; const mediumNet = 0; const smallNet = 0; cumulativeMain += mainForceNet; cumulativeRetail += 0; // 散户累计不计算 // 从K线数据获取收盘价、涨跌幅 const klineInfo = klineMap.get(item.date); return { date: item.date, mainNetInflow, superLargeInflow: Math.max(0, superLargeNet), superLargeOutflow: Math.abs(Math.min(0, superLargeNet)), largeInflow: Math.max(0, largeNet), largeOutflow: Math.abs(Math.min(0, largeNet)), mediumInflow: Math.max(0, mediumNet), mediumOutflow: Math.abs(Math.min(0, mediumNet)), smallInflow: Math.max(0, smallNet), smallOutflow: Math.abs(Math.min(0, smallNet)), mainNetInflowPercent: 0, superLargeInflowPercent: 0, superLargeOutflowPercent: 0, largeInflowPercent: 0, largeOutflowPercent: 0, mediumInflowPercent: 0, mediumOutflowPercent: 0, smallInflowPercent: 0, smallOutflowPercent: 0, turnover, mainForceNet, retailNet, cumulativeMainNet: cumulativeMain, cumulativeRetailNet: cumulativeRetail, changePercent: klineInfo?.changePercent || 0, closePrice: klineInfo?.close || 0, }; }); } else if (fflowKlines.length > 0) { // 使用原有东方财富API数据 let cumulativeMain = 0; let cumulativeRetail = 0; parsedData = fflowKlines.map((line: string) => { const fields = line.split(','); const date = fields[0]; const superLargeNet = (parseFloat(fields[2]) || 0) - (parseFloat(fields[3]) || 0); const largeNet = (parseFloat(fields[4]) || 0) - (parseFloat(fields[5]) || 0); const mediumNet = (parseFloat(fields[6]) || 0) - (parseFloat(fields[7]) || 0); const smallNet = (parseFloat(fields[8]) || 0) - (parseFloat(fields[9]) || 0); const mainForceNet = superLargeNet + largeNet; const retailNet = mediumNet + smallNet; cumulativeMain += mainForceNet; cumulativeRetail += retailNet; const klineInfo = klineMap.get(date); return { date, mainNetInflow: parseFloat(fields[1]) || 0, superLargeInflow: parseFloat(fields[2]) || 0, superLargeOutflow: parseFloat(fields[3]) || 0, largeInflow: parseFloat(fields[4]) || 0, largeOutflow: parseFloat(fields[5]) || 0, mediumInflow: parseFloat(fields[6]) || 0, mediumOutflow: parseFloat(fields[7]) || 0, smallInflow: parseFloat(fields[8]) || 0, smallOutflow: parseFloat(fields[9]) || 0, mainNetInflowPercent: parseFloat(fields[10]) || 0, superLargeInflowPercent: parseFloat(fields[11]) || 0, superLargeOutflowPercent: parseFloat(fields[12]) || 0, largeInflowPercent: parseFloat(fields[13]) || 0, largeOutflowPercent: parseFloat(fields[14]) || 0, mediumInflowPercent: parseFloat(fields[15]) || 0, mediumOutflowPercent: parseFloat(fields[16]) || 0, smallInflowPercent: parseFloat(fields[17]) || 0, smallOutflowPercent: parseFloat(fields[18]) || 0, turnover: klineInfo?.turnover || 0, mainForceNet, retailNet, cumulativeMainNet: cumulativeMain, cumulativeRetailNet: cumulativeRetail, changePercent: klineInfo?.changePercent || 0, closePrice: klineInfo?.close || 0, }; }); } if (parsedData.length === 0) { return new Response(JSON.stringify({ error: '未获取到资金流向数据', data: [], count: 0, }), { status: 404, headers: corsHeaders(), }); } console.log(`[stock-fund-flow] 解析完成,共 ${parsedData.length} 条数据`); console.log(`[stock-fund-flow] 首条数据 turnover:`, parsedData[0]?.turnover, 'mainForceNet:', parsedData[0]?.mainForceNet); // 计算总体统计 const totalMainNet = parsedData.reduce((sum, d) => sum + d.mainForceNet, 0); const totalTurnover = parsedData.reduce((sum, d) => sum + d.turnover, 0); // 大单流入资金 ≈ 主买资金(机构主动买入的近似指标) const totalLargeInflow = parsedData.reduce((sum, d) => sum + d.largeInflow, 0); return new Response(JSON.stringify({ data: parsedData, count: parsedData.length, stockCode: code, summary: { totalMainNet, totalTurnover, totalLargeInflow, // 大单流入资金(≈主买资金) avgDailyMainNet: totalMainNet / parsedData.length, positiveDays: parsedData.filter(d => d.mainForceNet > 0).length, negativeDays: parsedData.filter(d => d.mainForceNet < 0).length, }, }), { headers: corsHeaders() }); } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error('[stock-fund-flow] 错误:', msg); return new Response(JSON.stringify({ error: `获取资金流向数据失败: ${msg}`, data: [], count: 0, }), { status: 500, headers: corsHeaders(), }); } });