317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
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-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);
|
||
}
|
||
|
||
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-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;
|
||
}
|