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

317 lines
13 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,
BacktestRequest,
BacktestResponse,
ChipsResponse,
CurrentUser,
EventBacktestRequest,
EventBacktestResponse,
LoginRequest,
LoginResponse,
MarketOverview,
PreviewResponse,
ScreenerQueryItem,
ScreenerRunRequest,
ScreenerStreamEvent,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
StockFacets,
StockListResponse,
SyncRequest,
SyncResponse,
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);
}
export async function postBacktest(req: BacktestRequest): Promise<BacktestResponse> {
const res = await apiFetch('/api/backtest', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`, res.status);
return (await res.json()) as BacktestResponse;
}
export async function syncData(req: SyncRequest): Promise<SyncResponse> {
const res = await apiFetch('/api/data/sync', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`, res.status);
return (await res.json()) as SyncResponse;
}
/** 自然语言事件回测。全市场扫描较慢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(`获取同步状态失败 (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;
} = {},
): Promise<PreviewResponse> {
const { limit = 500, adjust = 'qfq', timeframe = '1d', mas, end } = 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()}`);
if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as PreviewResponse;
}
// ---------- 筹码分布(个股筹码峰) ----------
/** 获取某参考日的筹码截面date 缺省取最新;周/月 K 线由调用方换算成周期末日传入,
* 后端吸附到 <=date 的最近有数据交易日。价格与成本均已按 adjust 口径换算。 */
export async function getStockChips(
tsCode: string,
opts: { date?: string; adjust?: AdjustMode } = {},
): Promise<ChipsResponse> {
const q = new URLSearchParams();
if (opts.date) q.set('date', opts.date);
if (opts.adjust) q.set('adjust', opts.adjust);
const qs = q.toString();
const res = await apiFetch(`/api/stock/${encodeURIComponent(tsCode)}/chips${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new ApiError(await readError(res, `获取筹码分布失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as ChipsResponse;
}
// ---------- 大盘总览(主页) ----------
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 getStocks(params: {
search?: string;
market?: string;
industry?: string;
area?: string;
watched_only?: boolean;
sort?: string;
order?: 'asc' | 'desc';
limit?: number;
offset?: number;
}): 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()}`);
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;
}
// ---------- 用户偏好 / 自选股 / 提问历史 ----------
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;
}