483 lines
17 KiB
TypeScript
Executable File
483 lines
17 KiB
TypeScript
Executable File
// 股票资金流向数据代理 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<any[] | null> {
|
|
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<string, string> = {};
|
|
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<string, { close: number; changePercent: number; turnover: number }> = 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(),
|
|
});
|
|
}
|
|
});
|