Files
stock/frontend/src/lib/api.ts
T
2026-07-01 22:07:55 +08:00

1528 lines
48 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 客户端 — 全项目统一入口
//
// Dev:Vite 代理 /api 到 :3018
// Prod:同源(FastAPI 托管前端 dist)
import { toast } from '@/components/Toast'
const BASE = ''
function getAccessToken(): string | null {
try { return localStorage.getItem('access_token') } catch { return null }
}
function clearAccessToken(): void {
try { localStorage.removeItem('access_token') } catch { /* ignore */ }
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const isFormData = init?.body instanceof FormData
const headers: Record<string, string> = {}
if (!isFormData) headers['Content-Type'] = 'application/json'
const token = getAccessToken()
if (token) headers['X-Access-Token'] = token
// 合并调用方传入的 headers(如显式覆盖 Content-Type)
if (init?.headers) {
const incoming = init.headers as Record<string, string>
for (const [k, v] of Object.entries(incoming)) {
if (v != null) headers[k] = v
}
}
const res = await fetch(`${BASE}${path}`, { ...init, headers })
if (!res.ok) {
// 401 时清除令牌并跳转登录页,避免无限弹 toast
if (res.status === 401) {
clearAccessToken()
if (typeof window !== 'undefined' && window.location.pathname !== '/verify') {
window.location.href = '/verify'
}
}
let detail = ''
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
const msg = detail || `${res.status} ${res.statusText}`
toast(msg, 'error')
throw new Error(msg)
}
return res.json() as Promise<T>
}
// ===== Access UUID Gate =====
export interface VerifyAccessUuidResponse {
valid: boolean
role?: 'admin' | 'user'
token?: string
}
export interface AccessAuthStatusResponse {
enabled: boolean
verified: boolean
role?: 'admin' | 'user'
}
export interface UuidRecord {
uuid: string
label: string
enabled: boolean
created_at: number
}
// ===== Capabilities =====
export interface CapabilityLimits {
rpm: number | null
batch: number | null
subscribe: number | null
}
export interface CapabilitiesResponse {
label: string
capabilities: Record<string, CapabilityLimits>
}
// ===== Financials =====
export interface FinancialStatus {
available: boolean
tables: Record<string, { rows: number; symbols: number }>
last_sync: Record<string, string>
/** 服务端是否正在同步(手动触发)——驱动"同步中"UI 并防重复点击 */
syncing?: boolean
}
export interface FinancialMetricRecord {
symbol?: string
period_end: string
announce_date?: string | null
eps_basic?: number | null
eps_diluted?: number | null
bps?: number | null
ocfps?: number | null
roe?: number | null
roe_diluted?: number | null
roa?: number | null
gross_margin?: number | null
net_margin?: number | null
debt_to_asset_ratio?: number | null
revenue_yoy?: number | null
net_income_yoy?: number | null
operating_cash_to_revenue?: number | null
inventory_turnover?: number | null
[key: string]: any
}
export interface FinancialIncomeRecord {
symbol?: string
period_end: string
announce_date?: string | null
revenue?: number | null
operating_cost?: number | null
operating_profit?: number | null
total_profit?: number | null
net_income?: number | null
net_income_attributable?: number | null
basic_eps?: number | null
diluted_eps?: number | null
[key: string]: any
}
export interface FinancialBalanceSheetRecord {
symbol?: string
period_end: string
announce_date?: string | null
total_assets?: number | null
total_current_assets?: number | null
cash_and_equivalents?: number | null
total_liabilities?: number | null
total_equity?: number | null
equity_attributable?: number | null
[key: string]: any
}
export interface FinancialCashFlowRecord {
symbol?: string
period_end: string
announce_date?: string | null
net_operating_cash_flow?: number | null
net_investing_cash_flow?: number | null
net_financing_cash_flow?: number | null
capex?: number | null
net_cash_change?: number | null
[key: string]: any
}
// ===== Kline =====
export interface MinuteKlineRow {
datetime: string
open: number
high: number
low: number
close: number
volume: number
amount: number
}
export interface KlineRow {
symbol?: string
date: string
open: number
high: number
low: number
close: number
volume?: number
change_pct?: number
ma5?: number | null
ma20?: number | null
ma60?: number | null
macd_dif?: number | null
macd_dea?: number | null
macd_hist?: number | null
rsi_14?: number | null
vol_ratio_5d?: number | null
[key: string]: any
}
// ===== Watchlist =====
export interface WatchlistEntry {
symbol: string
added_at: string
note?: string
}
export interface Quote {
symbol: string
price?: number
pct?: number
close?: number
change_pct?: number
[key: string]: any
}
export interface IndexInstrument {
symbol: string
name?: string | null
code?: string | null
asset_type?: 'index'
[key: string]: any
}
export interface IndexQuote {
symbol: string
name?: string | null
last_price?: number | null
close?: number | null
prev_close?: number | null
change_pct?: number | null
change_amount?: number | null
open?: number | null
high?: number | null
low?: number | null
volume?: number | null
amount?: number | null
timestamp?: number | null
[key: string]: any
}
// ===== Screener =====
export interface ScreenerStrategy {
id: string
name: string
description: string
source?: string
}
export interface ScreenerResult {
as_of: string
strategy: string | null
rows: any[]
total: number
elapsed_ms: number
}
export interface MarketSnapshotRow {
symbol: string
name?: string | null
close?: number | null
change_pct?: number | null
amount?: number | null
volume?: number | null
turnover_rate?: number | null
vol_ratio_5d?: number | null
total_shares?: number | null
float_shares?: number | null
market_cap?: number | null
float_market_cap?: number | null
consecutive_limit_ups?: number | null
[key: string]: any
}
export interface OverviewDimensionRankItem {
name: string
count: number
avg_pct: number
up_count: number
down_count: number
amount: number
leader?: {
symbol?: string | null
name?: string | null
change_pct?: number | null
} | null
}
export interface OverviewMarket {
as_of: string | null
quote_status: {
enabled?: boolean
running?: boolean
quote_age_ms?: number | null
is_trading_hours?: boolean
[key: string]: any
}
indices: IndexQuote[]
breadth: {
total: number
up: number
down: number
flat: number
up_pct: number
down_pct: number
avg_pct?: number | null
median_pct?: number | null
strong_up?: number
strong_down?: number
}
amount: { total: number; avg: number }
boards: { board: string; count: number; up: number; down: number; up_pct: number; amount: number }[]
limit: { limit_up: number; broken: number; failed: number; limit_down: number; max_boards: number; seal_rate?: number; tiers: { boards: number; count: number }[]; sealed_ready?: boolean; fake_up?: number; fake_down?: number }
distribution: { label: string; count: number; pct: number }[]
trend: { above_ma5: number; above_ma20: number; above_ma60: number; above_ma5_pct: number; above_ma20_pct: number; above_ma60_pct: number; new_high: number; new_low: number }
activity: { avg_turnover: number; high_turnover: number; high_vol_ratio: number; vol_ratio: number }
radar: { key: string; label: string; value: number }[]
emotion: { score: number; label: string }
top_gainers: MarketSnapshotRow[]
top_losers: MarketSnapshotRow[]
turnover_leaders: MarketSnapshotRow[]
active_leaders: MarketSnapshotRow[]
concept_rank: { leading: OverviewDimensionRankItem[]; lagging: OverviewDimensionRankItem[] }
industry_rank: { leading: OverviewDimensionRankItem[]; lagging: OverviewDimensionRankItem[] }
}
// ===== Strategy Engine =====
export interface StrategyParamDef {
id: string
label: string
type: 'float' | 'int' | 'select' | 'bool'
default: number | string | boolean
min?: number
max?: number
step?: number
options?: string[]
}
export interface StrategyDetail {
id: string
name: string
description: string
tags: string[]
source: 'builtin' | 'custom' | 'ai'
version: string
basic_filter: Record<string, any>
params: StrategyParamDef[]
params_defaults: Record<string, any>
scoring: Record<string, number>
entry_signals: string[]
exit_signals: string[]
stop_loss: number | null
trailing_stop: number | null
trailing_take_profit_activate: number | null
trailing_take_profit_drawdown: number | null
max_hold_days: number | null
display_limit?: number
alerts: { field: string; op?: string; value?: number; message: string }[]
order_by: string
descending: boolean
limit: number
}
// ===== Custom Signals (自定义信号) =====
export interface CustomSignalCondition {
left: string // 字段名
op: string // > >= < <= == !=
right: string // "field:xxx" 或数字字符串
}
export interface CustomSignal {
id: string
name: string
kind: 'entry' | 'exit' | 'both'
conditions: CustomSignalCondition[]
enabled: boolean
}
export interface CustomSignalOptions {
fields: { key: string; label: string }[]
operators: string[]
kinds: { key: string; label: string }[]
}
// ===== Monitor (监控规则 + 触发记录) =====
export interface MonitorCondition {
field: string
op: string // truth | > >= < <= == !=
value?: number | null // op 非 truth 时必填
}
export interface MonitorRule {
id: string
name: string
enabled: boolean
type: 'strategy' | 'signal' | 'price' | 'market'
scope: 'symbols' | 'all' | 'sector'
symbols: string[]
sector?: string | null
strategy_id?: string | null
direction: 'entry' | 'exit' | 'both'
conditions: MonitorCondition[]
logic: 'and' | 'or'
cooldown_seconds: number
severity: 'info' | 'warn' | 'critical'
message: string
webhook_url?: string
webhook_enabled?: boolean
created_at?: string
}
export interface MonitorRuleOptions {
threshold_fields: { key: string; label: string }[]
builtin_signals: { key: string; label: string }[]
custom_signals: { key: string; label: string }[]
operators: string[]
types: { key: string; label: string }[]
scopes: { key: string; label: string }[]
logics: { key: string; label: string }[]
severities: { key: string; label: string }[]
directions: { key: string; label: string }[]
}
export interface AlertEvent {
ts: number
rule_id?: string
rule_name?: string
source: string
type: string
symbol?: string
name?: string | null
message: string
price?: number | null
change_pct?: number | null
signals?: string[]
severity?: string
strategy_id?: string
}
/** 生成监控规则 id (时间戳 + 随机后缀), 用户无需手动填写。 */
export function genRuleId(): string {
const ts = Date.now().toString(36)
const rand = Math.random().toString(36).slice(2, 6)
return `mr_${ts}_${rand}`
}
// ===== Limit Ladder =====
export interface LimitLadderStock {
symbol: string
name?: string | null
close?: number | null
change_pct?: number | null
consecutive_limit_ups?: number | null
consecutive_limit_downs?: number | null
status?: 'limit_up' | 'broken' | 'failed' | 'limit_down' | 'recovery' | null
/** 五档 sealed: real=真封板, fake=假涨停(已归炸板), pending=待确认, null=降级/无能力 */
sealed_status?: 'real' | 'fake' | 'pending' | null
/** 封单量(买一/卖一量), 仅真封板有值 */
sealed_vol?: number | null
}
export interface LimitLadderTier {
boards: number
count: number
stocks: LimitLadderStock[]
}
export interface LimitLadderResult {
as_of: string
tiers: LimitLadderTier[]
/** 双方向涨跌停计数(修正后, 不论当前 direction) */
counts?: { up: number; down: number }
/** 双方向涨跌停原始计数(修正前, 供弹窗对比) */
counts_raw?: { up: number; down: number }
/** sealed 数据是否就绪(false→前端显示降级标识) */
sealed_ready?: boolean
/** sealed 数据 age(秒), null=盘后定版或无数据 */
sealed_age?: number | null
/** sealed 修正统计: real=真封板, fake=假涨停(归炸板), pending=待确认 */
sealed_counts?: { real: number; fake: number; pending: number }
/** 涨停侧 sealed 明细 */
sealed_counts_up?: { real: number; fake: number; pending: number }
/** 跌停侧 sealed 明细 */
sealed_counts_down?: { real: number; fake: number; pending: number }
}
// ===== Backtest =====
export interface BacktestResult {
run_id: string
config: any
stats: Record<string, any>
equity_curve: { date: string; value: number }[]
trades: any[]
per_symbol_stats: { symbol: string; total_return: number }[]
}
// ===== Factor Backtest =====
export interface FactorColumn {
id: string
label: string
group: string
desc: string
}
export interface GroupStat {
group: number
label: string
total_return: number
annual_return: number
max_drawdown: number
sharpe: number
win_rate: number
}
export interface FactorBacktestResult {
run_id: string
config: Record<string, any>
ic_mean: number | null
ic_std: number | null
ir: number | null
ic_win_rate: number | null
ic_series: { date: string; ic: number }[]
group_stats: GroupStat[]
group_nav: Record<string, any>[]
long_short_stats: Record<string, any>
long_short_nav: { date: string; value: number }[]
elapsed_ms: number
n_symbols: number
n_dates: number
error: string | null
}
// ===== Strategy Backtest =====
export interface StrategyBacktestTrade {
symbol: string
name?: string
entry_date: string
exit_date: string
entry_price: number
exit_price: number
pnl_pct: number
duration: number
exit_reason: string
shares?: number
lots?: number
position_pct?: number
entry_value?: number
exit_value?: number
pnl_amount?: number
entry_score?: number | null
entry_signal_date?: string | null
exit_signal_date?: string | null
blocked_exit_days?: number
}
export interface StrategyBacktestResult {
run_id: string
config: Record<string, any>
stats: Record<string, any>
equity_curve: { date: string; value: number; cash?: number; positions?: number; exposure?: number }[]
drawdown_curve: { date: string; value: number }[]
benchmark_curve?: { date: string; value: number; close?: number; name?: string; symbol?: string }[]
trades: StrategyBacktestTrade[]
per_symbol_stats: {
symbol: string
n_trades: number
total_return: number
win_rate: number
best: number
worst: number
}[]
strategy_info: {
id: string
name: string
description: string
entry_signals: string[]
exit_signals: string[]
stop_loss: number | null
trailing_stop: number | null
trailing_take_profit_activate: number | null
trailing_take_profit_drawdown: number | null
score_min: number | null
score_max: number | null
max_hold_days: number | null
source: string
}
elapsed_ms: number
error: string | null
}
// ===== Settings =====
/** 端点发现清单 —— 对应数据源官网 /endpoints.json */
export interface EndpointItem {
id: string
url: string
label: string
region?: string
description?: string
premium?: boolean
}
export interface EndpointManifest {
version?: number
description?: string
healthPath?: string
/** 每端点测试轮数,用于 /health 多轮探测取中位数 */
testRounds?: number
endpoints: EndpointItem[]
/** 数据来源:remote=远程拉取 / fallback=内置回退列表 */
source?: 'remote' | 'fallback'
}
export interface SettingsState {
mode: 'none' | 'free' | 'api_key'
tickflow_api_key_masked: string
has_tickflow_key: boolean
tier_label: string
current_endpoint: string
probe_log: string[]
missing_caps: string[]
extras_caps: string[]
// 首次使用引导
onboarding_completed: boolean
// AI 配置
ai_provider: string
ai_base_url: string
ai_api_key_masked: string
has_ai_key: boolean
ai_model: string
ai_daily_token_budget: number
}
/** 保存数据源 Key 的响应(先探后存) */
export interface SaveTickflowKeyResult {
ok: boolean
/** ok=false 且 key 无效时的原因标识,前端据此提示「Key 无效」 */
reason?: 'invalid'
error?: string
mode?: 'none' | 'free' | 'api_key'
tier_label?: string
current_endpoint?: string
tickflow_api_key_masked?: string
capabilities_count?: number
}
export interface Preferences {
realtime_quotes_enabled: boolean
indices_nav_pinned: boolean
minute_sync_enabled: boolean
minute_sync_days: number
pipeline_schedule: { hour: number; minute: number }
instruments_schedule: { hour: number; minute: number }
enriched_batch_size: number
index_daily_batch_size: number
limit_ladder_monitor_enabled: boolean
depth_polling_interval: number
depth_finalize_time: { hour: number; minute: number }
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
system_notify_enabled: boolean
sidebar_index_symbols: string[]
nav_order: string[]
nav_hidden: string[]
screener_auto_run: boolean
}
// ===== Strategy Alert =====
export interface StrategyAlertEvent {
source: 'strategy' | 'depth'
type: string
strategy_id?: string
symbol?: string
name?: string | null
message: string
price?: number | null
change_pct?: number | null
signals?: string[]
}
// ===== API surface =====
export const api = {
health: () => request<{ status: string; version: string; mode: string }>('/health'),
verifyAccessUuid: (credential: string) =>
request<VerifyAccessUuidResponse>('/api/auth/verify', {
method: 'POST',
body: JSON.stringify({ credential }),
}),
accessAuthStatus: () => request<AccessAuthStatusResponse>('/api/auth/status'),
// ===== Admin UUID management =====
listUuids: () => request<UuidRecord[]>('/api/admin/uuids'),
createUuid: (label = '') =>
request<UuidRecord>('/api/admin/uuids', {
method: 'POST',
body: JSON.stringify({ label }),
}),
deleteUuid: (uuid: string) =>
request<{ ok: boolean }>(`/api/admin/uuids/${encodeURIComponent(uuid)}`, { method: 'DELETE' }),
toggleUuid: (uuid: string, enabled: boolean) =>
request<{ ok: boolean }>(
`/api/admin/uuids/${encodeURIComponent(uuid)}/toggle?enabled=${enabled}`,
{ method: 'PUT' },
),
settings: () => request<SettingsState>('/api/settings'),
saveTickflowKey: (api_key: string) =>
request<SaveTickflowKeyResult>('/api/settings/tickflow-key', {
method: 'POST',
body: JSON.stringify({ api_key }),
}),
clearTickflowKey: () =>
request<any>('/api/settings/tickflow-key', { method: 'DELETE' }),
/** 标记首次使用向导完成(持久化到后端 preferences */
completeOnboarding: () =>
request<{ ok: boolean; onboarding_completed: boolean }>(
'/api/settings/onboarding/complete', { method: 'POST' },
),
/** 保存 AI 配置 */
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; daily_token_budget?: number }) =>
request<{ ok: boolean }>('/api/settings/ai', {
method: 'POST',
body: JSON.stringify(ai),
}),
preferences: () => request<Preferences>('/api/settings/preferences'),
updateMinuteSync: (enabled: boolean, days: number) =>
request<Preferences>('/api/settings/preferences/minute-sync', {
method: 'PUT',
body: JSON.stringify({ minute_sync_enabled: enabled, minute_sync_days: days }),
}),
updateRealtimeQuotes: (enabled: boolean) =>
request<{ realtime_quotes_enabled: boolean }>('/api/settings/preferences/realtime-quotes', {
method: 'PUT',
body: JSON.stringify({ realtime_quotes_enabled: enabled }),
}),
updateIndicesNavPinned: (pinned: boolean) =>
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
method: 'PUT',
body: JSON.stringify({ indices_nav_pinned: pinned }),
}),
quoteStatus: () =>
request<{
enabled: boolean
running: boolean
interval_s: number
symbol_count: number
index_symbol_count?: number
quote_age_ms: number | null
is_trading_hours: boolean
last_fetch_ms: number | null
}>('/api/intraday/status'),
quoteInterval: () =>
request<{ interval: number; min_interval: number; max_interval: number }>(
'/api/settings/preferences/quote-interval',
),
updateQuoteInterval: (interval: number) =>
request<{ interval: number; min_interval: number; max_interval: number }>(
'/api/settings/preferences/quote-interval',
{ method: 'PUT', body: JSON.stringify({ interval }) },
),
intradayRefresh: () => request<{ status: string }>('/api/intraday/refresh', { method: 'POST' }),
indexQuotes: (symbols?: string[]) =>
request<{ rows: IndexQuote[]; count: number }>(
`/api/intraday/indices${symbols?.length ? `?symbols=${encodeURIComponent(symbols.join(','))}` : ''}`,
),
updateRealtimeMonitorConfig: (cfg: {
sse_refresh_pages?: Record<string, boolean>
strategy_monitor_enabled?: boolean
strategy_monitor_ids?: string[]
sidebar_index_symbols?: string[]
screener_auto_run?: boolean
}) =>
request<{
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
sidebar_index_symbols: string[]
screener_auto_run: boolean
}>('/api/settings/preferences/realtime-monitor', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updateSystemNotify: (enabled: boolean) =>
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
updatePipelineSchedule: (hour: number, minute: number) =>
request<{ hour: number; minute: number }>('/api/settings/preferences/pipeline-schedule', {
method: 'PUT',
body: JSON.stringify({ hour, minute }),
}),
updateDepthPollingInterval: (interval: number) =>
request<{ depth_polling_interval: number }>('/api/settings/preferences/depth-polling-interval', {
method: 'PUT',
body: JSON.stringify({ interval }),
}),
updateLimitLadderMonitor: (enabled: boolean) =>
request<{ limit_ladder_monitor_enabled: boolean }>('/api/settings/preferences/limit-ladder-monitor', {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
runLimitLadderFix: () =>
request<{ ok: boolean; count: number; msg: string }>('/api/settings/preferences/limit-ladder-monitor/run', {
method: 'POST',
}),
updateDepthFinalizeTime: (hour: number, minute: number) =>
request<{ hour: number; minute: number }>('/api/settings/preferences/depth-finalize-time', {
method: 'PUT',
body: JSON.stringify({ hour, minute }),
}),
saveNavOrder: (nav_order: string[]) =>
request<{ nav_order: string[] }>('/api/settings/preferences/nav-order', {
method: 'PUT',
body: JSON.stringify({ nav_order }),
}),
saveNavHidden: (nav_hidden: string[]) =>
request<{ nav_hidden: string[] }>('/api/settings/preferences/nav-hidden', {
method: 'PUT',
body: JSON.stringify({ nav_hidden }),
}),
updateInstrumentsSchedule: (hour: number, minute: number) =>
request<{ hour: number; minute: number }>('/api/settings/preferences/instruments-schedule', {
method: 'PUT',
body: JSON.stringify({ hour, minute }),
}),
updateEnrichedBatchSize: (size: number) =>
request<{ enriched_batch_size: number }>('/api/settings/preferences/enriched-batch-size', {
method: 'PUT',
body: JSON.stringify({ size }),
}),
updateIndexDailyBatchSize: (size: number) =>
request<{ index_daily_batch_size: number }>('/api/settings/preferences/index-daily-batch-size', {
method: 'PUT',
body: JSON.stringify({ size }),
}),
// 自选列表列配置
watchlistColumns: () =>
request<{ columns: any[] | null }>('/api/settings/preferences/watchlist-columns'),
updateWatchlistColumns: (columns: any[]) =>
request<{ columns: any[] }>('/api/settings/preferences/watchlist-columns', {
method: 'PUT',
body: JSON.stringify({ columns }),
}),
// 策略结果列表列配置
screenerResultColumns: () =>
request<{ columns: any[] | null }>('/api/settings/preferences/screener-result-columns'),
updateScreenerResultColumns: (columns: any[]) =>
request<{ columns: any[] }>('/api/settings/preferences/screener-result-columns', {
method: 'PUT',
body: JSON.stringify({ columns }),
}),
capabilities: () => request<CapabilitiesResponse>('/api/capabilities'),
version: () => request<{ version: string }>('/api/data/version'),
redetectCapabilities: () =>
request<CapabilitiesResponse>('/api/capabilities/redetect', { method: 'POST' }),
klineDaily: (symbol: string, days = 120, dateRange?: { start: string; end: string }, extColumns?: string) =>
request<{
symbol: string
name?: string
stock_info?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record<string, unknown> }
rows: KlineRow[]
source?: string
}>(
(dateRange
? `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&start_date=${dateRange.start}&end_date=${dateRange.end}`
: `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&days=${days}`)
+ (extColumns ? `&ext_columns=${encodeURIComponent(extColumns)}` : ''),
),
klineDailyBatch: (symbols: string[], days = 12) =>
request<{ data: Record<string, KlineRow[]> }>('/api/kline/daily-batch', {
method: 'POST',
body: JSON.stringify({ symbols, days }),
}),
instrumentSearch: (q: string, limit = 20) =>
request<{ results: { symbol: string; name: string; code: string }[] }>(
`/api/kline/instruments/search?q=${encodeURIComponent(q)}&limit=${limit}`,
),
/** 批量查股票名称 (传入 symbol 列表, 返回 {symbol: name}) */
instrumentNames: (symbols: string[]) =>
request<{ names: Record<string, string> }>('/api/kline/instruments/names', {
method: 'POST',
body: JSON.stringify(symbols),
}),
klineMinute: (symbol: string, date?: string) =>
request<{
symbol: string
name?: string
stock_info?: { name?: string; total_shares?: number; float_shares?: number }
date: string | null
rows: MinuteKlineRow[]
source?: 'local' | 'live' | 'none'
}>(
`/api/kline/minute?symbol=${encodeURIComponent(symbol)}${date ? `&date=${date}` : ''}`,
),
indexList: () => request<{ results: IndexInstrument[]; count: number }>('/api/index/list'),
indexSearch: (q: string, limit = 20) =>
request<{ results: IndexInstrument[] }>(
`/api/index/search?q=${encodeURIComponent(q)}&limit=${limit}`,
),
indexDaily: (symbol: string, days = 120, dateRange?: { start: string; end: string }) =>
request<{
symbol: string
name?: string
index_info?: IndexInstrument
rows: KlineRow[]
source?: string
}>(
dateRange
? `/api/index/daily?symbol=${encodeURIComponent(symbol)}&start_date=${dateRange.start}&end_date=${dateRange.end}`
: `/api/index/daily?symbol=${encodeURIComponent(symbol)}&days=${days}`,
),
indexMinute: (symbol: string, date?: string) =>
request<{
symbol: string
name?: string
index_info?: IndexInstrument
date: string | null
rows: MinuteKlineRow[]
source?: string
}>(
`/api/index/minute?symbol=${encodeURIComponent(symbol)}${date ? `&date=${date}` : ''}`,
),
syncIndexInstruments: () =>
request<{ status: string; count: number }>('/api/index/sync_instruments', { method: 'POST' }),
syncIndexDaily: (days = 365) =>
request<{ status: string; index_count: number; rows_written: number }>(
`/api/index/sync_daily?days=${days}`,
{ method: 'POST' },
),
syncSymbol: (symbol: string, days = 250) =>
request<{ symbol: string; rows_written: number }>(
`/api/kline/sync?symbol=${encodeURIComponent(symbol)}&days=${days}`,
{ method: 'POST' },
),
syncMinute: () =>
request<{ status: string; job_id: string }>('/api/kline/sync_minute', { method: 'POST' }),
extendHistory: (value: number, unit: 'day' | 'month' | 'year') =>
request<{ status: string; job_id: string }>('/api/kline/extend_history', {
method: 'POST',
body: JSON.stringify({ value, unit }),
}),
extendMinuteHistory: (value: number, unit: 'day' | 'month') =>
request<{ status: string; job_id: string }>('/api/kline/extend_minute_history', {
method: 'POST',
body: JSON.stringify({ value, unit }),
}),
rebuildEnriched: () =>
request<{ status: string; job_id: string }>('/api/kline/rebuild_enriched', {
method: 'POST',
}),
watchlistList: () => request<{ symbols: WatchlistEntry[] }>('/api/watchlist'),
watchlistAdd: (symbol: string, note = '') =>
request<{ symbols: WatchlistEntry[] }>('/api/watchlist', {
method: 'POST',
body: JSON.stringify({ symbol, note }),
}),
watchlistBatchAdd: (symbols: string[], note = '') =>
request<{ symbols: WatchlistEntry[]; added: number }>('/api/watchlist/batch', {
method: 'POST',
body: JSON.stringify({ symbols, note }),
}),
watchlistRemove: (symbol: string) =>
request<{ symbols: WatchlistEntry[] }>(
`/api/watchlist/${encodeURIComponent(symbol)}`,
{ method: 'DELETE' },
),
watchlistClear: () =>
request<{ removed: number }>('/api/watchlist', { method: 'DELETE' }),
watchlistQuotes: () => request<{ quotes: Quote[] }>('/api/watchlist/quotes'),
watchlistEnriched: (extColumns?: string) =>
request<{ rows: any[]; as_of: string | null; elapsed_ms: number }>(
extColumns
? `/api/watchlist/enriched?ext_columns=${encodeURIComponent(extColumns)}`
: '/api/watchlist/enriched',
),
screenerStrategies: () => request<{ presets: ScreenerStrategy[] }>('/api/screener/strategies'),
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) =>
request<ScreenerResult>('/api/screener/run_preset', {
method: 'POST',
body: JSON.stringify({ strategy_id, pool, as_of: asOf ?? null, ext_columns: extColumns || null }),
}),
screenerRunCustom: (conditions: string[], orderBy?: string, limit = 30, pool?: string[], extColumns?: string) =>
request<ScreenerResult>('/api/screener/run', {
method: 'POST',
body: JSON.stringify({ conditions, order_by: orderBy, limit, pool, ext_columns: extColumns || null }),
}),
screenerRunAll: (asOf?: string, strategyIds?: string[], extColumns?: string) =>
request<{ as_of: string | null; results: Record<string, { total: number; as_of: string; rows: any[] }> }>(
'/api/screener/run_all', { method: 'POST', body: JSON.stringify({ as_of: asOf ?? null, strategy_ids: strategyIds ?? null, ext_columns: extColumns || null }) },
),
screenerCached: (extColumns?: string) =>
request<{ as_of: string | null; results: Record<string, { total: number; as_of: string; rows: any[] }>; today_ever_matched: Record<string, string[]> | null; today_ever_rows: Record<string, Record<string, any>> | null; updated_at: number | null }>(
extColumns
? `/api/screener/cached?ext_columns=${encodeURIComponent(extColumns)}`
: '/api/screener/cached',
),
marketSnapshot: () =>
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>('/api/screener/market-snapshot'),
overviewMarket: (asOf?: string) => request<OverviewMarket>(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`),
limitLadder: (asOf?: string, extColumns?: string, direction?: 'up' | 'down') => {
const params = new URLSearchParams()
if (asOf) params.set('as_of', asOf)
if (extColumns) params.set('ext_columns', extColumns)
if (direction === 'down') params.set('direction', 'down')
const qs = params.toString()
return request<LimitLadderResult>(
`/api/screener/limit-ladder${qs ? `?${qs}` : ''}`,
)
},
backtestStatus: () => request<{ available: boolean }>('/api/backtest/status'),
backtestRun: (payload: {
symbols: string[]
entries: string[]
exits: string[]
start?: string
end?: string
stop_loss_pct?: number
max_hold_days?: number
matching?: 'close_t' | 'open_t+1'
}) =>
request<BacktestResult>('/api/backtest/run', {
method: 'POST',
body: JSON.stringify(payload),
}),
factorColumns: () =>
request<{ columns: FactorColumn[] }>('/api/backtest/factor/columns'),
factorRun: (payload: {
factor_name: string
symbols?: string[] | null
start?: string | null
end?: string | null
n_groups?: number
rebalance?: 'daily' | 'weekly' | 'monthly'
weight?: 'equal' | 'factor_weight'
fees_pct?: number
slippage_bps?: number
}) =>
request<FactorBacktestResult>('/api/backtest/factor/run', {
method: 'POST',
body: JSON.stringify(payload),
}),
strategyBacktestRun: (payload: {
strategy_id: string
symbols?: string[] | null
start?: string | null
end?: string | null
params?: Record<string, any> | null
overrides?: Record<string, any> | null
matching?: 'close_t' | 'open_t+1'
entry_fill?: 'close_t' | 'open_t+1' | null
exit_fill?: 'close_t' | 'open_t+1' | null
fees_pct?: number
slippage_bps?: number
max_positions?: number
initial_capital?: number
position_sizing?: 'equal' | 'score_weight'
}) =>
request<StrategyBacktestResult>('/api/backtest/strategy/run', {
method: 'POST',
body: JSON.stringify(payload),
}),
pipelineRun: () => request<{ job_id: string; reused: boolean }>(
'/api/pipeline/run', { method: 'POST' },
),
pipelineJob: (id: string) => request<PipelineJob>(`/api/pipeline/jobs/${id}`),
pipelineJobs: (limit = 20) =>
request<{ active_id: string | null; jobs: PipelineJobSummary[] }>(
`/api/pipeline/jobs?limit=${limit}`,
),
dataStatus: () => request<DataStatus>('/api/data/status'),
dataClear: () => request<{ deleted_files: number }>('/api/data/clear', { method: 'POST' }),
enrichedSchema: (table: string) => request<EnrichedField[]>(`/api/data/schema/${table}`),
testEndpoint: (url: string, rounds?: number) =>
request<{
ok: boolean
url: string
rounds: number
success: number
median_ms: number | null
min_ms?: number | null
max_ms?: number | null
/** 兼容旧字段,等于 median_ms */
latency_ms?: number | null
error?: string
}>(
'/api/settings/test_endpoint', {
method: 'POST',
body: JSON.stringify({ url, rounds }),
},
),
// 端点发现 —— 后端代理拉取数据源官网 /endpoints.json(前端无法跨域直连)
listEndpoints: () =>
request<EndpointManifest>('/api/settings/endpoints'),
switchEndpoint: (url: string) =>
request<{ ok: boolean; current_endpoint: string; error?: string }>(
'/api/settings/switch_endpoint', {
method: 'POST',
body: JSON.stringify({ url }),
},
),
// ===== 扩展数据 =====
extDataList: () =>
request<{ items: ExtDataConfig[] }>('/api/ext-data'),
extDataRows: (id: string, opts?: { date?: string; limit?: number; columns?: string[] }) => {
const qs = new URLSearchParams()
if (opts?.date) qs.set('date', opts.date)
if (opts?.limit) qs.set('limit', String(opts.limit))
if (opts?.columns?.length) qs.set('columns', opts.columns.join(','))
const suffix = qs.toString()
return request<ExtDataRowsResult>(`/api/ext-data/${encodeURIComponent(id)}/rows${suffix ? `?${suffix}` : ''}`)
},
analysisMenus: () =>
request<{ items: AnalysisMenu[] }>('/api/analysis-menus'),
analysisMenu: (id: string) =>
request<AnalysisMenu>(`/api/analysis-menus/${encodeURIComponent(id)}`),
analysisMenuSave: (id: string, body: Omit<AnalysisMenu, 'id' | 'created_at' | 'updated_at' | 'builtin'>) =>
request<AnalysisMenu>(`/api/analysis-menus/${encodeURIComponent(id)}`, {
method: 'POST',
body: JSON.stringify(body),
}),
analysisMenuReorder: (ids: string[]) =>
request<{ items: AnalysisMenu[] }>('/api/analysis-menus/reorder', {
method: 'POST',
body: JSON.stringify({ ids }),
}),
analysisMenuDelete: (id: string) =>
request<{ status: string }>(`/api/analysis-menus/${encodeURIComponent(id)}`, { method: 'DELETE' }),
extDataCreate: (body: { id: string; label: string; mode: 'snapshot' | 'timeseries'; fields: { name: string; dtype: string; label: string }[]; description?: string; symbol_map?: Record<string, string>; code_map?: Record<string, string> }) =>
request<ExtDataConfig>('/api/ext-data', {
method: 'POST',
body: JSON.stringify(body),
}),
extDataUpdate: (id: string, body: { label?: string; fields?: { name: string; dtype: string; label: string }[]; description?: string }) =>
request<ExtDataConfig>(`/api/ext-data/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
extDataDelete: (id: string) =>
request<{ status: string }>(`/api/ext-data/${id}`, { method: 'DELETE' }),
extDataUpload: (id: string, file: File, snapshotDate?: string) => {
const fd = new FormData()
fd.append('file', file)
return request<{ status: string; rows: number; date: string }>(
`/api/ext-data/${id}/upload${snapshotDate ? `?snapshot_date=${snapshotDate}` : ''}`,
{ method: 'POST', body: fd },
)
},
extDataIngest: (id: string, body: { date?: string; rows: Record<string, unknown>[] }) =>
request<{ status: string; rows: number; date: string }>(
`/api/ext-data/${id}/ingest`,
{ method: 'POST', body: JSON.stringify(body) },
),
extDataSchemaAll: () =>
request<{ items: { id: string; label: string; mode: string; columns: { name: string; type: string; label: string }[] }[] }>('/api/ext-data/schema-all'),
extDataPullConfig: (id: string, body: {
url: string; method?: string; headers?: Record<string, string>; body?: string;
response_path?: string; field_map?: Record<string, string>;
schedule_minutes?: number; enabled?: boolean;
}) =>
request<{ status: string; pull: PullConfig }>(
`/api/ext-data/${id}/pull`,
{ method: 'PUT', body: JSON.stringify(body) },
),
extDataPullTest: (id: string) =>
request<{ status: string; total_rows: number; preview: Record<string, unknown>[]; has_symbol: boolean }>(
`/api/ext-data/${id}/pull/test`,
{ method: 'POST' },
),
extDataPullRun: (id: string) =>
request<{ status: string; rows: number; date: string }>(
`/api/ext-data/${id}/pull/run`,
{ method: 'POST' },
),
extDataDetectFields: (file: File) => {
const fd = new FormData()
fd.append('file', file)
return request<{ fields: { name: string; dtype: string; label: string }[]; rows: number; symbol_candidates: string[]; code_candidates: string[] }>(
'/api/ext-data/detect-fields',
{ method: 'POST', body: fd },
)
},
extDataFixSymbol: (id: string) =>
request<{ status: string; fixed_files: number }>(
`/api/ext-data/${id}/fix-symbol`,
{ method: 'POST' },
),
// ===== Financials =====
financialStatus: () =>
request<FinancialStatus>('/api/financials/status'),
financialMetrics: (symbol?: string) =>
request<{ data: FinancialMetricRecord[] }>(
`/api/financials/metrics${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ''}`,
),
financialIncome: (symbol?: string) =>
request<{ data: FinancialIncomeRecord[] }>(
`/api/financials/income${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ''}`,
),
financialBalanceSheet: (symbol?: string) =>
request<{ data: FinancialBalanceSheetRecord[] }>(
`/api/financials/balance-sheet${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ''}`,
),
financialCashFlow: (symbol?: string) =>
request<{ data: FinancialCashFlowRecord[] }>(
`/api/financials/cash-flow${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ''}`,
),
financialSync: (table: string) =>
request<{ status: string; synced: Record<string, number> }>(
`/api/financials/sync/${table}`, { method: 'POST' },
),
// ===== Strategy Engine =====
strategyList: () =>
request<{ strategies: StrategyDetail[] }>('/api/strategies'),
strategyGet: (id: string) =>
request<StrategyDetail>(`/api/strategies/${id}`),
strategyRun: (strategyId: string, params?: Record<string, any>, asOf?: string, pool?: string[]) =>
request<ScreenerResult>('/api/strategies/run', {
method: 'POST',
body: JSON.stringify({ strategy_id: strategyId, params, as_of: asOf ?? null, pool }),
}),
strategyRunAll: (asOf?: string) =>
request<{ as_of: string | null; results: Record<string, { total: number; as_of: string }> }>(
'/api/strategies/run-all',
{ method: 'POST', body: JSON.stringify({ as_of: asOf ?? null }) },
),
strategySaveConfig: (strategyId: string, overrides: Record<string, any>) =>
request<{ ok: boolean }>('/api/strategies/config', {
method: 'POST',
body: JSON.stringify({ strategy_id: strategyId, overrides }),
}),
strategyResetConfig: (strategyId: string) =>
request<{ ok: boolean }>(`/api/strategies/config/${strategyId}`, { method: 'DELETE' }),
/** 删除自定义策略(内置策略不可删除) */
strategyDelete: (strategyId: string) =>
request<{ ok: boolean }>(`/api/strategies/${strategyId}`, { method: 'DELETE' }),
strategyReload: () =>
request<{ ok: boolean; count: number }>('/api/strategies/reload', { method: 'POST' }),
// ===== Custom Signals (自定义信号) =====
customSignalsList: () =>
request<{ signals: CustomSignal[] }>('/api/custom-signals'),
customSignalsOptions: () =>
request<CustomSignalOptions>('/api/custom-signals/options'),
customSignalSave: (signal: CustomSignal) =>
request<{ ok: boolean; signal: CustomSignal }>('/api/custom-signals', {
method: 'POST',
body: JSON.stringify(signal),
}),
customSignalDelete: (id: string) =>
request<{ ok: boolean }>(`/api/custom-signals/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// ===== Monitor Rules (监控规则) =====
monitorRulesList: () =>
request<{ rules: MonitorRule[] }>('/api/monitor-rules'),
monitorRuleOptions: () =>
request<MonitorRuleOptions>('/api/monitor-rules/options'),
monitorRuleSave: (rule: MonitorRule) =>
request<{ ok: boolean; rule: MonitorRule }>('/api/monitor-rules', {
method: 'POST',
body: JSON.stringify(rule),
}),
monitorRuleDelete: (id: string) =>
request<{ ok: boolean }>(`/api/monitor-rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
/** 生成演示监控规则 (Dev 页用) */
monitorRuleSeed: () =>
request<{ ok: boolean; generated: number }>('/api/monitor-rules/seed', { method: 'POST' }),
// ===== Alerts (触发记录) =====
alertsList: (params?: { days?: number; limit?: number; source?: string; type?: string }) => {
const qs = new URLSearchParams()
if (params?.days) qs.set('days', String(params.days))
if (params?.limit) qs.set('limit', String(params.limit))
if (params?.source) qs.set('source', params.source)
if (params?.type) qs.set('type', params.type)
const s = qs.toString()
return request<{ alerts: AlertEvent[]; total: number }>(`/api/alerts${s ? `?${s}` : ''}`)
},
alertsClear: () =>
request<{ ok: boolean; cleared: number }>('/api/alerts', { method: 'DELETE' }),
alertDelete: (ts: number) =>
request<{ ok: boolean }>(`/api/alerts/${ts}`, { method: 'DELETE' }),
/** 生成演示触发记录 (Dev 页用) */
alertSeed: (count = 12, recent = true) =>
request<{ ok: boolean; generated: number }>(`/api/alerts/seed?count=${count}&recent=${recent}`, { method: 'POST' }),
/** 检查 AI 配置状态 */
strategyAiStatus: () =>
request<{ configured: boolean; has_key: boolean; has_model: boolean }>('/api/strategies/ai/status'),
/** 测试 AI 连通性 */
strategyAiTest: () =>
request<{ ok: boolean; error?: string; model?: string; usage?: { prompt: number; completion: number } }>(
'/api/strategies/ai/test',
{ method: 'POST' },
),
/** 获取策略源文件内容 */
strategyGetSource: (id: string) =>
request<{ code: string; source: string }>(`/api/strategies/${id}/source`),
strategyBuild: (step: number, payload: Record<string, any>) =>
request<{ code: string; meta: Record<string, any>; valid: boolean; error: string | null }>(
'/api/strategies/build',
{ method: 'POST', body: JSON.stringify({ step, ...payload }) },
),
/** 保存 AI 生成的策略文件 */
strategySaveCode: (strategyId: string, code: string) =>
request<{ ok: boolean; path: string }>('/api/strategies/ai/save', {
method: 'POST',
body: JSON.stringify({ strategy_id: strategyId, code }),
}),
}
// ===== Pipeline =====
export interface PipelineJob {
id: string
status: 'pending' | 'running' | 'succeeded' | 'failed'
stage: string
progress: number // 0-100 整体进度
stage_pct: number // 0-100 当前阶段内进度
log: { ts: string; stage: string; msg: string }[]
started_at: string | null
finished_at: string | null
duration_s: number | null
result: {
universe_size: number
daily_days: number
adj_factor_symbols: number
enriched_days: number
index_count?: number
index_daily_rows?: number
minute_rows: number
skipped_stages?: string[]
} | null
error: string | null
}
export type PipelineJobSummary = Omit<PipelineJob, 'log'>
// ===== Data status =====
interface TableStats {
rows: number
earliest_date: string | null
latest_date: string | null
symbols_covered: number
trading_days: number
}
interface InstrumentsStats {
rows: number
symbols_covered: number
latest_as_of: string | null
named: number
}
export interface DataStatus {
daily: TableStats | null
enriched: TableStats | null
index_daily: TableStats | null
index_enriched: TableStats | null
index_instruments: InstrumentsStats | null
minute: TableStats | null
adj_factor: TableStats | null
instruments: InstrumentsStats | null
financials: { rows: number; tables: Record<string, { rows: number; symbols: number }> } | null
storage: {
daily_files: number
daily_size_mb: number
enriched_files: number
enriched_size_mb: number
index_daily_files?: number
index_daily_size_mb?: number
index_enriched_files?: number
index_enriched_size_mb?: number
index_instruments_files?: number
index_instruments_size_mb?: number
minute_files: number
minute_size_mb: number
adj_factor_files: number
adj_factor_size_mb: number
instruments_files: number
instruments_size_mb: number
financials_files?: number
financials_size_mb?: number
ext_data_files?: number
ext_data_size_mb?: number
total_size_mb: number
}
next_pipeline_run: string | null
next_instruments_run: string | null
last_pipeline_run: string | null
last_instruments_run: string | null
checked_at: string
}
export interface EnrichedField {
name: string
type: string
desc: string
}
// ===== 扩展数据 =====
export interface ExtDataField {
name: string
dtype: string
label: string
}
export interface PullConfig {
url: string
method: string
headers?: Record<string, string>
body?: string | null
response_path: string
field_map?: Record<string, string>
schedule_minutes: number
enabled: boolean
last_run?: string | null
last_status?: string | null
last_message?: string | null
last_rows?: number | null
}
export interface ExtDataConfig {
id: string
label: string
mode: 'snapshot' | 'timeseries'
fields: ExtDataField[]
description?: string
symbol_map?: Record<string, string>
code_map?: Record<string, string>
created_at: string
updated_at: string
latest_sync_date?: string | null
date_range?: string[] | null
pull?: PullConfig | null
}
export interface ExtDataRowsResult {
id: string
label: string
mode: 'snapshot' | 'timeseries'
date: string | null
total: number
limit: number
fields: ExtDataField[]
rows: Record<string, any>[]
}
export interface AnalysisColumn {
field: string
label?: string
type?: 'string' | 'number' | 'percent' | 'amount' | 'date'
width?: number | null
sortable?: boolean
precision?: number | null
format?: string | null
aggregate?: 'count' | 'avg' | 'sum' | 'min' | 'max' | null
visible?: boolean
}
export interface AnalysisMenu {
id: string
label: string
icon: string
data_source: string
template: 'dimension_rank' | 'ranking' | 'table'
dimension_field?: string | null
rank_field?: string | null
group_columns: AnalysisColumn[]
detail_columns: AnalysisColumn[]
default_sort?: { field: string; order: 'asc' | 'desc' } | null
visible: boolean
order: number
created_at?: string | null
updated_at?: string | null
builtin?: boolean
}