422 lines
18 KiB
TypeScript
422 lines
18 KiB
TypeScript
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-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<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-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<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;
|
||
}
|