Files
auv/src/lib/stock-api.ts
T
Sakurasan b1f216b2a4 feat: K线增加分钟/小时周期切换,拆分独立KLineCard组件
- 后端:腾讯 mkline 新增 /api/stock/history-minute(m1/m5/m15/m30/m60)
- 新组件 kline-card.tsx:自包含周期/折线蜡烛/指标开关与K线数据加载,
  切周期只重拉K线,不刷新页面其他模块
- 详情页瘦身为独立模块:K线图 / 今开最高最低昨收 / 每日行情明细互不耦合
- 时间统一按 UTC 解析传 Unix 秒,修复日线 invalid date/N/A 与分钟线时区问题
- 资金流向失败降级为非致命,不再导致整页报错
- vite 构建拆分 recharts/lightweight-charts/router 独立 chunk
2026-08-28 17:43:11 +08:00

476 lines
14 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 股票数据获取工具:通过 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 | null | undefined): BoardInfo {
// 题材列表等场景下 securityCode 可能为 null(无领涨股的题材),兜底为主板
if (!code) return { board: "main", label: "", className: "" };
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线数据
* @param code 股票代码(6位)
* @param period 周期:m1/m5/m15/m30/m60(60=小时线)
* @param count K线数量(默认320)
*/
export async function fetchStockHistoryMinute(
code: string,
period: "m1" | "m5" | "m15" | "m30" | "m60" = "m60",
count: number = 320,
): Promise<KLineData[]> {
if (!/^\d{6}$/.test(code)) {
throw new Error("股票代码格式错误,需为6位数字");
}
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/stock/history-minute?code=${code}&period=${period}&count=${count}`;
try {
const resp = await fetch(url, { method: "GET" });
if (!resp.ok) {
let errorMsg = `请求失败 (${resp.status})`;
try {
const errData = await resp.json();
errorMsg = errData.detail || errorMsg;
} catch {
// 忽略 JSON 解析错误
}
throw new Error(errorMsg);
}
const result = await resp.json();
if (!result.data || !Array.isArray(result.data)) {
throw new Error(result.detail || "未获取到分钟K线数据");
}
return result.data as KLineData[];
} catch (err) {
console.error("[stock-api] 获取分钟K线数据失败:", err);
return [];
}
}
/**
* 获取股票历史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;
}
}