feat: 接入同花顺官方SDK,新增v2数据接口,股票详情K线改用v2(前复权日K)
- vendor 同花顺官方 SDK 到 backend/sdk(含K线>10年自动切片、重试、拼音首字母检索兜底) - 新增 /api/v2 路由:行情/估值/财务/日历/指数/K线/标的检索 - 股票详情页K线改用 v2 同花顺接口(前复权日K+总手+按昨收涨跌幅) - 密钥仅后端持有,响应/日志无泄露 - 新增 run_local.sh 本地直接拉起(不再依赖 docker)
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// v2 数据接口(同花顺官方 API)前端客户端
|
||||
// 仅调用后端 /api/v2 代理,密钥由后端持有,前端永远接触不到。
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
/* ── 通用信封(后端返回 { data, count })── */
|
||||
|
||||
interface V2Response<T> {
|
||||
data: T;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
async function v2Get<T>(path: string): Promise<T> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/v2${path}`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) {
|
||||
// 尝试读取 detail(FastAPI 错误信息)
|
||||
try {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.detail || `请求失败 (${resp.status})`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw e;
|
||||
throw new Error(`请求失败 (${resp.status})`);
|
||||
}
|
||||
}
|
||||
const result: V2Response<T> = await resp.json();
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
console.error("[fuyao-api] v2 请求失败:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 类型 ── */
|
||||
|
||||
export interface V2Ticker {
|
||||
thscode: string;
|
||||
ticker: string;
|
||||
name: string;
|
||||
exchange: string;
|
||||
asset_type: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface V2PriceSnapshot {
|
||||
thscode: string;
|
||||
ticker: string;
|
||||
volume: number;
|
||||
turnover: number;
|
||||
last_price: number;
|
||||
price_change: number;
|
||||
price_change_ratio_pct: number;
|
||||
open_price: number;
|
||||
high_price: number;
|
||||
low_price: number;
|
||||
prev_price: number;
|
||||
}
|
||||
|
||||
export interface V2Valuation {
|
||||
thscode: string;
|
||||
ticker: string;
|
||||
name: string;
|
||||
pe_ttm: number;
|
||||
pe_mrq: number;
|
||||
pb_mrq: number;
|
||||
ps_ttm: number;
|
||||
pcf_ttm: number;
|
||||
}
|
||||
|
||||
export interface V2Financial {
|
||||
thscode: string;
|
||||
ticker: string;
|
||||
fiscal_year: number;
|
||||
fiscal_period: string;
|
||||
operating_income: number;
|
||||
operating_costs: number;
|
||||
net_profit: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface V2TradingDay {
|
||||
date: string; // YYYYMMDD
|
||||
date_ms: number;
|
||||
}
|
||||
|
||||
export interface V2IndexItem {
|
||||
thscode: string;
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ── 基础 / 检索 ── */
|
||||
|
||||
export function v2TickerSearch(q: string, limit = 10): Promise<V2Ticker[]> {
|
||||
return v2Get(`/meta/tickers/search?q=${encodeURIComponent(q)}&limit=${limit}`);
|
||||
}
|
||||
|
||||
/** 判断是否已是标准 thscode(如 600519.SH / 000021.SZ / 830xxx.BJ) */
|
||||
function isThscode(token: string): boolean {
|
||||
return /^\d{6}\.(SH|SZ|BJ)$/i.test(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把用户输入解析成 thscode 列表。
|
||||
* 支持:标准 thscode(600519.SH)、纯代码(600519)、名称(茅台)、拼音首字母(gzmt)。
|
||||
* 输入用逗号/空格分隔多个标的;每个 token 单独解析。
|
||||
* 解析失败(找不到)的 token 会被跳过。
|
||||
*/
|
||||
export async function resolveThscodes(input: string): Promise<string[]> {
|
||||
const tokens = input
|
||||
.split(/[,,\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (const token of tokens) {
|
||||
if (isThscode(token)) {
|
||||
out.push(token.toUpperCase());
|
||||
} else {
|
||||
try {
|
||||
const hits = await v2TickerSearch(token, 1);
|
||||
if (hits.length > 0) out.push(hits[0].thscode);
|
||||
} catch {
|
||||
// 忽略单个 token 解析失败
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── A股 ── */
|
||||
|
||||
/** 6位代码 → thscode(按代码前缀推断交易所后缀) */
|
||||
export function codeToThscode(code: string): string {
|
||||
const c = code.trim();
|
||||
if (/^\d{6}\.(SH|SZ|BJ)$/i.test(c)) return c.toUpperCase();
|
||||
if (!/^\d{6}$/.test(c)) return c;
|
||||
if (/^(60|68|9)/.test(c)) return `${c}.SH`;
|
||||
if (/^(00|30|20|12)/.test(c)) return `${c}.SZ`;
|
||||
if (/^(4|8|92)/.test(c)) return `${c}.BJ`;
|
||||
return `${c}.SH`;
|
||||
}
|
||||
|
||||
export interface V2PriceBar {
|
||||
date_ms: number;
|
||||
open_price: number;
|
||||
high_price: number;
|
||||
low_price: number;
|
||||
close_price: number;
|
||||
volume: number;
|
||||
turnover: number;
|
||||
}
|
||||
|
||||
export function v2PriceSnapshot(thscodes: string): Promise<V2PriceSnapshot[]> {
|
||||
return v2Get(`/prices/snapshot?thscodes=${encodeURIComponent(thscodes)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史日K(毫秒时间戳)。后端用官方 SDK,>10 年窗口自动切片。
|
||||
*/
|
||||
export function v2PriceHistorical(
|
||||
thscode: string,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
adjust = "forward",
|
||||
): Promise<V2PriceBar[]> {
|
||||
return v2Get(`/prices/historical?thscode=${encodeURIComponent(thscode)}&start=${startMs}&end=${endMs}&adjust=${adjust}`);
|
||||
}
|
||||
|
||||
/** 毫秒时间戳 → 北京时间(UTC+8)日期 YYYY-MM-DD */
|
||||
function bjDate(ms: number): string {
|
||||
const d = new Date(ms + 8 * 60 * 60 * 1000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 版股票日K(供股票详情页复用现有图表结构)。
|
||||
* 返回与 v1 KLineData 兼容的结构;涨跌幅按昨收计算。
|
||||
*/
|
||||
export async function fetchStockHistoryV2(
|
||||
code: string,
|
||||
days: number = 120,
|
||||
adjust: string = "forward",
|
||||
): Promise<Array<{
|
||||
date: string;
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
changePercent: number;
|
||||
}>> {
|
||||
const thscode = codeToThscode(code);
|
||||
const end = Date.now();
|
||||
const start = end - days * 24 * 60 * 60 * 1000;
|
||||
const bars = await v2PriceHistorical(thscode, start, end, adjust);
|
||||
const sorted = bars.slice().sort((a, b) => a.date_ms - b.date_ms);
|
||||
return sorted.map((b, i) => {
|
||||
const prevClose = i > 0 ? sorted[i - 1].close_price : b.open_price;
|
||||
const changePercent = prevClose > 0 ? ((b.close_price - prevClose) / prevClose) * 100 : 0;
|
||||
return {
|
||||
date: bjDate(b.date_ms),
|
||||
open: b.open_price,
|
||||
close: b.close_price,
|
||||
high: b.high_price,
|
||||
low: b.low_price,
|
||||
volume: b.volume / 100, // 股 → 手(与 v1 口径一致,现有图表按手展示)
|
||||
changePercent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function v2Valuations(thscodes: string): Promise<V2Valuation[]> {
|
||||
return v2Get(`/valuations/snapshot?thscodes=${encodeURIComponent(thscodes)}`);
|
||||
}
|
||||
|
||||
export function v2Financials(
|
||||
statement: "income-statements" | "balance-sheets" | "cash-flow-statements",
|
||||
thscode: string,
|
||||
period = "annual",
|
||||
limit = 6,
|
||||
): Promise<V2Financial[]> {
|
||||
return v2Get(`/financials/${statement}?thscode=${encodeURIComponent(thscode)}&period=${period}&limit=${limit}`);
|
||||
}
|
||||
|
||||
export function v2TradingDays(): Promise<V2TradingDay[]> {
|
||||
return v2Get(`/calendar/trading-days`);
|
||||
}
|
||||
|
||||
/* ── 指数 / 板块 ── */
|
||||
|
||||
export function v2IndexCatalog(tag = "industry"): Promise<V2IndexItem[]> {
|
||||
return v2Get(`/index/catalog?tag=${tag}`);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useMemo, Fragment } from "react";
|
||||
import { collectionsApi } from "@/lib/api-client";
|
||||
import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
|
||||
import { fetchStockQuote, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
|
||||
import { fetchStockHistoryV2 } from "@/lib/fuyao-api";
|
||||
import StockProfileTabs from "@/components/stock-profile-tabs";
|
||||
import { getUserId } from "@/lib/user-id";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
@@ -182,7 +183,7 @@ function StockDetail() {
|
||||
setFundFlowLoading(true);
|
||||
|
||||
const [historyResult, fundFlowResult] = await Promise.allSettled([
|
||||
fetchStockHistory(code, chartDays),
|
||||
fetchStockHistoryV2(code, chartDays),
|
||||
fetchStockFundFlow(code, quote.name, 21),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user