480 lines
13 KiB
TypeScript
Executable File
480 lines
13 KiB
TypeScript
Executable File
// 股票数据获取工具:通过 Python 后端代理调用腾讯财经API
|
||
import { getApiBaseUrl } from "@/lib/api-client";
|
||
|
||
export interface StockQuote {
|
||
code: string;
|
||
market: string;
|
||
name: string;
|
||
todayOpen: number;
|
||
yesterdayClose: number;
|
||
currentPrice: number;
|
||
high: number;
|
||
low: number;
|
||
volume: number;
|
||
amount: number;
|
||
outerDisk: number; // 外盘(手)
|
||
innerDisk: number; // 内盘(手)
|
||
date: string;
|
||
time: string;
|
||
change: number;
|
||
changePercent: number;
|
||
turnoverRate: number; // 换手率%
|
||
pe: number; // 市盈率
|
||
pb: number; // 市净率
|
||
totalMarketCap: number; // 总市值
|
||
circulatingMarketCap: number; // 流通市值
|
||
}
|
||
|
||
export interface KLineData {
|
||
date: string;
|
||
open: number;
|
||
close: number;
|
||
high: number;
|
||
low: number;
|
||
volume: number;
|
||
changePercent?: number;
|
||
turnover?: number;
|
||
amplitude?: number;
|
||
turnoverRate?: number;
|
||
}
|
||
|
||
export interface StockSearchResult {
|
||
code: string;
|
||
name: string;
|
||
market: string;
|
||
type: string;
|
||
}
|
||
|
||
export 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; // 主力净额(超大单+大单净流入)
|
||
cumulativeMainNet: number; // 累计主力净流入
|
||
changePercent: number; // 当日涨幅%
|
||
closePrice: number; // 收盘价
|
||
}
|
||
|
||
export interface FundFlowSummary {
|
||
totalMainNet: number; // 期间主力总净流入
|
||
totalTurnover: number; // 期间总成交额
|
||
totalLargeInflow?: number; // 期间大单流入资金(≈主买资金)
|
||
avgDailyMainNet: number; // 日均主力净流入
|
||
positiveDays: number; // 主力净流入为正的天数
|
||
negativeDays: number; // 主力净流入为负的天数
|
||
}
|
||
|
||
export type StockBoard = "main" | "cyb" | "kcb" | "bjb";
|
||
|
||
export interface BoardInfo {
|
||
board: StockBoard;
|
||
label: string;
|
||
className: string;
|
||
}
|
||
|
||
/**
|
||
* 根据股票代码判断所属板块
|
||
* - 688开头:科创板(KCB)
|
||
* - 300/301开头:创业板(CYB,301为注册制)
|
||
* - 8/4/920开头:北交所(BJB)
|
||
* - 其他:主板
|
||
*/
|
||
export function getStockBoard(code: string): BoardInfo {
|
||
if (code.startsWith("688")) {
|
||
return { board: "kcb", label: "科", className: "bg-red-500/10 text-red-500 border-red-500/30" };
|
||
}
|
||
// 300/301 开头均为创业板(301 为创业板注册制)
|
||
if (code.startsWith("300") || code.startsWith("301")) {
|
||
return { board: "cyb", label: "创", className: "bg-purple-500/10 text-purple-500 border-purple-500/30" };
|
||
}
|
||
if (code.startsWith("8") || code.startsWith("4") || code.startsWith("920")) {
|
||
return { board: "bjb", label: "北", className: "bg-orange-500/10 text-orange-500 border-orange-500/30" };
|
||
}
|
||
return { board: "main", label: "", className: "" };
|
||
}
|
||
|
||
/**
|
||
* 搜索股票(支持名称/代码/拼音模糊匹配)
|
||
*/
|
||
export async function fetchStockSearch(keyword: string): Promise<StockSearchResult[]> {
|
||
const trimmed = keyword.trim();
|
||
if (!trimmed) return [];
|
||
|
||
const baseUrl = getApiBaseUrl();
|
||
const url = `${baseUrl}/api/stock/search?keyword=${encodeURIComponent(trimmed)}`;
|
||
|
||
try {
|
||
const resp = await fetch(url, { method: "GET" });
|
||
if (!resp.ok) {
|
||
console.error("[stock-api] 搜索失败:", resp.status);
|
||
return [];
|
||
}
|
||
const result = await resp.json();
|
||
if (!result.data || !Array.isArray(result.data)) return [];
|
||
return result.data as StockSearchResult[];
|
||
} catch (err) {
|
||
console.error("[stock-api] 搜索股票失败:", err);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取单只股票实时行情
|
||
*/
|
||
export async function fetchStockQuote(code: string): Promise<StockQuote | null> {
|
||
if (!/^\d{6}$/.test(code)) {
|
||
throw new Error("股票代码格式错误,需为6位数字");
|
||
}
|
||
|
||
const baseUrl = getApiBaseUrl();
|
||
const url = `${baseUrl}/api/stock/quote?code=${code}`;
|
||
|
||
console.log("[stock-api] 请求URL:", url);
|
||
|
||
try {
|
||
const resp = await fetch(url, { method: "GET" });
|
||
|
||
console.log("[stock-api] 响应状态:", resp.status);
|
||
|
||
if (!resp.ok) {
|
||
let errorMsg = `请求失败 (${resp.status})`;
|
||
try {
|
||
const errData = await resp.json();
|
||
errorMsg = errData.error || errorMsg;
|
||
} catch {
|
||
// 忽略 JSON 解析错误
|
||
}
|
||
throw new Error(errorMsg);
|
||
}
|
||
|
||
const result = await resp.json();
|
||
console.log("[stock-api] 响应数据:", result);
|
||
|
||
if (!result.data) {
|
||
throw new Error(result.error || "未获取到股票数据");
|
||
}
|
||
|
||
return result.data as StockQuote;
|
||
} catch (err) {
|
||
console.error("[stock-api] 获取股票数据失败:", err);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取股票历史K线数据(通过Edge Function代理腾讯财经API)
|
||
* @param code 股票代码(6位)
|
||
* @param days 天数(默认90天)
|
||
*/
|
||
export async function fetchStockHistory(code: string, days: number = 90): Promise<KLineData[]> {
|
||
if (!/^\d{6}$/.test(code)) {
|
||
throw new Error("股票代码格式错误,需为6位数字");
|
||
}
|
||
|
||
const baseUrl = getApiBaseUrl();
|
||
const url = `${baseUrl}/api/stock/history?code=${code}&days=${days}`;
|
||
|
||
console.log("[stock-api] 请求历史K线URL:", url);
|
||
|
||
try {
|
||
const resp = await fetch(url, { method: "GET" });
|
||
|
||
console.log("[stock-api] 历史K线响应状态:", resp.status);
|
||
|
||
if (!resp.ok) {
|
||
let errorMsg = `请求失败 (${resp.status})`;
|
||
try {
|
||
const errData = await resp.json();
|
||
errorMsg = errData.error || errorMsg;
|
||
} catch {
|
||
// 忽略 JSON 解析错误
|
||
}
|
||
console.error("[stock-api] 历史K线错误:", errorMsg);
|
||
throw new Error(errorMsg);
|
||
}
|
||
|
||
const result = await resp.json();
|
||
console.log("[stock-api] 历史K线数据条数:", result.count);
|
||
|
||
if (!result.data || !Array.isArray(result.data)) {
|
||
throw new Error(result.error || "未获取到历史数据");
|
||
}
|
||
|
||
return result.data as KLineData[];
|
||
} catch (err) {
|
||
console.error("[stock-api] 获取历史K线数据失败:", err);
|
||
// 降级方案:返回空数组,让前端使用模拟数据
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量获取多只股票行情(并行调用)
|
||
*/
|
||
export async function fetchStockQuotes(codes: string[]): Promise<Map<string, StockQuote>> {
|
||
const map = new Map<string, StockQuote>();
|
||
const results = await Promise.allSettled(
|
||
codes.map((code) => fetchStockQuote(code))
|
||
);
|
||
|
||
results.forEach((res, idx) => {
|
||
if (res.status === "fulfilled" && res.value) {
|
||
map.set(codes[idx], res.value);
|
||
}
|
||
});
|
||
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* 获取股票资金流向数据
|
||
* @param code 股票代码(6位)
|
||
* @param name 股票名称(可选,用于MX API查询)
|
||
* @param days 天数(默认21个交易日)
|
||
*/
|
||
export async function fetchStockFundFlow(code: string, name?: string, days: number = 21): Promise<{ data: FundFlowData[]; summary: FundFlowSummary | null; count: number }> {
|
||
if (!/^\d{6}$/.test(code)) {
|
||
throw new Error("股票代码格式错误,需为6位数字");
|
||
}
|
||
|
||
const baseUrl = getApiBaseUrl();
|
||
const nameParam = name ? `&name=${encodeURIComponent(name)}` : '';
|
||
const url = `${baseUrl}/api/stock/fund-flow?code=${code}${nameParam}&days=${days}`;
|
||
|
||
console.log("[stock-api] 请求资金流向URL:", url);
|
||
|
||
try {
|
||
const resp = await fetch(url, { method: "GET" });
|
||
|
||
console.log("[stock-api] 资金流向响应状态:", resp.status);
|
||
|
||
if (!resp.ok) {
|
||
let errorMsg = `请求失败 (${resp.status})`;
|
||
try {
|
||
const errData = await resp.json();
|
||
errorMsg = errData.error || errorMsg;
|
||
} catch {
|
||
// 忽略 JSON 解析错误
|
||
}
|
||
console.error("[stock-api] 资金流向错误:", errorMsg);
|
||
throw new Error(errorMsg);
|
||
}
|
||
|
||
const result = await resp.json();
|
||
console.log("[stock-api] 资金流向响应:", JSON.stringify(result).substring(0, 500));
|
||
console.log("[stock-api] 资金流向数据条数:", result.count);
|
||
|
||
if (!result.data || !Array.isArray(result.data)) {
|
||
throw new Error(result.error || "未获取到资金流向数据");
|
||
}
|
||
|
||
return {
|
||
data: result.data as FundFlowData[],
|
||
summary: result.summary || null,
|
||
count: result.count || 0,
|
||
};
|
||
} catch (err) {
|
||
console.error("[stock-api] 获取资金流向数据失败:", err);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// ---- 公司概况 ----
|
||
|
||
export interface CompanyProfile {
|
||
code: string;
|
||
name: string;
|
||
fullName: string;
|
||
englishName: string;
|
||
boardType: string;
|
||
industry: string;
|
||
exchange: string;
|
||
industryDetail: string;
|
||
chairman: string;
|
||
generalManager: string;
|
||
secretary: string;
|
||
phone: string;
|
||
email: string;
|
||
fax: string;
|
||
website: string;
|
||
officeAddress: string;
|
||
registeredAddress: string;
|
||
region: string;
|
||
postalCode: string;
|
||
registeredCapital: string;
|
||
unifiedCreditCode: string;
|
||
employees: string;
|
||
managementCount: string;
|
||
lawFirm: string;
|
||
auditor: string;
|
||
businessScope: string;
|
||
companyProfile: string;
|
||
establishmentDate: string;
|
||
listingDate: string;
|
||
listingPrice: string;
|
||
issuePriceEarningsRatio: string;
|
||
issueAmount: string;
|
||
netProceeds: string;
|
||
issueMethod: string;
|
||
}
|
||
|
||
/**
|
||
* 获取公司概况
|
||
*/
|
||
export async function fetchCompanyProfile(code: string): Promise<CompanyProfile | null> {
|
||
const baseUrl = getApiBaseUrl();
|
||
const url = `${baseUrl}/api/stock/profile?code=${code}`;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return null;
|
||
const result = await resp.json();
|
||
return result.data || null;
|
||
} catch (err) {
|
||
console.error("[stock-api] 获取公司概况失败:", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ---- 经营分析(收入构成)----
|
||
|
||
export interface BusinessSegmentItem {
|
||
itemName: string;
|
||
income: number | null;
|
||
cost: number | null;
|
||
profit: number | null;
|
||
grossRatio: number | null;
|
||
incomeRatio: number | null;
|
||
profitRatio: number | null;
|
||
costRatio: number | null;
|
||
}
|
||
|
||
export interface BusinessSegmentPeriod {
|
||
reportName: string;
|
||
items: BusinessSegmentItem[];
|
||
}
|
||
|
||
export interface BusinessSegmentsResponse {
|
||
data: BusinessSegmentPeriod[];
|
||
count: number;
|
||
code: string;
|
||
name: string;
|
||
byType: string;
|
||
}
|
||
|
||
type SegmentType = "product" | "industry" | "region";
|
||
|
||
/**
|
||
* 获取主营构成(收入构成)
|
||
*/
|
||
export async function fetchBusinessSegments(code: string, byType: SegmentType = "product", years: number = 3): Promise<BusinessSegmentsResponse | null> {
|
||
const baseUrl = getApiBaseUrl();
|
||
const url = `${baseUrl}/api/stock/business-segments?code=${code}&by_type=${byType}&years=${years}`;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return null;
|
||
return await resp.json();
|
||
} catch (err) {
|
||
console.error("[stock-api] 获取主营构成失败:", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ---- 财务指标 ----
|
||
|
||
export interface FinancialReportItem {
|
||
reportDate: string;
|
||
reportType: string;
|
||
reportDateName: string;
|
||
noticeDate: string;
|
||
indicators: Record<string, number | string | boolean>;
|
||
}
|
||
|
||
export interface FinancialDataResponse {
|
||
data: FinancialReportItem[];
|
||
count: number;
|
||
code: string;
|
||
name: string;
|
||
}
|
||
|
||
/**
|
||
* 获取财务指标数据
|
||
*/
|
||
export async function fetchFinancialData(code: string, years: number = 5): Promise<FinancialDataResponse | null> {
|
||
const baseUrl = getApiBaseUrl();
|
||
const url = `${baseUrl}/api/stock/financial?code=${code}&years=${years}`;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return null;
|
||
return await resp.json();
|
||
} catch (err) {
|
||
console.error("[stock-api] 获取财务数据失败:", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ---- 板块数据 ----
|
||
|
||
export interface SectorItem {
|
||
code: string;
|
||
name: string;
|
||
level: number | null;
|
||
changePercent: number | null;
|
||
changeAmount: number | null;
|
||
mainNetInflow: number;
|
||
mainNetInflowPercent: number | null;
|
||
superLargeInflow: number | null;
|
||
superLargeInflowPercent: number | null;
|
||
largeInflow: number | null;
|
||
largeInflowPercent: number | null;
|
||
mediumInflow: number | null;
|
||
mediumInflowPercent: number | null;
|
||
smallInflow: number | null;
|
||
smallInflowPercent: number | null;
|
||
turnover: number;
|
||
}
|
||
|
||
export type SectorType = "industry" | "concept";
|
||
|
||
export interface SectorResponse {
|
||
data: SectorItem[];
|
||
count: number;
|
||
type: SectorType;
|
||
}
|
||
|
||
/**
|
||
* 获取东方财富板块列表(按主力净流入排序)
|
||
* @param type industry=行业板块, concept=概念板块
|
||
*/
|
||
export async function fetchSectors(type: SectorType, signal?: AbortSignal): Promise<SectorItem[]> {
|
||
const baseUrl = getApiBaseUrl();
|
||
const url = `${baseUrl}/api/sectors?type=${type}`;
|
||
|
||
try {
|
||
const resp = await fetch(url, { method: "GET", signal, cache: "no-store" });
|
||
if (!resp.ok) return [];
|
||
const result: SectorResponse = await resp.json();
|
||
return result.data || [];
|
||
} catch (err) {
|
||
console.error("[stock-api] 获取板块数据失败:", err);
|
||
return [];
|
||
}
|
||
}
|