Files
stock/frontend/src/api/client.ts
2026-09-09 11:35:02 +08:00

422 lines
18 KiB
TypeScript
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.
import type {
AdjustMode,
Candle,
CurrentUser,
EventBacktestRequest,
EventBacktestResponse,
EtfListResponse,
EtfSyncStatus,
GlobalIndexList,
IndexDetail,
IndexWeights,
LimitBoard,
ThsBoardList,
ThsBoardMembers,
LoginRequest,
LoginResponse,
MarketOverview,
PreviewResponse,
ScreenerQueryItem,
ScreenerRunRequest,
ScreenerStreamEvent,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
StockCompanyInfo,
StockDividendOut,
StockFacets,
StockFinanceOut,
StockReferenceOut,
StockListResponse,
Timeframe,
TradesClearResponse,
TradesImportResponse,
UserTrade,
} from './types';
// dev 用 Vite 代理(/api -> :8000生产构建设 VITE_API_BASE 指向后端地址。
const BASE = import.meta.env.VITE_API_BASE ?? '';
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
async function readError(res: Response, fallback: string): Promise<string> {
const body = await res.text();
if (!body) return fallback;
try {
const data = JSON.parse(body) as { detail?: unknown };
// FastAPI 校验类 422 的 detail 是对象数组,直接当字符串用会显示成 [object Object]
if (typeof data.detail === 'string') return data.detail;
if (data.detail != null) return JSON.stringify(data.detail);
return fallback;
} catch {
return body;
}
}
async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const res = await fetch(`${BASE}${path}`, {
...init,
credentials: 'include',
headers: {
// 仅 JSON字符串 body手工设 Content-TypeFormData 必须留给浏览器生成
// multipart 边界,手工设置会导致后端解析失败 422
...(typeof init.body === 'string' ? { 'Content-Type': 'application/json' } : {}),
...init.headers,
},
});
if (res.status === 401 && !path.startsWith('/api/auth/login')) {
window.dispatchEvent(new CustomEvent('stock:unauthorized'));
}
return res;
}
export async function login(req: LoginRequest): Promise<LoginResponse> {
const res = await apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(await readError(res, '登录失败'), res.status);
return (await res.json()) as LoginResponse;
}
export async function getCurrentUser(): Promise<CurrentUser> {
const res = await apiFetch('/api/auth/me');
if (!res.ok) throw new ApiError(await readError(res, '登录状态无效'), res.status);
return (await res.json()) as CurrentUser;
}
export async function logout(): Promise<void> {
const res = await apiFetch('/api/auth/logout', { method: 'POST' });
if (!res.ok && res.status !== 401) throw new ApiError(await readError(res, '退出登录失败'), res.status);
}
/** 自然语言事件回测。全市场扫描较慢timeout 放宽到 15 分钟。 */
export async function postEventBacktest(req: EventBacktestRequest): Promise<EventBacktestResponse> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15 * 60 * 1000);
try {
const res = await apiFetch('/api/backtest/event', {
method: 'POST',
body: JSON.stringify(req),
signal: ctrl.signal,
});
if (!res.ok) throw new ApiError(await readError(res, `事件回测失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EventBacktestResponse;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new ApiError('事件回测超时(全市场扫描较慢,可先缩短日期范围或指定单只股票)', 0);
}
throw e;
} finally {
clearTimeout(timer);
}
}
/** 选股流式执行:逐行回调 NDJSON 事件(阶段/进度/解析条件),最终返回完整结果。
* signal 中止后(含读流中途)以 AbortError 拒绝,由调用方决定如何呈现。 */
export async function runScreener(
req: ScreenerRunRequest,
onEvent?: (ev: ScreenerStreamEvent) => void,
signal?: AbortSignal,
): Promise<ScreenerRunResponse> {
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req), signal });
if (!res.ok) throw new ApiError(await readError(res, `选股失败 (HTTP ${res.status})`), res.status);
if (!res.body) throw new ApiError('当前环境不支持流式响应', 0);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let result: ScreenerRunResponse | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
const ev = JSON.parse(line) as ScreenerStreamEvent;
if (ev.type === 'error') throw new ApiError(ev.message, ev.code ?? 0);
if (ev.type === 'result') result = ev.result;
onEvent?.(ev);
}
}
if (!result) throw new ApiError('选股响应中断(未收到结果事件)', 0);
return result;
}
export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<ScreenerSyncStatus> {
const res = await apiFetch('/api/screener/sync', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(await readError(res, `启动同步失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as ScreenerSyncStatus;
}
export async function getScreenerSyncStatus(): Promise<ScreenerSyncStatus> {
const res = await apiFetch('/api/screener/sync/status');
if (!res.ok) throw new ApiError(await readError(res, `获取同步状态失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as ScreenerSyncStatus;
}
export async function getStockPreview(
tsCode: string,
opts: {
limit?: number;
adjust?: AdjustMode;
timeframe?: Timeframe;
mas?: number[];
/** 向前翻页:返回该日期(不含)之前的 limit 根 K 线 + 预热好的指标 */
end?: string;
signal?: AbortSignal;
} = {},
): Promise<PreviewResponse> {
const { limit = 500, adjust = 'qfq', timeframe = '1d', mas, end, signal } = opts;
const q = new URLSearchParams({
limit: String(limit),
adjust,
timeframe,
...(mas?.length ? { mas: mas.join(',') } : {}),
...(end ? { end } : {}),
});
const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?${q.toString()}`, { signal });
if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as PreviewResponse;
}
/** 个股公司简介tushare stock_company 按需懒加载404 = ETF/无此股,调用方据此隐藏面板) */
export async function getStockCompany(tsCode: string): Promise<StockCompanyInfo> {
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/company`);
if (!res.ok) throw new ApiError(await readError(res, `获取公司简介失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockCompanyInfo;
}
/** 个股财务数据fina_indicator+三大报表关键值近五年404 = ETF/无数据,调用方据此隐藏面板) */
export async function getStockFinance(tsCode: string): Promise<StockFinanceOut> {
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/finance`);
if (!res.ok) throw new ApiError(await readError(res, `获取财务数据失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockFinanceOut;
}
/** 个股分红送股tushare dividend 全历史;空 records = 确认无分红) */
export async function getStockDividends(tsCode: string): Promise<StockDividendOut> {
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/dividends`);
if (!res.ok) throw new ApiError(await readError(res, `获取分红数据失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockDividendOut;
}
/** 个股参考数据tushare 参考数据版块按 kind 懒加载;空 records = 确认无数据) */
export async function getStockReference(tsCode: string, kind: string): Promise<StockReferenceOut> {
const res = await apiFetch(`/api/stocks/${encodeURIComponent(tsCode)}/reference/${encodeURIComponent(kind)}`);
if (!res.ok) throw new ApiError(await readError(res, `获取参考数据失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockReferenceOut;
}
// ---------- 大盘总览(主页) ----------
export async function getMarketOverview(): Promise<MarketOverview> {
const res = await apiFetch('/api/market/overview');
if (!res.ok) throw new ApiError(await readError(res, '获取大盘行情失败'), res.status);
return (await res.json()) as MarketOverview;
}
// ---------- 打板专题(主页) ----------
export async function getLimitBoard(): Promise<LimitBoard> {
const res = await apiFetch('/api/market/limit-board');
if (!res.ok) throw new ApiError(await readError(res, '获取打板数据失败'), res.status);
return (await res.json()) as LimitBoard;
}
// ---------- 概念板块THS ----------
export async function getThsBoards(opts: { signal?: AbortSignal } = {}): Promise<ThsBoardList> {
const res = await apiFetch('/api/market/boards', { signal: opts.signal });
if (!res.ok) throw new ApiError(await readError(res, '获取板块列表失败'), res.status);
return (await res.json()) as ThsBoardList;
}
export async function getThsBoardMembers(code: string, opts: { signal?: AbortSignal } = {}): Promise<ThsBoardMembers> {
const res = await apiFetch(`/api/market/boards/${encodeURIComponent(code)}/members`, { signal: opts.signal });
if (!res.ok) throw new ApiError(await readError(res, '获取板块成分失败'), res.status);
return (await res.json()) as ThsBoardMembers;
}
/** 上证指数全量 K 线(日线基底聚合到目标周期;收盘口径) */
export async function getIndexCandles(timeframe: Timeframe): Promise<Candle[]> {
const res = await apiFetch(`/api/market/index-candles?timeframe=${encodeURIComponent(timeframe)}`);
if (!res.ok) throw new ApiError(await readError(res, `获取指数K线失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as Candle[];
}
// ---------- 指数专题(国际指数卡片 + 指数详情) ----------
export async function getGlobalIndexes(): Promise<GlobalIndexList> {
const res = await apiFetch('/api/market/global-indexes');
if (!res.ok) throw new ApiError(await readError(res, '获取国际指数失败'), res.status);
return (await res.json()) as GlobalIndexList;
}
export async function getIndexDetail(code: string): Promise<IndexDetail> {
const res = await apiFetch(`/api/market/indexes/${encodeURIComponent(code)}`);
if (!res.ok) throw new ApiError(await readError(res, '获取指数详情失败'), res.status);
return (await res.json()) as IndexDetail;
}
/** 白名单指数全量 K 线(国内 index_daily / 国际 index_global日线基底聚合 */
export async function getAnyIndexCandles(code: string, timeframe: Timeframe, opts: { signal?: AbortSignal } = {}): Promise<Candle[]> {
const res = await apiFetch(
`/api/market/indexes/${encodeURIComponent(code)}/candles?timeframe=${encodeURIComponent(timeframe)}`,
{ signal: opts.signal },
);
if (!res.ok) throw new ApiError(await readError(res, `获取指数K线失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as Candle[];
}
/** 指数成分股权重(仅国内指数;国际指数后端 404 */
export async function getIndexWeights(code: string, limit = 50): Promise<IndexWeights> {
const res = await apiFetch(
`/api/market/indexes/${encodeURIComponent(code)}/weights?limit=${limit}`,
);
if (!res.ok) throw new ApiError(await readError(res, '获取成分权重失败'), res.status);
return (await res.json()) as IndexWeights;
}
export async function getStocks(params: {
search?: string;
market?: string;
industry?: string;
area?: string;
watched_only?: boolean;
sort?: string;
order?: 'asc' | 'desc';
limit?: number;
offset?: number;
signal?: AbortSignal;
}): Promise<StockListResponse> {
const q = new URLSearchParams();
if (params.search) q.set('search', params.search);
if (params.market) q.set('market', params.market);
if (params.industry) q.set('industry', params.industry);
if (params.area) q.set('area', params.area);
if (params.watched_only) q.set('watched_only', 'true');
if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order);
q.set('limit', String(params.limit ?? 100));
q.set('offset', String(params.offset ?? 0));
const res = await apiFetch(`/api/stocks?${q.toString()}`, { signal: params.signal });
if (!res.ok) throw new ApiError(await readError(res, `获取股票列表失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockListResponse;
}
export async function getStockFacets(): Promise<StockFacets> {
const res = await apiFetch('/api/stocks/facets');
if (!res.ok) throw new ApiError(await readError(res, `获取筛选项失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockFacets;
}
// ---------- ETF 列表(全市场浏览) ----------
export async function getEtfs(params: {
search?: string;
exchange?: string;
watched_only?: boolean;
sort?: string;
order?: 'asc' | 'desc';
limit?: number;
offset?: number;
signal?: AbortSignal;
}): Promise<EtfListResponse> {
const q = new URLSearchParams();
if (params.search) q.set('search', params.search);
if (params.exchange) q.set('exchange', params.exchange);
if (params.watched_only) q.set('watched_only', 'true');
if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order);
q.set('limit', String(params.limit ?? 100));
q.set('offset', String(params.offset ?? 0));
const res = await apiFetch(`/api/etfs?${q.toString()}`, { signal: params.signal });
if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 列表失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EtfListResponse;
}
export async function startEtfSync(full = false): Promise<EtfSyncStatus> {
const res = await apiFetch('/api/etf/sync', { method: 'POST', body: JSON.stringify({ full }) });
if (!res.ok) throw new ApiError(await readError(res, `启动 ETF 同步失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EtfSyncStatus;
}
export async function getEtfSyncStatus(): Promise<EtfSyncStatus> {
const res = await apiFetch('/api/etf/sync/status');
if (!res.ok) throw new ApiError(await readError(res, `获取 ETF 同步状态失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EtfSyncStatus;
}
// ---------- 用户偏好 / 自选股 / 提问历史 ----------
export async function getPreferences(): Promise<Record<string, unknown>> {
const res = await apiFetch('/api/preferences');
if (!res.ok) throw new ApiError(await readError(res, `获取偏好失败 (HTTP ${res.status})`), res.status);
const data = (await res.json()) as { prefs: Record<string, unknown> };
return data.prefs ?? {};
}
export async function putPreferences(prefs: Record<string, unknown>): Promise<Record<string, unknown>> {
const res = await apiFetch('/api/preferences', { method: 'PUT', body: JSON.stringify({ prefs }) });
if (!res.ok) throw new ApiError(await readError(res, `保存偏好失败 (HTTP ${res.status})`), res.status);
const data = (await res.json()) as { prefs: Record<string, unknown> };
return data.prefs ?? {};
}
export async function getWatchlist(): Promise<string[]> {
const res = await apiFetch('/api/watchlist');
if (!res.ok) throw new ApiError(await readError(res, `获取自选股失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function addWatchlist(tsCode: string): Promise<string[]> {
const res = await apiFetch('/api/watchlist', { method: 'POST', body: JSON.stringify({ ts_code: tsCode }) });
if (!res.ok) throw new ApiError(await readError(res, `加自选失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function removeWatchlist(tsCode: string): Promise<string[]> {
const res = await apiFetch(`/api/watchlist/${encodeURIComponent(tsCode)}`, { method: 'DELETE' });
if (!res.ok) throw new ApiError(await readError(res, `移除自选失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function getScreenerQueries(limit = 20): Promise<ScreenerQueryItem[]> {
const res = await apiFetch(`/api/screener/queries?limit=${limit}`);
if (!res.ok) throw new ApiError(await readError(res, `获取提问历史失败 (HTTP ${res.status})`), res.status);
const data = (await res.json()) as { items: ScreenerQueryItem[] };
return data.items ?? [];
}
export async function deleteScreenerQuery(id: number): Promise<void> {
const res = await apiFetch(`/api/screener/queries/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 401) throw new ApiError(`删除失败 (HTTP ${res.status})`, res.status);
}
// ---------- 交割单(个人实盘买卖点) ----------
export async function getTrades(tsCode?: string): Promise<UserTrade[]> {
const q = tsCode ? `?ts_code=${encodeURIComponent(tsCode)}` : '';
const res = await apiFetch(`/api/trades${q}`);
if (!res.ok) throw new ApiError(await readError(res, `获取成交记录失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as UserTrade[];
}
/** 上传交割单文件CSV/Excel/HTML 均可,后端自动识别列名与编码)。 */
export async function importTrades(file: File): Promise<TradesImportResponse> {
const form = new FormData();
form.append('file', file);
// 注意:不能手工设 Content-TypeFormData 需自带 multipart 边界
const res = await apiFetch('/api/trades/import', { method: 'POST', body: form });
if (!res.ok) throw new ApiError(await readError(res, `导入失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as TradesImportResponse;
}
export async function clearTrades(): Promise<TradesClearResponse> {
const res = await apiFetch('/api/trades', { method: 'DELETE' });
if (!res.ok) throw new ApiError(await readError(res, `清空成交失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as TradesClearResponse;
}