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:
Sakurasan
2026-08-28 02:09:49 +08:00
parent 82006f7cf9
commit d5516b72d7
13 changed files with 2465 additions and 5 deletions
+233
View File
@@ -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}`);
}
+3 -2
View File
@@ -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),
]);