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 { 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); } /** 自然语言事件回测。全市场扫描较慢,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(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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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> { 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; }