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 { 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 { const res = await fetch(`${BASE}${path}`, { ...init, credentials: 'include', headers: { // 仅 JSON(字符串 body)手工设 Content-Type;FormData 必须留给浏览器生成 // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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> { 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 }; return data.prefs ?? {}; } export async function putPreferences(prefs: Record): Promise> { 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 }; return data.prefs ?? {}; } export async function getWatchlist(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { const form = new FormData(); form.append('file', file); // 注意:不能手工设 Content-Type,FormData 需自带 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 { 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; }