Files
easy_tdx_max/web-ui/src/api.ts
T

837 lines
31 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.
// 后端 API 封装。统一 fetch + 错误处理,返回类型化结果。
// 开发期通过 vite proxy 走 /api(同源),生产期由 FastAPI 同源托管。
import type {
ApiError,
BacktestRequest,
BacktestResult,
Bar,
BoardOverviewResp,
BoardRow,
Category,
CoreLeaderRow,
CcpmProductsResponse,
CcpmRankResponse,
DataFrameResponse,
LlmChatResponse,
LlmChatContext,
LlmHistoryResponse,
LlmConfigResponse,
LlmConfigUpdate,
LlmTestResult,
MarketSessionInfo,
MarketStat,
MinutePoint,
MultiStrategyBacktestRequest,
OptimizeAllBacktestRequest,
OptimizeBacktestRequest,
PortfolioBacktestRequest,
RankRow,
SavedStrategy,
SavedStrategyCreate,
SavedStrategyListResponse,
SecurityQuote,
ServerHostInfo,
ServerHostListResponse,
ServerSwitchResult,
SignalScanRequest,
SignalScanResult,
StrategiesResponse,
TaskListResponse,
TaskState,
TaskSubmitResponse,
WatchlistResponse,
} from './types'
const BASE = '/api/v1'
/** 把未知错误格式化为用户可读的消息(网络错误给友好提示)。 */
export function formatError(e: unknown): string {
if (e instanceof TypeError && e.message.includes('fetch')) {
return '网络错误:无法连接后端服务,请确认 easy-tdx serve 已启动'
}
return e instanceof Error ? e.message : String(e)
}
/** 把 Response 解析为 ApiError 抛出(后端统一错误格式 {error, detail})。 */
async function throwError(resp: Response): Promise<never> {
let detail = `${resp.status} ${resp.statusText}`
try {
const body = (await resp.json()) as ApiError
if (body?.detail) detail = body.detail
} catch {
// 非 JSON 错误体,用 statusText
}
throw new Error(detail)
}
/** 枚举预置策略 + 参数 schema。 */
export async function fetchStrategies(): Promise<StrategiesResponse> {
const resp = await fetch(`${BASE}/backtest/strategies`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as StrategiesResponse
}
/**
* 按标的取 K 线行情(OHLCV)。
*
* 后端 /bars 单次最多 800 根。当 startDate 到 endDate 跨度超过 800 根时,
* 自动分页拉取(start=0, 800, 1600...)拼接,直到覆盖 startDate 或达上限。
* 可选 startDate/endDate 对结果做闭区间过滤(ISO 日期字符串,如 "2024-01-01")。
*/
const MAX_PAGES = 10 // 翻页上限:10 × 800 = 8000 根(约 32 年日线)
export async function fetchBars(
market: string,
code: string,
category: Category,
startDate?: string,
endDate?: string,
): Promise<Bar[]> {
let allBars: Bar[] = []
for (let page = 0; page < MAX_PAGES; page++) {
const params = new URLSearchParams({
market,
code,
category,
count: '800',
start: String(page * 800),
})
const resp = await fetch(`${BASE}/bars?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as { data: Record<string, unknown>[] }
const pageBars = body.data.map((row) => normalizeBar(row))
if (pageBars.length === 0) break // 无更多数据
allBars = allBars.concat(pageBars)
// 若已覆盖到 startDate(本页最早一根 ≤ startDate),停止翻页
if (startDate && pageBars.length > 0) {
const oldest = pageBars[pageBars.length - 1].datetime.slice(0, 10)
if (oldest <= startDate) break
}
// 不足 800 根说明已到数据起点
if (pageBars.length < 800) break
}
// 按日期范围过滤(闭区间)
let bars = allBars
if (startDate) bars = bars.filter((b) => b.datetime.slice(0, 10) >= startDate)
if (endDate) bars = bars.filter((b) => b.datetime.slice(0, 10) <= endDate)
// 翻页拼接后按时间正序排序:每页内部是正序,但页间是逆序
// page1=最新段,page2=更旧段),concat 后需排序保证整体正序,
// 否则引擎/图表只正确处理第一页的数据。
bars.sort((a, b) => a.datetime.localeCompare(b.datetime))
return bars
}
/** 把后端 bars 的单条记录归一化为统一 Bardatetime 字段)。 */
function normalizeBar(row: Record<string, unknown>): Bar {
const raw = (row.datetime ?? row.date) as string | undefined
if (!raw) throw new Error('行情数据缺少 datetime/date 字段')
return {
datetime: raw.slice(0, 19).replace(' ', 'T'),
open: Number(row.open),
high: Number(row.high),
low: Number(row.low),
close: Number(row.close),
vol: Number(row.vol),
amount: Number(row.amount),
}
}
/** 同步回测(内联 OHLCV,快速)。 */
export async function runBacktest(req: BacktestRequest): Promise<BacktestResult> {
const resp = await fetch(`${BASE}/backtest/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as BacktestResult
}
/** 提交后台回测任务,返回 task_id。 */
export async function submitBacktestTask(req: BacktestRequest): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交 Walk-Forward 样本外验证后台任务(v1.27n_windows 默认 7)。 */
export async function submitWalkforwardTask(
req: BacktestRequest,
nWindows = 7,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/wf/run/async?n_windows=${nWindows}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交一条龙评估后台任务(回测+WF+适配性+评分+基准对比,v1.27)。 */
export async function submitEvaluateTask(
req: BacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/evaluate/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交组合回测后台任务,返回 task_id。 */
export async function submitPortfolioTask(
req: PortfolioBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/portfolio/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交组合级 Walk-Forward 样本外验证后台任务(n_windows 默认 7)。 */
export async function submitPortfolioWalkforwardTask(
req: PortfolioBacktestRequest,
nWindows = 7,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/portfolio/wf/run/async?n_windows=${nWindows}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交组合级一条龙评估后台任务(组合回测+WF+适配性+评分+基准对比)。 */
export async function submitPortfolioEvaluateTask(
req: PortfolioBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/portfolio/evaluate/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交多策略组合回测后台任务(资金分仓),返回 task_id。 */
export async function submitMultiStrategyTask(
req: MultiStrategyBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/multi-strategy/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交多策略组合级 Walk-Forward 样本外验证后台任务(n_windows 默认 7)。 */
export async function submitMultiStrategyWalkforwardTask(
req: MultiStrategyBacktestRequest,
nWindows = 7,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/multi-strategy/wf/run/async?n_windows=${nWindows}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交多策略组合级一条龙评估后台任务(组合回测+WF+适配性+评分+基准对比)。 */
export async function submitMultiStrategyEvaluateTask(
req: MultiStrategyBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/multi-strategy/evaluate/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交参数网格寻优后台任务,返回 task_id。 */
export async function submitOptimizeTask(
req: OptimizeBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/optimize/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交「一键寻优所有策略」后台任务,返回 task_id。 */
export async function submitOptimizeAllTask(
req: OptimizeAllBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/optimize-all/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 查询后台任务状态(轮询用)。 */
export async function fetchTask(taskId: string): Promise<TaskState> {
const resp = await fetch(`${BASE}/backtest/tasks/${taskId}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskState
}
/** 列出最近任务摘要(供对比页选择)。 */
export async function fetchTaskList(limit = 20): Promise<TaskListResponse> {
const resp = await fetch(`${BASE}/backtest/tasks?limit=${limit}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskListResponse
}
/**
* 提交后台任务并轮询直到 done/failed。
* @param req 回测请求
* @param onPoll 每次轮询回调(可选,用于更新 UI 进度)
* @param intervalMs 轮询间隔(默认 300ms
* @param timeoutMs 总超时(默认 120s
*/
export async function runBacktestWithPolling(
req: BacktestRequest,
onPoll?: (state: TaskState) => void,
intervalMs = 300,
timeoutMs = 120_000,
): Promise<TaskState> {
const { task_id } = await submitBacktestTask(req)
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchTask(task_id)
onPoll?.(state)
if (state.status === 'done' || state.status === 'failed') return state
if (Date.now() - start > timeoutMs) {
throw new Error(`回测任务超时(${timeoutMs / 1000}s`)
}
await new Promise((r) => setTimeout(r, intervalMs))
}
}
// ── 策略库(已保存策略)──────────────────────────────────────────────────────
/** 提交「信号雷达」一键扫描后台任务,返回 task_id。 */
export async function submitSignalScanTask(
req: SignalScanRequest = {},
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/signal-scan/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/**
* 提交信号扫描并轮询直到 done/failed。
*
* 与 runBacktestWithPolling 的区别:扫描要在请求内逐标的取行情(提交本身
* 就可能耗时数十秒),且标的较多时总时长可能超过 2 分钟,故默认 300s 超时。
*/
export async function runSignalScanWithPolling(
req: SignalScanRequest = {},
onPoll?: (state: TaskState) => void,
intervalMs = 500,
timeoutMs = 300_000,
): Promise<TaskState> {
const { task_id } = await submitSignalScanTask(req)
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchTask(task_id)
onPoll?.(state)
if (state.status === 'done' || state.status === 'failed') return state
if (Date.now() - start > timeoutMs) {
throw new Error(`信号扫描超时(${timeoutMs / 1000}s),可稍后重试或减小窗口`)
}
await new Promise((r) => setTimeout(r, intervalMs))
}
}
/** 断言任务结果为信号扫描结果(类型收窄用)。 */
export function asSignalScanResult(state: TaskState): SignalScanResult {
if (state.status === 'failed') throw new Error(state.error || '信号扫描失败')
const result = state.result as SignalScanResult | null
if (!result || !Array.isArray(result.rows)) {
throw new Error('信号扫描结果格式异常(缺少 rows)')
}
return result
}
/** 列出全部已保存策略(按创建时间倒序)。 */
export async function fetchSavedStrategies(): Promise<SavedStrategyListResponse> {
const resp = await fetch(`${BASE}/strategies`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as SavedStrategyListResponse
}
/** 查看单条已保存策略。 */
export async function fetchSavedStrategy(id: string): Promise<SavedStrategy> {
const resp = await fetch(`${BASE}/strategies/${id}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as SavedStrategy
}
/** 保存一条策略(含当时的标的上下文与成绩快照)。 */
export async function saveStrategy(req: SavedStrategyCreate): Promise<SavedStrategy> {
const resp = await fetch(`${BASE}/strategies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as SavedStrategy
}
/** 删除一条已保存策略。 */
export async function deleteSavedStrategy(id: string): Promise<void> {
const resp = await fetch(`${BASE}/strategies/${id}`, { method: 'DELETE' })
if (!resp.ok) await throwError(resp)
}
// ── 服务器设置 ──────────────────────────────────────────────────────────────
/** 列出所有候选通达信服务器 + 当前使用的 host(不含延迟,需点测速)。 */
export async function fetchServerHosts(): Promise<ServerHostListResponse> {
const resp = await fetch(`${BASE}/server/hosts`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as ServerHostListResponse
}
/** 并发测速全部(或指定)host,返回延迟和可达性。 */
export async function testServerHosts(hosts?: string[]): Promise<ServerHostInfo[]> {
const resp = await fetch(`${BASE}/server/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hosts: hosts ?? null }),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as ServerHostInfo[]
}
/** 切换到指定 host(热重连,无需重启服务)。 */
export async function switchServerHost(host: string): Promise<ServerSwitchResult> {
const resp = await fetch(`${BASE}/server/switch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host }),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as ServerSwitchResult
}
// ── 行情终端 ────────────────────────────────────────────────────────────────
/** 批量拉实时五档(REST 一次性;持续刷新走 SSE,见 stores/quotes.ts)。
* 注意:后端路由是 POST /quotesmarket router 挂在 /api/v1 前缀下)。 */
export async function fetchQuotes(symbols: Array<{ market: string; code: string }>): Promise<SecurityQuote[]> {
const resp = await fetch(`${BASE}/quotes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stocks: symbols }),
})
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data.map(normalizeQuote)
}
/** 后端 quotes 行 → SecurityQuote(补 symbol 键;数值列容错 null)。 */
function normalizeQuote(row: Record<string, unknown>): SecurityQuote {
const market = String(row.market ?? '')
const code = String(row.code ?? '')
const num = (v: unknown): number | null => {
const n = Number(v)
return v == null || Number.isNaN(n) ? null : n
}
const q = { ...row } as Record<string, unknown>
q.symbol = `${market}${code}`
for (const k of Object.keys(q)) {
if (k === 'symbol' || k === 'market' || k === 'code' || k === 'server_time') continue
q[k] = num(q[k])
}
return q as unknown as SecurityQuote
}
/** 全市场涨跌统计(涨/跌/平/涨停/跌停家数 + 总成交)。 */
export async function fetchMarketStat(): Promise<MarketStat> {
const resp = await fetch(`${BASE}/market/stat`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
const row = body.data[0] ?? {}
const num = (v: unknown): number => Number(v ?? 0)
return {
up_count: num(row.up_count),
down_count: num(row.down_count),
neutral_count: num(row.neutral_count),
suspended_count: num(row.suspended_count),
total_count: num(row.total_count),
total_amount: num(row.total_amount),
total_volume: num(row.total_volume),
total_market_cap: num(row.total_market_cap),
limit_up_count: num(row.limit_up_count),
limit_down_count: num(row.limit_down_count),
}
}
/** 今日分时(240 点:价格 + 每分钟量)。 */
export async function fetchMinute(market: string, code: string): Promise<MinutePoint[]> {
const params = new URLSearchParams({ market, code })
const resp = await fetch(`${BASE}/minute?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data.map((row) => ({
datetime: String(row.datetime ?? ''),
price: Number(row.price ?? 0),
vol: Number(row.vol ?? 0),
}))
}
/** 历史某日分时(date: YYYYMMDD 整数,如 20260829)。 */
export async function fetchHistoryMinute(
market: string,
code: string,
date: number,
): Promise<MinutePoint[]> {
const params = new URLSearchParams({ market, code, date: String(date) })
const resp = await fetch(`${BASE}/minute/history?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data.map((row) => ({
datetime: String(row.datetime ?? ''),
price: Number(row.price ?? 0),
vol: Number(row.vol ?? 0),
}))
}
/** 指数日 K/bars/index;指数代码在个股接口可能返回空,用它兜底)。 */
export async function fetchIndexBars(
market: string,
code: string,
count = 250,
): Promise<Bar[]> {
const params = new URLSearchParams({ market, code, category: 'DAY', count: String(count) })
const resp = await fetch(`${BASE}/bars/index?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data.map((row) => ({
datetime: String(row.datetime ?? row.date ?? '').slice(0, 19).replace(' ', 'T'),
open: Number(row.open),
high: Number(row.high),
low: Number(row.low),
close: Number(row.close),
vol: Number(row.vol),
amount: Number(row.amount ?? 0),
}))
}
/** 板块列表(MAC 协议;CHANGE_PCT 排序时涨跌幅 = price/pre_close - 1 自行计算)。 */
export async function fetchBoards(boardType = 'HY', count = 60): Promise<BoardRow[]> {
const params = new URLSearchParams({ board_type: boardType, count: String(count) })
const resp = await fetch(`${BASE}/board-mac/list?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data.map((row) => {
const r = { ...row }
const price = Number(r.price ?? 0)
const pre = Number(r.pre_close ?? 0)
r.change_pct = pre > 0 ? (price / pre - 1) * 100 : Number(r.change_pct ?? 0)
return r as BoardRow
})
}
/** 板块总览(服务端一次归并当日涨跌幅 + 涨速 + 多周期涨幅,15s 缓存)。
* 当日涨跌幅后端已按 price/pre_close-1 计算;未请求/缺失的周期指标为 null。 */
export async function fetchBoardOverview(
boardType: string,
metrics = 'SPEED,CHANGE_3D,CHANGE_5D,CHANGE_20D,YTD',
): Promise<BoardOverviewResp> {
const params = new URLSearchParams({ board_type: boardType, metrics })
const resp = await fetch(`${BASE}/board-mac/overview?${params}`)
if (!resp.ok) await throwError(resp)
// 后端 DictResponse 包装为 {data: {...}},这里解包
const body = (await resp.json()) as { data: BoardOverviewResp }
return body.data
}
/** 市场异动流(火箭发射/大笔买入/封涨停板/打开跌停板/快速反弹等)。 */
export async function fetchUnusual(market: 'SH' | 'SZ', count = 50): Promise<Record<string, unknown>[]> {
const params = new URLSearchParams({ market, count: String(count) })
const resp = await fetch(`${BASE}/mac/unusual?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data
}
/** 排行榜(MAC 排行行情)。
* MAC 协议价格列是 close(无 price/change_pct),这里统一归一化为
* price/change_pct,渲染端不再做多候选探测。sortBy 对应后端 SortType。 */
export async function fetchRankList(
sortOrder: 'DESC' | 'ASC',
count = 20,
sortBy = 'CHANGE_PCT',
): Promise<RankRow[]> {
const params = new URLSearchParams({
category: 'A',
sort_type: sortBy,
sort_order: sortOrder,
count: String(count),
})
const resp = await fetch(`${BASE}/mac/quote-list?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data.map((row) => {
const close = Number(row.close ?? row.price ?? 0)
const pre = Number(row.pre_close ?? 0)
const r = { ...row } as Record<string, unknown>
r.price = close
r.change_pct = pre > 0 ? (close / pre - 1) * 100 : 0
r.market = Number(row.market) === 1 ? 'SH' : 'SZ'
return r as RankRow
})
}
/** 查询证券中文名称(MAC 协议个股快照;BJ 市场可能不支持,失败返回空)。 */
export async function fetchSymbolName(market: string, code: string): Promise<string> {
try {
const params = new URLSearchParams({ market, code })
const resp = await fetch(`${BASE}/mac/symbol-info?${params}`)
if (!resp.ok) return ''
const body = (await resp.json()) as DataFrameResponse
return String(body.data[0]?.name ?? '')
} catch {
return ''
}
}
/** 板块成分股(MAC 协议;按涨跌幅排序,列与排行行情同构,做同款归一化)。 */
export async function fetchBoardMembers(
boardSymbol: string,
count = 100,
sortOrder: 'DESC' | 'ASC' = 'DESC',
): Promise<RankRow[]> {
const params = new URLSearchParams({
board_symbol: boardSymbol,
count: String(count),
sort_type: 'CHANGE_PCT',
sort_order: sortOrder,
})
const resp = await fetch(`${BASE}/board-mac/members?${params}`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data.map((row) => {
const close = Number(row.close ?? row.price ?? 0)
const pre = Number(row.pre_close ?? 0)
const r = { ...row } as Record<string, unknown>
r.price = close
r.change_pct = pre > 0 ? (close / pre - 1) * 100 : 0
const m = Number(row.market)
r.market = m === 1 ? 'SH' : m === 2 ? 'BJ' : 'SZ'
return r as RankRow
})
}
// ── 自选 ────────────────────────────────────────────────────────────────────
/** 列出全部自选。 */
export async function fetchWatchlist(): Promise<WatchlistResponse> {
const resp = await fetch(`${BASE}/watchlist`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as WatchlistResponse
}
/** 加入自选(幂等)。 */
export async function addWatchItem(market: string, code: string, name = ''): Promise<void> {
const resp = await fetch(`${BASE}/watchlist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market, code, name }),
})
if (!resp.ok) await throwError(resp)
}
/** 移除自选。 */
export async function removeWatchItem(market: string, code: string): Promise<void> {
const resp = await fetch(`${BASE}/watchlist/${market}/${code}`, { method: 'DELETE' })
if (!resp.ok) await throwError(resp)
}
// ── 交易时段(Dashboard 自动刷新门控) ──────────────────────────────────────
/** 服务器侧交易时段判断(前端本地判断为主,本接口用于校准展示)。 */
export async function fetchMarketSession(): Promise<MarketSessionInfo> {
const resp = await fetch(`${BASE}/market/session`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as MarketSessionInfo
}
// ── LLM 配置与对话(AI 设置页 / AI 解读直连) ───────────────────────────────
/** 当前 LLM 配置(key 脱敏)+ Provider 预设表。 */
export async function fetchLlmConfig(): Promise<LlmConfigResponse> {
const resp = await fetch(`${BASE}/llm/config`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as LlmConfigResponse
}
/** 保存 LLM 配置(写入 ~/.easy_tdx/llm.json,与手工编辑同一份文件)。 */
export async function saveLlmConfig(req: LlmConfigUpdate): Promise<LlmConfigResponse> {
const resp = await fetch(`${BASE}/llm/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
await resp.json() // 丢弃 PUT 响应体,统一以 GET 回读为准(providers/missing 同步刷新)
return fetchLlmConfig()
}
/** 连通性测试(用已保存配置或请求体临时配置发一句 ping)。 */
export async function testLlm(override?: LlmConfigUpdate): Promise<LlmTestResult> {
const resp = await fetch(`${BASE}/llm/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(override ?? null),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as LlmTestResult
}
/** 一轮 LLM 对话(如把 AI 解读 Prompt 直接发给已配置的模型)。 */
export async function chatLlm(
prompt: string,
systemPrompt?: string | null,
): Promise<LlmChatResponse> {
const resp = await fetch(`${BASE}/llm/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, system_prompt: systemPrompt ?? null }),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as LlmChatResponse
}
/** 提交 AI 解读后台任务(长耗时模型调用不占 HTTP 连接),返回 task_id。 */
export async function submitLlmChatTask(
prompt: string,
context?: LlmChatContext | null,
systemPrompt?: string | null,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/llm/chat/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, system_prompt: systemPrompt ?? null, context: context ?? null }),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 查询 AI 解读任务状态(轮询用)。 */
export async function fetchLlmChatTask(taskId: string): Promise<TaskState> {
const resp = await fetch(`${BASE}/llm/chat/tasks/${taskId}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskState
}
/**
* 提交 AI 解读并轮询直到 done/failed。
*
* 大报告解读 1-3 分钟属正常:轮询间隔放宽到 1.5s(回测是 0.3s),
* 前端上限 20 分钟兜底(后端 LLM 读超时最大 600s,正常应先于此前返回)。
*/
export async function runLlmChatWithPolling(
prompt: string,
context?: LlmChatContext | null,
onPoll?: (state: TaskState) => void,
intervalMs = 1_500,
timeoutMs = 20 * 60_000,
): Promise<TaskState> {
const { task_id } = await submitLlmChatTask(prompt, context)
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchLlmChatTask(task_id)
onPoll?.(state)
if (state.status === 'done' || state.status === 'failed') return state
if (Date.now() - start > timeoutMs) {
throw new Error(`AI 解读任务超时(${timeoutMs / 1000}s),任务仍在后台运行,可稍后重试`)
}
await new Promise((r) => setTimeout(r, intervalMs))
}
}
// ── AI 解读历史 ──────────────────────────────────────────────────────────────
/** 列出 AI 解读历史(时间倒序,含 Prompt/正文/策略上下文)。 */
export async function fetchLlmHistory(limit = 50): Promise<LlmHistoryResponse> {
const resp = await fetch(`${BASE}/llm/history?limit=${limit}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as LlmHistoryResponse
}
/** 删除一条历史记录。 */
export async function deleteLlmHistory(id: number): Promise<void> {
const resp = await fetch(`${BASE}/llm/history/${id}`, { method: 'DELETE' })
if (!resp.ok) await throwError(resp)
}
/** 清空全部历史。 */
export async function clearLlmHistory(): Promise<number> {
const resp = await fetch(`${BASE}/llm/history`, { method: 'DELETE' })
if (!resp.ok) await throwError(resp)
return (await resp.json()).deleted as number
}
/** 核心龙头池(159 只)。 */
export async function fetchCoreLeaders(): Promise<CoreLeaderRow[]> {
const resp = await fetch(`${BASE}/market/core-leaders`)
if (!resp.ok) await throwError(resp)
const body = (await resp.json()) as DataFrameResponse
return body.data as unknown as CoreLeaderRow[]
}
/** 中金所成交持仓排名:品种列表(含科普元数据)。 */
export async function fetchCcpmProducts(): Promise<CcpmProductsResponse> {
const resp = await fetch(`${BASE}/ccpm/products`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as CcpmProductsResponse
}
/** 中金所成交持仓排名:按品种 + 交易日抓取(date 缺省自动回溯最近有数据的交易日)。 */
export async function fetchCcpmRank(product: string, date?: string): Promise<CcpmRankResponse> {
const params = new URLSearchParams()
params.set('product', product)
if (date) params.set('date', date)
const resp = await fetch(`${BASE}/ccpm/rank?${params.toString()}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as CcpmRankResponse
}