@@ -1,5 +1,5 @@
|
||||
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
|
||||
import { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns'
|
||||
import type { ColumnConfig } from '@/lib/list-columns'
|
||||
|
||||
interface ColumnCustomizerProps {
|
||||
columns: ColumnConfig[]
|
||||
@@ -12,7 +12,7 @@ export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCus
|
||||
return (
|
||||
<ListColumnCustomizer
|
||||
columns={columns}
|
||||
groups={COLUMN_GROUPS}
|
||||
groups={[]}
|
||||
onChange={onChange}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
} from '@/lib/useSharedQueries'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import {
|
||||
Star,
|
||||
ScanSearch,
|
||||
History,
|
||||
FileText,
|
||||
Settings,
|
||||
Key,
|
||||
@@ -47,9 +45,7 @@ const BRAND = '#8B5CF6'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||
{ to: '/watchlist', label: '自选', icon: Star },
|
||||
{ to: '/screener', label: '策略', icon: ScanSearch },
|
||||
{ to: '/backtest', label: '回测', icon: History },
|
||||
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
||||
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, RefreshCw, Clock } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cnSignal } from '@/lib/signals'
|
||||
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
@@ -44,21 +42,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
queryFn: api.watchlistList,
|
||||
enabled: !!symbol,
|
||||
})
|
||||
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
|
||||
|
||||
const toggleWatchlist = useMutation({
|
||||
mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
||||
},
|
||||
})
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
if (!symbol) return
|
||||
@@ -239,8 +222,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
||||
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
|
||||
dateRange={dateRange}
|
||||
onMonitor={() => setShowMonitorEditor(true)}
|
||||
inWatchlist={inWatchlist}
|
||||
onToggleWatchlist={() => toggleWatchlist.mutate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
|
||||
*/
|
||||
import { useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { Check, Plus, Eye, EyeOff } from 'lucide-react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import type { KlineRow } from '@/lib/api'
|
||||
import { fmtPrice } from '@/lib/format'
|
||||
import type { ColumnConfig } from '@/lib/screener-columns'
|
||||
@@ -22,10 +22,7 @@ interface ScreenerTableProps {
|
||||
strategyIdToName: Record<string, string>
|
||||
symbolStrategyMap: Map<string, string[]>
|
||||
activeStrategy: string | null
|
||||
watchlistSet: Set<string>
|
||||
onPreview: (symbol: string, name: string) => void
|
||||
onToggleWatchlist: (symbol: string, inList: boolean) => void
|
||||
watchlistPending: boolean
|
||||
/** symbol → 日k 数据,仅当启用日k列时传入 */
|
||||
klineData?: Record<string, KlineRow[]>
|
||||
/** 日k蜡烛图是否显示(表头眼睛开关) */
|
||||
@@ -114,7 +111,7 @@ function renderExtValue(
|
||||
|
||||
export function ScreenerTable({
|
||||
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
|
||||
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
|
||||
onPreview, klineData = {},
|
||||
dailyKChartVisible = true, onToggleDailyKChart,
|
||||
sort, onSortToggle,
|
||||
}: ScreenerTableProps) {
|
||||
@@ -164,7 +161,6 @@ export function ScreenerTable({
|
||||
switch (key) {
|
||||
case 'symbol': {
|
||||
const board = boardTag(r.symbol)
|
||||
const inWatchlist = watchlistSet.has(r.symbol)
|
||||
return (
|
||||
<td key={col.id} className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -189,25 +185,10 @@ export function ScreenerTable({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpired ? (
|
||||
{isExpired && (
|
||||
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
|
||||
失效
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleWatchlist(r.symbol, inWatchlist)}
|
||||
disabled={watchlistPending}
|
||||
className={`shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full border transition-colors cursor-pointer
|
||||
disabled:opacity-50
|
||||
${inWatchlist
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-border text-muted hover:border-accent/40 hover:text-accent'
|
||||
}`}
|
||||
title={inWatchlist ? '移出自选' : '加入自选'}
|
||||
>
|
||||
{inWatchlist ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -2,25 +2,36 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
|
||||
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
|
||||
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
||||
import { color } from '@/lib/colors'
|
||||
import { SignalPicker } from './SignalPicker'
|
||||
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
||||
|
||||
// 内置列名 → 中文标签
|
||||
const FIELD_LABEL: Record<string, string> = {}
|
||||
for (const c of BUILTIN_COLUMNS) {
|
||||
if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label
|
||||
}
|
||||
// enriched 列名别名
|
||||
Object.assign(FIELD_LABEL, {
|
||||
// 字段名 → 中文标签
|
||||
const FIELD_LABEL: Record<string, string> = {
|
||||
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
|
||||
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
|
||||
momentum_5d: '5D动量', momentum_10d: '10D动量',
|
||||
momentum_20d: '20D动量', momentum_30d: '30D动量',
|
||||
momentum_60d: '60D动量', turnover_rate: '换手率',
|
||||
change_amount: '涨跌额', amplitude: '振幅',
|
||||
volume: '成交量', amount: '成交额',
|
||||
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
|
||||
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
|
||||
vol_ratio: '量比',
|
||||
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
|
||||
boll_upper: '布林上轨', boll_lower: '布林下轨',
|
||||
})
|
||||
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
|
||||
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
|
||||
atr14: 'ATR14', annual_vol: '年化波动',
|
||||
high_60d: '60日高', low_60d: '60日低',
|
||||
eps: 'EPS', bps: 'BPS', roe: 'ROE', pe_ttm: 'PE(TTM)', pb: 'PB',
|
||||
gross_margin: '毛利率', net_margin: '净利率',
|
||||
revenue_yoy: '营收增速', net_income_yoy: '净利增速',
|
||||
debt_ratio: '负债率',
|
||||
score: '评分',
|
||||
limit_ups: '连板', limit_downs: '连跌',
|
||||
float_val: '流通值', price: '现价', pct: '涨跌幅', turnover: '换手率',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
strategyId: string | null
|
||||
|
||||
@@ -192,23 +192,6 @@ export interface KlineRow {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// ===== Watchlist =====
|
||||
export interface WatchlistEntry {
|
||||
symbol: string
|
||||
added_at: string
|
||||
note?: string
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
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
|
||||
@@ -505,111 +488,6 @@ export interface LimitLadderResult {
|
||||
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
|
||||
take_profit: 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 =====
|
||||
|
||||
/** 端点发现清单 —— 对应 tickflow.org/endpoints.json */
|
||||
@@ -856,15 +734,6 @@ export const api = {
|
||||
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'),
|
||||
@@ -976,37 +845,6 @@ export const api = {
|
||||
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' },
|
||||
),
|
||||
watchlistMoveToTop: (symbol: string) =>
|
||||
request<{ symbols: WatchlistEntry[] }>(
|
||||
`/api/watchlist/${encodeURIComponent(symbol)}/top`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
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', {
|
||||
@@ -1047,63 +885,6 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
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' },
|
||||
),
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { StrategyBacktestResult } from './api'
|
||||
|
||||
/**
|
||||
* 全局回测任务管理 (SSE 模式 + 任务缓存 + 重连支持)。
|
||||
*
|
||||
* 特性:
|
||||
* - 实时进度: EventSource 监听后端 SSE, 推送 day/total/equity
|
||||
* - 可取消: POST /strategy/cancel/{job_key}, 后端 cancel_event
|
||||
* - 切页/刷新保持: 后端按参数 hash 缓存任务, 重连不重启
|
||||
* - 切页: 模块级 store 保持, EventSource 随组件卸载断开, 回来后重连
|
||||
* - 刷新: localStorage 存 job 参数, 刷新后重新连接到同一任务
|
||||
*/
|
||||
|
||||
export interface BacktestProgress {
|
||||
day: number
|
||||
total: number
|
||||
date: string
|
||||
equity: number
|
||||
}
|
||||
|
||||
export interface BacktestTask {
|
||||
id: number
|
||||
isPending: boolean
|
||||
result: StrategyBacktestResult | null
|
||||
progress: BacktestProgress | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
let current: BacktestTask | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
let taskSeq = 0
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
const RECONNECT_KEY = 'backtest_reconnect'
|
||||
|
||||
function emit() {
|
||||
listeners.forEach(fn => fn())
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return current
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 查询字符串构建 */
|
||||
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const sp = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') sp.set(k, String(v))
|
||||
}
|
||||
return sp.toString()
|
||||
}
|
||||
|
||||
/** 连接 SSE (新建或重连都用这个) */
|
||||
function connectSSE(url: string): void {
|
||||
const id = current?.id ?? ++taskSeq
|
||||
|
||||
// 关闭旧连接
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
const es = new EventSource(url)
|
||||
eventSource = es
|
||||
|
||||
es.addEventListener('progress', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
try {
|
||||
const prog = JSON.parse(e.data) as BacktestProgress
|
||||
current = { ...current, progress: prog }
|
||||
emit()
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
es.addEventListener('done', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
try {
|
||||
const result = JSON.parse(e.data) as StrategyBacktestResult
|
||||
current = { ...current, isPending: false, result, error: null }
|
||||
emit()
|
||||
} catch {
|
||||
current = { ...current, isPending: false, error: '结果解析失败' }
|
||||
emit()
|
||||
}
|
||||
es.close()
|
||||
eventSource = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
})
|
||||
|
||||
es.addEventListener('error', (e: MessageEvent) => {
|
||||
if (current?.id !== id) return
|
||||
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
|
||||
if (e.data) {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)?.message ?? '回测出错'
|
||||
current = { ...current, isPending: false, error: msg }
|
||||
emit()
|
||||
} catch {
|
||||
current = { ...current, isPending: false, error: '回测出错' }
|
||||
emit()
|
||||
}
|
||||
es.close()
|
||||
eventSource = null
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
}
|
||||
// 无 data: 连接异常断开, EventSource 会自动重连, 不改变状态
|
||||
})
|
||||
}
|
||||
|
||||
/** 启动一次 SSE 回测任务 */
|
||||
export function startBacktest(params: {
|
||||
strategy_id: string
|
||||
symbols?: string[] | null
|
||||
start?: string | null
|
||||
end?: string | null
|
||||
matching?: string
|
||||
entry_fill?: string
|
||||
exit_fill?: string
|
||||
fees_pct?: number
|
||||
slippage_bps?: number
|
||||
max_positions?: number
|
||||
max_exposure_pct?: number
|
||||
initial_capital?: number
|
||||
position_sizing?: string
|
||||
params?: Record<string, any> | null
|
||||
overrides?: Record<string, any> | null
|
||||
mode?: 'position' | 'full'
|
||||
holding_days?: number
|
||||
}): void {
|
||||
// 取消之前的任务状态
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
const id = ++taskSeq
|
||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
||||
emit()
|
||||
|
||||
const qs = buildQuery({
|
||||
strategy_id: params.strategy_id,
|
||||
symbols: params.symbols?.join(','),
|
||||
start: params.start ?? undefined,
|
||||
end: params.end ?? undefined,
|
||||
matching: params.matching,
|
||||
entry_fill: params.entry_fill,
|
||||
exit_fill: params.exit_fill,
|
||||
fees_pct: params.fees_pct,
|
||||
slippage_bps: params.slippage_bps,
|
||||
max_positions: params.max_positions,
|
||||
max_exposure_pct: params.max_exposure_pct,
|
||||
initial_capital: params.initial_capital,
|
||||
position_sizing: params.position_sizing,
|
||||
params: params.params ? JSON.stringify(params.params) : undefined,
|
||||
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
|
||||
mode: params.mode,
|
||||
holding_days: params.holding_days,
|
||||
})
|
||||
|
||||
// 存 reconnect 信息 (刷新后用)
|
||||
localStorage.setItem(RECONNECT_KEY, qs)
|
||||
|
||||
connectSSE(`/api/backtest/strategy/stream?${qs}`)
|
||||
}
|
||||
|
||||
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
|
||||
export async function stopBacktest(): Promise<void> {
|
||||
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
|
||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
||||
if (qs) {
|
||||
// 解析出参数, 用 fetch 调 cancel
|
||||
try {
|
||||
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
|
||||
// 后端会找到同一个 job 并返回它的 job_key? 不行。
|
||||
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
|
||||
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
|
||||
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
|
||||
await fetch('/api/backtest/strategy/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ qs }),
|
||||
}).catch(() => {})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
if (current?.isPending) {
|
||||
current = { ...current, isPending: false, error: '已取消' }
|
||||
emit()
|
||||
}
|
||||
localStorage.removeItem(RECONNECT_KEY)
|
||||
}
|
||||
|
||||
/** 清除任务状态 (隐藏提示) */
|
||||
export function clearBacktest(): void {
|
||||
current = null
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
|
||||
export function tryReconnect(): boolean {
|
||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
||||
if (!qs) return false
|
||||
// 有未完成的任务, 重连
|
||||
const id = ++taskSeq
|
||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
||||
emit()
|
||||
connectSSE(`/api/backtest/strategy/stream?${qs}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/** React hook: 读取当前全局回测任务状态 */
|
||||
export function useBacktestTask(): BacktestTask | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
}
|
||||
@@ -16,11 +16,7 @@ export const QK = {
|
||||
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
|
||||
indexList: ['index-list'] as const,
|
||||
|
||||
// Watchlist
|
||||
watchlist: ['watchlist'] as const,
|
||||
watchlistQuotes: ['watchlist-quotes'] as const,
|
||||
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
|
||||
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
|
||||
// Search
|
||||
instrumentSearch: (q: string) => ['instrument-search', q] as const,
|
||||
|
||||
// Screener
|
||||
@@ -31,9 +27,6 @@ export const QK = {
|
||||
marketSnapshot: ['market-snapshot'] as const,
|
||||
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
|
||||
|
||||
// Backtest
|
||||
backtestStatus: ['backtest-status'] as const,
|
||||
|
||||
// Data / Pipeline
|
||||
dataStatus: ['data-status'] as const,
|
||||
pipelineJobs: ['pipeline-jobs'] as const,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* 策略结果列表自定义列配置。
|
||||
*
|
||||
* 内置列与分组与自选页 (watchlist-columns) 保持一致,额外保留策略特有的
|
||||
* 「策略」「评分」两列。通用列模型/合并/扩展列参数来自 list-columns。
|
||||
*/
|
||||
import { storage } from '@/lib/storage'
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 买卖触发器信号定义 — 选股页弹窗 / 回测页共用。
|
||||
* 买卖触发器信号定义 — 选股页弹窗共用。
|
||||
*
|
||||
* 信号 ID 必须与后端 backtest/strategy.py:_build_signal_mask 对齐
|
||||
* 信号 ID 必须与后端对齐
|
||||
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
|
||||
*/
|
||||
|
||||
|
||||
@@ -27,27 +27,15 @@ export const storage = {
|
||||
/** 策略池 (screener) */
|
||||
strategyPool: kv<string[]>('strategy-pool'),
|
||||
|
||||
/** 自选列表列配置 */
|
||||
watchlistColumns: kv<unknown[]>('watchlist_columns'),
|
||||
|
||||
/** 个股日K信息条指标配置 */
|
||||
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
|
||||
|
||||
/** 策略结果列表列配置 */
|
||||
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
|
||||
|
||||
/** 自选列表视图模式 table | card */
|
||||
watchlistView: kv<string>('watchlist_view'),
|
||||
|
||||
/** 自选列表日K蜡烛图显示状态 */
|
||||
watchlistCandle: kv<boolean>('watchlist_showCandle'),
|
||||
|
||||
/** 策略结果列表日K蜡烛图显示状态 */
|
||||
screenerCandle: kv<boolean>('screener_showCandle'),
|
||||
|
||||
/** 自选列表板块筛选 */
|
||||
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
|
||||
|
||||
/** Screener 卡片尺寸 */
|
||||
screenerCardSize: kv<string>('screener-card-size'),
|
||||
|
||||
@@ -78,31 +66,6 @@ export const storage = {
|
||||
/** 已保存策略的原始规则(策略ID → 规则文本) */
|
||||
strategyRules: kv<Record<string, string>>('strategy-rules'),
|
||||
|
||||
/** 策略回测快捷区间按钮配置 */
|
||||
strategyBacktestQuickRanges: kv<unknown>('strategy-backtest-quick-ranges'),
|
||||
|
||||
/** 策略回测最后一次成功结果和参数 */
|
||||
strategyBacktestLast: kv<{
|
||||
selectedStrategy: string | null
|
||||
symbols: string
|
||||
start: string
|
||||
end: string
|
||||
matching: 'close_t' | 'open_t+1'
|
||||
entryFill: 'close_t' | 'open_t+1'
|
||||
exitFill: 'close_t' | 'open_t+1'
|
||||
fees: string
|
||||
slippage: string
|
||||
maxPositions: string
|
||||
maxExposure: string
|
||||
initialCapital: string
|
||||
positionSizing: 'equal' | 'score_weight'
|
||||
mode: 'position' | 'full'
|
||||
holdingDays: string
|
||||
params?: Record<string, any>
|
||||
overrides?: Record<string, any>
|
||||
result: any
|
||||
} | null>('strategy-backtest-last'),
|
||||
|
||||
/** 概念分析页面字段配置 */
|
||||
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
|
||||
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
/**
|
||||
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
|
||||
*/
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from './api'
|
||||
import { QK } from './queryKeys'
|
||||
|
||||
/** 批量添加自选 — Screener / Intraday 共用 */
|
||||
export function useWatchlistBatchAdd() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* 自选列表自定义列配置。
|
||||
*
|
||||
* 自选页只保留业务内置列、分组和偏好持久化;通用列模型/合并/扩展列参数
|
||||
* 来自 list-columns,策略页等其它股票列表可复用同一底座。
|
||||
*/
|
||||
|
||||
import { storage } from '@/lib/storage'
|
||||
import {
|
||||
buildExtColumnsParam as buildExtColumnsParamBase,
|
||||
createExtColumn as createExtColumnBase,
|
||||
mergeColumns as mergeColumnsBase,
|
||||
serializeColumns as serializeColumnsBase,
|
||||
type ColumnConfig,
|
||||
type ColumnGroup,
|
||||
type ColumnSource,
|
||||
type ExtColumnDisplayConfig,
|
||||
type CandleColumnConfig,
|
||||
} from '@/lib/list-columns'
|
||||
|
||||
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
|
||||
|
||||
// ===== 内置列注册表(与当前硬编码一一对应) =====
|
||||
|
||||
export const BUILTIN_COLUMNS: ColumnConfig[] = [
|
||||
// 固定列
|
||||
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '代码/名称', visible: true, pinned: true, align: 'left' },
|
||||
// 价格
|
||||
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'center' },
|
||||
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
|
||||
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
|
||||
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
|
||||
// 成交
|
||||
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
|
||||
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
|
||||
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
|
||||
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
|
||||
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'center' },
|
||||
// 均线
|
||||
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'center' },
|
||||
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'center' },
|
||||
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'center' },
|
||||
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'center' },
|
||||
// 区间
|
||||
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'center' },
|
||||
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'center' },
|
||||
// 技术指标
|
||||
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'center' },
|
||||
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'center' },
|
||||
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'center' },
|
||||
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'center' },
|
||||
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'center' },
|
||||
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'center' },
|
||||
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'center' },
|
||||
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'center' },
|
||||
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'center' },
|
||||
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'center' },
|
||||
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'center' },
|
||||
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'center' },
|
||||
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'center' },
|
||||
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'center' },
|
||||
// 动量
|
||||
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'center' },
|
||||
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'center' },
|
||||
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'center' },
|
||||
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'center' },
|
||||
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum' }, label: '60D 动量', visible: true, align: 'center' },
|
||||
// 连板
|
||||
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
|
||||
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
|
||||
// 信号 & 图表
|
||||
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
|
||||
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
|
||||
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
|
||||
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
|
||||
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
|
||||
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
|
||||
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'center' },
|
||||
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'center' },
|
||||
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'center' },
|
||||
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'center' },
|
||||
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
|
||||
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
|
||||
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
|
||||
]
|
||||
|
||||
export const COLUMN_GROUPS: ColumnGroup[] = [
|
||||
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
|
||||
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
|
||||
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
|
||||
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
|
||||
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
|
||||
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
|
||||
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
|
||||
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
|
||||
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
|
||||
]
|
||||
|
||||
// 操作列(始终显示,不参与自定义)
|
||||
export const ACTION_COLUMN_ID = 'builtin:action'
|
||||
|
||||
// ===== localStorage 持久化 =====
|
||||
|
||||
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action) */
|
||||
export function serializeColumns(columns: ColumnConfig[]): ColumnConfig[] {
|
||||
return serializeColumnsBase(columns, ACTION_COLUMN_ID)
|
||||
}
|
||||
|
||||
/** 序列化并保存到后端 + localStorage */
|
||||
export async function saveColumnConfig(columns: ColumnConfig[]): Promise<void> {
|
||||
const saveable = serializeColumns(columns)
|
||||
// 同时写 localStorage(即时)和后端(持久化)
|
||||
storage.watchlistColumns.set(saveable)
|
||||
try {
|
||||
const { api } = await import('@/lib/api')
|
||||
await api.updateWatchlistColumns(saveable)
|
||||
} catch {
|
||||
// 后端不可用时 localStorage 仍有效
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载列配置:优先后端,回退 localStorage,最终用默认值 */
|
||||
export async function loadColumnConfig(): Promise<ColumnConfig[]> {
|
||||
// 1. 尝试从后端加载
|
||||
try {
|
||||
const { api } = await import('@/lib/api')
|
||||
const res = await api.watchlistColumns()
|
||||
if (res.columns && res.columns.length > 0) {
|
||||
const merged = mergeColumns(res.columns, BUILTIN_COLUMNS)
|
||||
// 同步到 localStorage
|
||||
storage.watchlistColumns.set(serializeColumns(merged))
|
||||
return merged
|
||||
}
|
||||
} catch {
|
||||
// 后端不可用,继续尝试 localStorage
|
||||
}
|
||||
|
||||
// 2. 尝试从 localStorage 加载
|
||||
const saved = storage.watchlistColumns.get([]) as ColumnConfig[]
|
||||
if (saved.length > 0) {
|
||||
return mergeColumns(saved, BUILTIN_COLUMNS)
|
||||
}
|
||||
|
||||
// 3. 默认值
|
||||
return [...BUILTIN_COLUMNS]
|
||||
}
|
||||
|
||||
/** 合并用户保存的列与默认列 */
|
||||
function mergeColumns(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
|
||||
return mergeColumnsBase(saved, defaults, { actionColumnId: ACTION_COLUMN_ID })
|
||||
}
|
||||
|
||||
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口 */
|
||||
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
|
||||
return buildExtColumnsParamBase(columns)
|
||||
}
|
||||
|
||||
/** 根据 ext schema 数据创建 ext 列配置 */
|
||||
export function createExtColumn(
|
||||
configId: string,
|
||||
configLabel: string,
|
||||
fieldName: string,
|
||||
fieldLabel?: string,
|
||||
): ColumnConfig {
|
||||
return createExtColumnBase(configId, configLabel, fieldName, fieldLabel)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { FactorBacktest } from './backtest/FactorBacktest'
|
||||
import { StrategyBacktest } from './backtest/StrategyBacktest'
|
||||
import { BarChart3, FlaskConical } from 'lucide-react'
|
||||
|
||||
type Tab = 'factor' | 'strategy'
|
||||
|
||||
const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
|
||||
factor: {
|
||||
title: '因子回测',
|
||||
subtitle: '验证单个因子是否有预测能力',
|
||||
hint: '看 IC / IR、分层收益和多空组合,适合先筛掉无效指标。',
|
||||
},
|
||||
strategy: {
|
||||
title: '策略回测',
|
||||
subtitle: '验证完整选股和交易规则',
|
||||
hint: '看净值曲线、回撤、胜率和交易明细,适合判断策略是否可执行。',
|
||||
},
|
||||
}
|
||||
|
||||
export function Backtest() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('strategy')
|
||||
|
||||
const modeSwitch = (
|
||||
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
|
||||
{(['factor', 'strategy'] as const).map(tab => {
|
||||
const Icon = tab === 'factor' ? BarChart3 : FlaskConical
|
||||
const active = activeTab === tab
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: 'text-secondary hover:bg-elevated hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{MODES[tab].title}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-base flex flex-col">
|
||||
<PageHeader
|
||||
title="回测工作台"
|
||||
subtitle={`${MODES[activeTab].title} · ${MODES[activeTab].hint}`}
|
||||
right={modeSwitch}
|
||||
className="shrink-0 bg-base/95"
|
||||
/>
|
||||
|
||||
<main className="flex-1 min-h-0 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
|
||||
{activeTab === 'factor' && <FactorBacktest />}
|
||||
{activeTab === 'strategy' && <StrategyBacktest />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
|
||||
import { SealedBadge } from '@/components/SealedBadge'
|
||||
import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns'
|
||||
import type { ExtColumnDisplayConfig } from '@/lib/list-columns'
|
||||
|
||||
// ===== Ext 字段配置 =====
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
|
||||
import { ScanSearch, Clock, TrendingUp, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
|
||||
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
|
||||
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
|
||||
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
@@ -35,7 +34,6 @@ export function Screener() {
|
||||
const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<ScreenerResult | null>(null)
|
||||
const [asOf, setAsOf] = useState<string>('')
|
||||
const [batchMsg, setBatchMsg] = useState<string>('')
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('')
|
||||
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
|
||||
@@ -402,28 +400,6 @@ export function Screener() {
|
||||
const minDate = dataStatus.data?.enriched?.earliest_date ?? ''
|
||||
const maxDate = dataStatus.data?.enriched?.latest_date ?? ''
|
||||
|
||||
const batchAdd = useWatchlistBatchAdd()
|
||||
|
||||
// 自选股列表 (用于判断是否在自选中)
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
queryFn: api.watchlistList,
|
||||
})
|
||||
const watchlistSet = useMemo(() => {
|
||||
const symbols = watchlist.data?.symbols ?? []
|
||||
return new Set(symbols.map((s: any) => s.symbol))
|
||||
}, [watchlist.data])
|
||||
|
||||
// 单只股票加入/移出自选
|
||||
const toggleWatchlist = useMutation({
|
||||
mutationFn: ({ symbol, inList }: { symbol: string; inList: boolean }) =>
|
||||
inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
||||
},
|
||||
})
|
||||
|
||||
// 重新运行策略:重载策略文件 + 重跑全部策略,刷新符合条件的个股
|
||||
const reloadStrategies = useMutation({
|
||||
mutationFn: api.strategyReload,
|
||||
@@ -473,22 +449,6 @@ export function Screener() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchAdd = () => {
|
||||
if (!displayRows.length) return
|
||||
const symbols = displayRows.map((r: any) => r.symbol)
|
||||
batchAdd.mutate(symbols, {
|
||||
onSuccess: (data) => {
|
||||
setBatchMsg(`已添加 ${data.added} 只到自选`)
|
||||
setTimeout(() => setBatchMsg(''), 3000)
|
||||
},
|
||||
onError: () => {
|
||||
setBatchMsg('添加失败')
|
||||
setTimeout(() => setBatchMsg(''), 3000)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -688,18 +648,6 @@ export function Screener() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{displayRows.length > 0 && (
|
||||
<button
|
||||
onClick={handleBatchAdd}
|
||||
disabled={batchAdd.isPending}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
|
||||
border border-accent/40 bg-accent/10 text-accent text-xs font-medium
|
||||
hover:bg-accent/20 disabled:opacity-50 transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
<Star className="h-3 w-3" />
|
||||
{batchAdd.isPending ? '添加中…' : '批量加自选'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCustomizerOpen(true)}
|
||||
title="列表配置"
|
||||
@@ -711,9 +659,6 @@ export function Screener() {
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
</button>
|
||||
{batchMsg && (
|
||||
<span className="text-xs text-accent animate-pulse">{batchMsg}</span>
|
||||
)}
|
||||
{!showAll && result && result.elapsed_ms > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<Clock className="h-3 w-3" />
|
||||
@@ -749,10 +694,7 @@ export function Screener() {
|
||||
strategyIdToName={strategyIdToName}
|
||||
symbolStrategyMap={symbolStrategyMap}
|
||||
activeStrategy={activeStrategy}
|
||||
watchlistSet={watchlistSet}
|
||||
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
|
||||
onToggleWatchlist={(symbol, inList) => toggleWatchlist.mutate({ symbol, inList })}
|
||||
watchlistPending={toggleWatchlist.isPending}
|
||||
klineData={klineData}
|
||||
dailyKChartVisible={dailyKChartVisible}
|
||||
onToggleDailyKChart={toggleDailyKChart}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,447 +0,0 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Play, BarChart3, Clock } from 'lucide-react'
|
||||
import { api, type FactorColumn, type FactorBacktestResult, type GroupStat } from '@/lib/api'
|
||||
import { fmtPct, priceColorClass } from '@/lib/format'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { FactorICChart } from './charts/FactorICChart'
|
||||
import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
||||
const monthsAgo = (months: number) => {
|
||||
const date = new Date()
|
||||
date.setMonth(date.getMonth() - months)
|
||||
return formatDate(date)
|
||||
}
|
||||
const TODAY = formatDate(new Date())
|
||||
const THREE_MONTHS_AGO = monthsAgo(3)
|
||||
|
||||
const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs
|
||||
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`
|
||||
|
||||
function StatCard({ label, value, highlight }: {
|
||||
label: string
|
||||
value: string | null | undefined
|
||||
highlight?: 'bull' | 'bear' | 'neutral'
|
||||
}) {
|
||||
const colorCls = highlight === 'bull'
|
||||
? 'text-bull' : highlight === 'bear' ? 'text-bear' : ''
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted">{label}</div>
|
||||
<div className={`mt-1 text-lg font-mono font-semibold tracking-tight num ${colorCls}`}>
|
||||
{value ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingPanel({ symbolsText }: { symbolsText: string }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-card border border-accent/25 bg-accent/[0.04] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">正在计算因子分析</div>
|
||||
<div className="mt-1 text-xs text-muted">{symbolsText} · 完成后会一次性刷新 IC、分层收益和净值曲线。</div>
|
||||
</div>
|
||||
<div className="h-8 w-8 rounded-full border-2 border-accent/25 border-t-accent animate-spin" />
|
||||
</div>
|
||||
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-base">
|
||||
<div className="h-full w-1/2 rounded-full bg-accent/70 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{['读取因子', '计算 IC', '分层回测', '汇总指标'].map(item => (
|
||||
<div key={item} className="rounded-btn border border-border bg-surface p-3">
|
||||
<div className="h-2 w-10 rounded bg-accent/30 animate-pulse" />
|
||||
<div className="mt-3 text-xs text-secondary">{item}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-secondary">分层净值预览</div>
|
||||
<div className="text-[11px] text-muted">等待后端返回完整结果</div>
|
||||
</div>
|
||||
<div className="mt-4 h-[260px] rounded-btn border border-border bg-base/60 p-4">
|
||||
<div className="flex h-full items-end gap-2 opacity-70">
|
||||
{[46, 38, 54, 50, 64, 58, 74, 68, 84, 78, 90, 86].map((h, i) => (
|
||||
<div key={i} className="flex-1 rounded-t bg-accent/20 animate-pulse" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FactorBacktest() {
|
||||
const [factorName, setFactorName] = useState('momentum_20d')
|
||||
const [symbols, setSymbols] = useState('')
|
||||
const [start, setStart] = useState(THREE_MONTHS_AGO)
|
||||
const [end, setEnd] = useState(TODAY)
|
||||
const [nGroups, setNGroups] = useState(5)
|
||||
const [weight, setWeight] = useState<'equal' | 'factor_weight'>('equal')
|
||||
const [fees, setFees] = useState('2')
|
||||
const [result, setResult] = useState<FactorBacktestResult | null>(null)
|
||||
|
||||
const columns = useQuery({
|
||||
queryKey: ['backtest-factor-columns'],
|
||||
queryFn: api.factorColumns,
|
||||
})
|
||||
|
||||
// 按 group 分类的因子
|
||||
const factorGroups = useMemo(() => {
|
||||
const cols = columns.data?.columns ?? []
|
||||
const groups: Record<string, FactorColumn[]> = {}
|
||||
for (const c of cols) {
|
||||
;(groups[c.group] ??= []).push(c)
|
||||
}
|
||||
return groups
|
||||
}, [columns.data])
|
||||
|
||||
// 当前因子描述
|
||||
const factorDesc = useMemo(() => {
|
||||
return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
|
||||
}, [columns.data, factorName])
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: () =>
|
||||
api.factorRun({
|
||||
factor_name: factorName,
|
||||
symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
|
||||
start: start || null,
|
||||
end: end || undefined,
|
||||
n_groups: nGroups,
|
||||
rebalance: 'daily',
|
||||
weight,
|
||||
fees_pct: Number(fees) / 10000,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
setResult(data)
|
||||
} else {
|
||||
setResult(data)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const applyRange = (months: number) => {
|
||||
setStart(monthsAgo(months))
|
||||
setEnd(formatDate(new Date()))
|
||||
}
|
||||
|
||||
const applyAllRange = () => {
|
||||
setStart('')
|
||||
setEnd(formatDate(new Date()))
|
||||
}
|
||||
|
||||
const rangeKey = end === TODAY && start === THREE_MONTHS_AGO
|
||||
? '3m'
|
||||
: end === TODAY && start === monthsAgo(6)
|
||||
? '6m'
|
||||
: end === TODAY && start === monthsAgo(12)
|
||||
? '1y'
|
||||
: end === TODAY && start === ''
|
||||
? 'all'
|
||||
: 'custom'
|
||||
const rangeTitle = rangeKey === '3m'
|
||||
? '近 3 个月'
|
||||
: rangeKey === '6m'
|
||||
? '近 6 个月'
|
||||
: rangeKey === '1y'
|
||||
? '近 1 年'
|
||||
: rangeKey === 'all'
|
||||
? '全部历史'
|
||||
: '自定义区间'
|
||||
const rangeButtonCls = (key: string) => `rounded-btn px-2 py-1 text-[11px] font-medium transition-colors ${rangeKey === key
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted hover:bg-elevated/70 hover:text-secondary'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-hidden rounded-card border border-border bg-surface/80 grid grid-cols-1 xl:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
{/* 配置面板 */}
|
||||
<section className="space-y-3 border-b xl:border-b-0 xl:border-r border-border bg-base/25 px-3 py-3 xl:overflow-y-auto">
|
||||
<div className="border-b border-border/70 pb-2">
|
||||
<div className="text-xs font-semibold text-foreground">因子配置</div>
|
||||
<div className="mt-0.5 text-[10px] leading-4 text-muted">选择因子、区间和分组方式。默认最近 3 个月。</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">因子</label>
|
||||
<select
|
||||
value={factorName}
|
||||
onChange={e => setFactorName(e.target.value)}
|
||||
className={INPUT_CLS}
|
||||
>
|
||||
{Object.entries(factorGroups).map(([group, cols]) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{cols.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{factorDesc && (
|
||||
<p className="mt-1 text-[11px] text-muted">{factorDesc}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">
|
||||
标的(逗号分隔,留空=全市场)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={symbols}
|
||||
onChange={e => setSymbols(e.target.value)}
|
||||
placeholder="留空则使用全市场,建议最近3个月"
|
||||
className={`w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs font-mono
|
||||
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-btn border border-border bg-surface p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs font-medium text-foreground">回测区间</div>
|
||||
<span className="shrink-0 rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{rangeTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[11px] text-secondary block mb-1">开始</label>
|
||||
<DatePicker
|
||||
value={start}
|
||||
onChange={setStart}
|
||||
max={end || undefined}
|
||||
placeholder="全部历史"
|
||||
className="w-full"
|
||||
buttonClassName="w-full justify-start"
|
||||
align="left"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-secondary block mb-1">结束</label>
|
||||
<DatePicker
|
||||
value={end}
|
||||
onChange={setEnd}
|
||||
min={start || undefined}
|
||||
className="w-full"
|
||||
buttonClassName="w-full justify-start"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex rounded-input bg-base/60 p-0.5">
|
||||
<button type="button" onClick={() => applyRange(3)} className={`${rangeButtonCls('3m')} flex-1`}>3个月</button>
|
||||
<button type="button" onClick={() => applyRange(6)} className={`${rangeButtonCls('6m')} flex-1`}>6个月</button>
|
||||
<button type="button" onClick={() => applyRange(12)} className={`${rangeButtonCls('1y')} flex-1`}>1年</button>
|
||||
<button type="button" onClick={applyAllRange} className={`${rangeButtonCls('all')} flex-1`}>全部</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">分组数</label>
|
||||
<select value={nGroups} onChange={e => setNGroups(Number(e.target.value))} className={INPUT_CLS}>
|
||||
<option value={3}>3组</option>
|
||||
<option value={5}>5组</option>
|
||||
<option value={10}>10组</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">权重</label>
|
||||
<select value={weight} onChange={e => setWeight(e.target.value as any)} className={INPUT_CLS}>
|
||||
<option value="equal">等权</option>
|
||||
<option value="factor_weight">因子加权</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-secondary block mb-1.5">佣金(万分之)</label>
|
||||
<input type="number" value={fees} onChange={e => setFees(e.target.value)}
|
||||
className={INPUT_CLS} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => run.mutate()}
|
||||
disabled={run.isPending}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
|
||||
bg-accent text-sm font-medium hover:bg-accent/90
|
||||
transition-colors duration-150 ease-smooth disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{run.isPending ? '分析中…' : '开始因子分析'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 结果面板 */}
|
||||
<section className="min-w-0 space-y-3 bg-base/15 px-3 py-3 xl:overflow-y-auto">
|
||||
{result?.error && !result.ic_mean && (
|
||||
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.isError && (
|
||||
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
|
||||
{String((run.error as any).message)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !run.isPending && (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="选择因子并开始分析"
|
||||
hint="因子回测分析因子的预测能力 ( IC/IR ) 和分层收益差异。服务器建议优先使用最近3个月;长周期建议本机或 8GB 以上内存环境运行。"
|
||||
/>
|
||||
)}
|
||||
|
||||
{run.isPending && result && (
|
||||
<div className="rounded-card border border-accent/25 bg-accent/[0.04] px-4 py-3 text-xs text-secondary">
|
||||
正在重新计算,当前暂时展示上一次因子分析结果,完成后会自动替换。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.isPending && !result && (
|
||||
<LoadingPanel symbolsText={symbols ? `${symbols.split(',').length} 只标的` : '全市场 · 当前区间'} />
|
||||
)}
|
||||
|
||||
{result && result.ic_mean != null && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* IC/IR 指标 */}
|
||||
<div className="rounded-card border border-border bg-surface p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-foreground">因子预测能力</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted">
|
||||
Rank IC · 日度调仓
|
||||
</span>
|
||||
{result.elapsed_ms > 0 && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span className="num">{result.elapsed_ms.toFixed(0)} ms</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="IC 均值"
|
||||
value={result.ic_mean != null ? fmtPct(result.ic_mean) : null}
|
||||
highlight={result.ic_mean != null
|
||||
? result.ic_mean > 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'
|
||||
: undefined}
|
||||
/>
|
||||
<StatCard label="IC 标准差" value={result.ic_std != null ? fmtPct(result.ic_std) : null} />
|
||||
<StatCard
|
||||
label="ICIR"
|
||||
value={result.ir != null ? result.ir.toFixed(2) : null}
|
||||
highlight={result.ir != null
|
||||
? Math.abs(result.ir) > 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
|
||||
: undefined}
|
||||
/>
|
||||
<StatCard label="IC 胜率" value={result.ic_win_rate != null ? fmtPct(result.ic_win_rate) : null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IC 时序图 */}
|
||||
{result.ic_series.length > 0 && (
|
||||
<div className="rounded-card border border-border overflow-hidden">
|
||||
<div className="bg-elevated px-4 py-2">
|
||||
<span className="text-xs font-medium text-secondary">IC 时序</span>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<FactorICChart result={result} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分层净值 */}
|
||||
{result.group_nav.length > 0 && (
|
||||
<div className="rounded-card border border-border overflow-hidden">
|
||||
<div className="bg-elevated px-4 py-2">
|
||||
<span className="text-xs font-medium text-secondary">分层净值曲线</span>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<FactorGroupNavChart result={result} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分层统计表 */}
|
||||
{result.group_stats.length > 0 && (
|
||||
<div className="rounded-card border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-elevated">
|
||||
<tr className="text-left text-secondary">
|
||||
<th className="px-4 py-2.5 font-medium">分组</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">总收益</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">年化</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">最大回撤</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">夏普</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">胜率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.group_stats.map((g: GroupStat) => (
|
||||
<tr key={g.group} className="border-t border-border hover:bg-elevated/50 transition-colors">
|
||||
<td className="px-4 py-2 text-sm font-medium">{g.label}</td>
|
||||
<td className={`px-4 py-2 text-right num ${priceColorClass(g.total_return)}`}>
|
||||
{fmtPct(g.total_return)}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-right num ${priceColorClass(g.annual_return)}`}>
|
||||
{fmtPct(g.annual_return)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right num text-bear">{fmtPct(g.max_drawdown)}</td>
|
||||
<td className="px-4 py-2 text-right num">{g.sharpe?.toFixed(2)}</td>
|
||||
<td className="px-4 py-2 text-right num">{fmtPct(g.win_rate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 多空行 */}
|
||||
{result.long_short_stats?.total_return != null && (
|
||||
<tr className="border-t-2 border-accent/30 bg-accent/[0.03]">
|
||||
<td className="px-4 py-2 text-sm font-medium text-accent">
|
||||
多空({result.long_short_stats.top_group ?? ''}-{result.long_short_stats.bottom_group ?? ''})
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-right num font-medium ${priceColorClass(result.long_short_stats.total_return)}`}>
|
||||
{fmtPct(result.long_short_stats.total_return as number)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right num">—</td>
|
||||
<td className="px-4 py-2 text-right num text-bear">
|
||||
{fmtPct(result.long_short_stats.max_drawdown as number)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right num">—</td>
|
||||
<td className="px-4 py-2 text-right num">—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据概要 */}
|
||||
<div className="flex items-center gap-4 text-[11px] text-muted">
|
||||
<span>{result.n_symbols} 只标的</span>
|
||||
<span>{result.n_dates} 个交易日</span>
|
||||
<span>run_id: {result.run_id}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useECharts } from './useECharts'
|
||||
import type { FactorBacktestResult } from '@/lib/api'
|
||||
|
||||
const GROUP_COLORS = [
|
||||
'#6366f1', // Q1 indigo
|
||||
'#8b5cf6', // Q2 violet
|
||||
'#f59e0b', // Q3 amber
|
||||
'#f97316', // Q4 orange
|
||||
'#ef4444', // Q5 red
|
||||
'#ec4899', // Q6
|
||||
'#14b8a6', // Q7
|
||||
'#06b6d4', // Q8
|
||||
'#84cc16', // Q9
|
||||
'#a855f7', // Q10
|
||||
]
|
||||
|
||||
interface Props {
|
||||
result: FactorBacktestResult
|
||||
}
|
||||
|
||||
export function FactorGroupNavChart({ result }: Props) {
|
||||
const option = useMemo(() => {
|
||||
if (!result.group_nav.length) return null
|
||||
|
||||
const dates = result.group_nav.map(r => (r.date as string).slice(0, 10))
|
||||
const groupCols = Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
|
||||
|
||||
// 多空净值
|
||||
const lsNav = result.long_short_nav
|
||||
const hasLS = lsNav && lsNav.length > 0
|
||||
|
||||
const series = groupCols.map((col, i) => ({
|
||||
name: col,
|
||||
type: 'line',
|
||||
data: result.group_nav.map(r => r[col]),
|
||||
symbol: 'none',
|
||||
lineStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length], width: 1.5 } as any,
|
||||
itemStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length] } as any,
|
||||
}))
|
||||
|
||||
if (hasLS) {
|
||||
series.push({
|
||||
name: '多空',
|
||||
type: 'line',
|
||||
data: lsNav.map(r => r.value),
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#fbbf24', width: 2, type: 'dashed' },
|
||||
itemStyle: { color: '#fbbf24' },
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
grid: { left: 56, right: 16, top: 12, bottom: 28 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
||||
borderColor: 'rgba(148,163,184,0.2)',
|
||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
const date = params[0]?.axisValue ?? ''
|
||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
||||
for (const p of params) {
|
||||
if (p.value == null) continue
|
||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
||||
<span style="display:flex;align-items:center;gap:4px">
|
||||
<span style="width:8px;height:3px;border-radius:1px;background:${p.color};display:inline-block"></span>
|
||||
${p.seriesName}
|
||||
</span>
|
||||
<span style="font-family:monospace">${(p.value as number).toFixed(4)}</span>
|
||||
</div>`
|
||||
}
|
||||
return html
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
||||
axisLine: { lineStyle: { color: '#334155' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
axisLabel: { color: '#64748b', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
series,
|
||||
} as any
|
||||
}, [result.group_nav, result.long_short_nav, result.run_id])
|
||||
|
||||
const chartRef = useECharts(option, [result.run_id])
|
||||
|
||||
// 图例
|
||||
const groupCols = result.group_nav.length > 0
|
||||
? Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
|
||||
: []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 px-4 pb-2">
|
||||
{groupCols.map((col, i) => (
|
||||
<span key={col} className="flex items-center gap-1 text-[10px] text-secondary">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: GROUP_COLORS[i % GROUP_COLORS.length] }} />
|
||||
{col}
|
||||
</span>
|
||||
))}
|
||||
{result.long_short_nav?.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-secondary">
|
||||
<span className="w-2 h-0.5 rounded bg-yellow-400" style={{ borderTop: '2px dashed #fbbf24' }} />
|
||||
多空
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div ref={chartRef} className="h-[280px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useECharts } from './useECharts'
|
||||
import type { FactorBacktestResult } from '@/lib/api'
|
||||
|
||||
interface Props {
|
||||
result: FactorBacktestResult
|
||||
}
|
||||
|
||||
export function FactorICChart({ result }: Props) {
|
||||
const option = useMemo(() => {
|
||||
if (!result.ic_series.length) return null
|
||||
|
||||
const dates = result.ic_series.map(r => r.date.slice(0, 10))
|
||||
const values = result.ic_series.map(r => r.ic)
|
||||
|
||||
// 12期移动平均
|
||||
const maWindow = 12
|
||||
const ma: (number | null)[] = values.map((_, i) => {
|
||||
if (i < maWindow - 1) return null
|
||||
const slice = values.slice(i - maWindow + 1, i + 1)
|
||||
return slice.reduce((a, b) => a + b, 0) / slice.length
|
||||
})
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
grid: { left: 50, right: 16, top: 16, bottom: 28 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
||||
borderColor: 'rgba(148,163,184,0.2)',
|
||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
const date = params[0]?.axisValue ?? ''
|
||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
||||
for (const p of params) {
|
||||
if (p.value == null) continue
|
||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
||||
<span style="color:${p.color}">${p.seriesName}</span>
|
||||
<span style="font-family:monospace">${(p.value * 100).toFixed(2)}%</span>
|
||||
</div>`
|
||||
}
|
||||
return html
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
||||
axisLine: { lineStyle: { color: '#334155' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#64748b', fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
|
||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'IC',
|
||||
type: 'bar',
|
||||
data: values.map(v => ({
|
||||
value: v,
|
||||
itemStyle: {
|
||||
color: v >= 0
|
||||
? 'rgba(240,68,56,0.6)'
|
||||
: 'rgba(18,183,106,0.6)',
|
||||
},
|
||||
})),
|
||||
barMaxWidth: 6,
|
||||
},
|
||||
{
|
||||
name: `MA${maWindow}`,
|
||||
type: 'line',
|
||||
data: ma,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#f59e0b', width: 1.5 },
|
||||
z: 10,
|
||||
},
|
||||
],
|
||||
} as any
|
||||
}, [result.ic_series])
|
||||
|
||||
const chartRef = useECharts(option, [result.run_id])
|
||||
|
||||
return <div ref={chartRef} className="h-[200px]" />
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useECharts } from './useECharts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
|
||||
interface DistBin {
|
||||
range: string
|
||||
count: number
|
||||
ratio: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 收益分布直方图 — 全量模拟专用的候选标的收益分布。
|
||||
* 柱子颜色按收益正负区分(正红负绿),零轴居中。
|
||||
*/
|
||||
export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const cats = distribution.map(d => d.range)
|
||||
const vals = distribution.map(d => d.count)
|
||||
// 判断每档是正还是负(按 range 字符串首字符 +/~)
|
||||
const colors = distribution.map(d => {
|
||||
const lo = parseFloat(d.range)
|
||||
// 中心档(跨 0) 用中性色
|
||||
if (lo < 0 && parseFloat(d.range.split('~')[1]) > 0) return '#a1a1aa'
|
||||
return lo >= 0 ? '#ef4444' : '#22c55e'
|
||||
})
|
||||
|
||||
return {
|
||||
grid: { left: 48, right: 16, top: 24, bottom: 56 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const p = Array.isArray(params) ? params[0] : params
|
||||
const bin = distribution[p.dataIndex]
|
||||
if (!bin) return ''
|
||||
return `${bin.range}<br/>数量: ${bin.count}<br/>占比: ${(bin.ratio * 100).toFixed(1)}%`
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: cats,
|
||||
axisLabel: { color: '#a1a1aa', fontSize: 10, rotate: 45, interval: 1 },
|
||||
axisLine: { lineStyle: { color: '#3f3f46' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#a1a1aa', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: '#27272a' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: vals.map((v, i) => ({ value: v, itemStyle: { color: colors[i] } })),
|
||||
barWidth: '90%',
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [distribution])
|
||||
|
||||
const chartRef = useECharts(option, [distribution])
|
||||
|
||||
return <div ref={chartRef} className="h-48 w-full" />
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useECharts } from './useECharts'
|
||||
import type { StrategyBacktestResult } from '@/lib/api'
|
||||
|
||||
interface Props {
|
||||
result: StrategyBacktestResult
|
||||
}
|
||||
|
||||
export function StrategyNavChart({ result }: Props) {
|
||||
const option = useMemo(() => {
|
||||
if (!result.equity_curve.length) return null
|
||||
|
||||
const moneyFmt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const valueFmt = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
const axisMoneyFmt = (v: number) => {
|
||||
if (Math.abs(v) >= 100_000_000) return `${(v / 100_000_000).toFixed(1)}亿`
|
||||
if (Math.abs(v) >= 10_000) return `${(v / 10_000).toFixed(0)}万`
|
||||
return moneyFmt.format(v)
|
||||
}
|
||||
const dates = result.equity_curve.map(r => r.date.slice(0, 10))
|
||||
const navValues = result.equity_curve.map(r => r.value)
|
||||
const benchmarkByDate = new Map((result.benchmark_curve ?? []).map(r => [r.date.slice(0, 10), r.close ?? r.value]))
|
||||
const benchmarkValues = dates.map(d => benchmarkByDate.get(d) ?? null)
|
||||
const hasBenchmark = benchmarkValues.some(v => v != null)
|
||||
const ddValues = result.drawdown_curve.map(r => r.value * 100)
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
axisPointer: {
|
||||
link: [{ xAxisIndex: 'all' }],
|
||||
label: { backgroundColor: '#334155' },
|
||||
},
|
||||
grid: [
|
||||
{ left: 64, right: hasBenchmark ? 64 : 16, top: 14, bottom: '40%' },
|
||||
{ left: 64, right: hasBenchmark ? 64 : 16, top: '68%', bottom: 46 },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category', data: dates, gridIndex: 0,
|
||||
axisLabel: { show: false }, axisTick: { show: false },
|
||||
axisPointer: { show: true, type: 'line' },
|
||||
axisLine: { lineStyle: { color: '#334155' } },
|
||||
},
|
||||
{
|
||||
type: 'category', data: dates, gridIndex: 1,
|
||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
||||
axisTick: { show: false },
|
||||
axisPointer: { show: true, type: 'line' },
|
||||
axisLine: { lineStyle: { color: '#334155' } },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value', gridIndex: 0,
|
||||
scale: true,
|
||||
name: hasBenchmark ? '上证点位' : '策略资金',
|
||||
nameTextStyle: { color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
|
||||
axisLabel: {
|
||||
color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b',
|
||||
fontSize: 10,
|
||||
formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'value', gridIndex: 0,
|
||||
position: 'right',
|
||||
scale: true,
|
||||
name: hasBenchmark ? '策略资金' : '',
|
||||
nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
|
||||
axisLabel: {
|
||||
show: hasBenchmark,
|
||||
color: '#64748b',
|
||||
fontSize: 10,
|
||||
formatter: axisMoneyFmt,
|
||||
},
|
||||
splitLine: { show: false },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'value', gridIndex: 1,
|
||||
position: 'right',
|
||||
max: 0,
|
||||
axisLabel: {
|
||||
color: '#64748b', fontSize: 10,
|
||||
formatter: (v: number) => `${v.toFixed(1)}%`,
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
xAxisIndex: [0, 1],
|
||||
filterMode: 'filter',
|
||||
zoomOnMouseWheel: true,
|
||||
moveOnMouseMove: true,
|
||||
moveOnMouseWheel: false,
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
xAxisIndex: [0, 1],
|
||||
filterMode: 'filter',
|
||||
height: 16,
|
||||
bottom: 10,
|
||||
borderColor: 'rgba(148,163,184,0.18)',
|
||||
backgroundColor: 'rgba(15,23,42,0.55)',
|
||||
fillerColor: 'rgba(59,130,246,0.18)',
|
||||
handleStyle: { color: '#64748b', borderColor: '#94a3b8' },
|
||||
textStyle: { color: '#64748b', fontSize: 10 },
|
||||
brushSelect: false,
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
||||
borderColor: 'rgba(148,163,184,0.2)',
|
||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
const date = params[0]?.axisValue ?? ''
|
||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
||||
for (const p of params) {
|
||||
if (p.value == null) continue
|
||||
const isDrawdown = p.seriesName === '回撤'
|
||||
const isBenchmark = p.seriesName === '同期上证指数'
|
||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
||||
<span style="color:${p.color}">${p.seriesName}</span>
|
||||
<span style="font-family:monospace">${
|
||||
isDrawdown
|
||||
? `${(p.value as number).toFixed(2)}%`
|
||||
: isBenchmark
|
||||
? `${valueFmt.format(p.value as number)} 点`
|
||||
: moneyFmt.format(p.value as number)
|
||||
}</span>
|
||||
</div>`
|
||||
}
|
||||
return html
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '净值',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: hasBenchmark ? 1 : 0,
|
||||
data: navValues,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#3b82f6', width: 2.2 },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(59,130,246,0.15)' },
|
||||
{ offset: 1, color: 'rgba(59,130,246,0.01)' },
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
...(hasBenchmark ? [{
|
||||
name: '同期上证指数',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: benchmarkValues,
|
||||
symbol: 'none',
|
||||
connectNulls: true,
|
||||
lineStyle: { color: 'rgba(148,163,184,0.45)', width: 1, type: 'dashed' },
|
||||
}] : []),
|
||||
{
|
||||
name: '回撤',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 2,
|
||||
data: ddValues,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: 'rgba(240,68,56,0.6)', width: 1 },
|
||||
areaStyle: { color: 'rgba(240,68,56,0.12)' },
|
||||
},
|
||||
],
|
||||
} as any
|
||||
}, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id])
|
||||
|
||||
const chartRef = useECharts(option, [result.run_id])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-4 px-4 pb-2">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
||||
<span className="w-3 h-0.5 rounded bg-accent" />
|
||||
策略净值
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
||||
<span className="w-3 h-0.5 rounded bg-red-400/60" />
|
||||
回撤
|
||||
</span>
|
||||
{(result.benchmark_curve?.length ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
||||
<span className="w-3 h-0.5 rounded border-t border-dashed border-slate-400/60" />
|
||||
同期上证指数
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-muted">滚轮缩放 · 拖动平移</span>
|
||||
</div>
|
||||
<div ref={chartRef} className="h-[282px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts, EChartsOption } from 'echarts'
|
||||
|
||||
/**
|
||||
* ECharts 实例管理 Hook — 自动初始化/resize/销毁。
|
||||
* 返回 ref 绑定到容器 div,和 setOption 方法。
|
||||
*/
|
||||
export function useECharts(
|
||||
option: EChartsOption | null,
|
||||
deps: any[] = [],
|
||||
) {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const instanceRef = useRef<ECharts | null>(null)
|
||||
|
||||
// 初始化 / 销毁
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return
|
||||
instanceRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
|
||||
const handleResize = () => instanceRef.current?.resize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
instanceRef.current?.dispose()
|
||||
instanceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 更新 option
|
||||
useEffect(() => {
|
||||
if (!instanceRef.current || !option) return
|
||||
instanceRef.current.setOption(option, { notMerge: true })
|
||||
}, [option, ...deps])
|
||||
|
||||
return chartRef
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Clock, X } from 'lucide-react'
|
||||
import { StockPanel } from '@/components/StockPanel'
|
||||
import type { ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
|
||||
import type { StrategyBacktestTrade } from '@/lib/api'
|
||||
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
||||
|
||||
interface Props {
|
||||
trade: StrategyBacktestTrade | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function addDays(date: string, days: number): string {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + days)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function fmtMoney(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(Number(v))) return '—'
|
||||
const n = Number(v)
|
||||
const abs = Math.abs(n)
|
||||
if (abs >= 100_000_000) return `${(n / 100_000_000).toFixed(2)}亿`
|
||||
if (abs >= 10_000) return `${(n / 10_000).toFixed(2)}万`
|
||||
return n.toFixed(0)
|
||||
}
|
||||
|
||||
function fmtSignedMoney(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(Number(v))) return '—'
|
||||
const prefix = Number(v) > 0 ? '+' : ''
|
||||
return `${prefix}${fmtMoney(v)}`
|
||||
}
|
||||
|
||||
export function TradeKlineModal({ trade, onClose }: Props) {
|
||||
const [showIntraday, setShowIntraday] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!trade) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [trade, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (trade) setShowIntraday(false)
|
||||
}, [trade])
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
if (!trade) return null
|
||||
return {
|
||||
start: addDays(String(trade.entry_date).slice(0, 10), -45),
|
||||
end: addDays(String(trade.exit_date).slice(0, 10), 20),
|
||||
}
|
||||
}, [trade])
|
||||
|
||||
const ranges = useMemo<ChartRange[]>(() => {
|
||||
if (!trade) return []
|
||||
return [{
|
||||
start: String(trade.entry_date).slice(0, 10),
|
||||
end: String(trade.exit_date).slice(0, 10),
|
||||
label: '持仓区间',
|
||||
color: 'rgba(59,130,246,0.07)',
|
||||
}]
|
||||
}, [trade])
|
||||
|
||||
const priceLines = useMemo<ChartPriceLine[]>(() => {
|
||||
if (!trade) return []
|
||||
const start = String(trade.entry_date).slice(0, 10)
|
||||
const end = String(trade.exit_date).slice(0, 10)
|
||||
return [
|
||||
{
|
||||
value: Number(trade.entry_price),
|
||||
label: `买入价 ${fmtPrice(trade.entry_price)}`,
|
||||
color: '#C74040',
|
||||
start,
|
||||
end,
|
||||
},
|
||||
{
|
||||
value: Number(trade.exit_price),
|
||||
label: `卖出价 ${fmtPrice(trade.exit_price)}`,
|
||||
color: '#2D9B65',
|
||||
start,
|
||||
end,
|
||||
},
|
||||
]
|
||||
}, [trade])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{trade && dateRange && (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative flex max-h-[94vh] w-[92vw] max-w-[1120px] flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold text-foreground">{trade.symbol}</span>
|
||||
<span className="truncate text-sm text-foreground">{trade.name || '交易回放'}</span>
|
||||
<span className="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">交易回放</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted">
|
||||
{String(trade.entry_date).slice(0, 10)} 买入 → {String(trade.exit_date).slice(0, 10)} 卖出 · 持仓 {trade.duration ?? '—'} 天
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||
<div className="text-right">
|
||||
<div className="text-muted">买 / 卖</div>
|
||||
<div className="num text-foreground">{fmtPrice(trade.entry_price)} / {fmtPrice(trade.exit_price)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-muted">盈亏</div>
|
||||
<div className={`num font-semibold ${priceColorClass(trade.pnl_amount ?? trade.pnl_pct)}`}>
|
||||
{fmtSignedMoney(trade.pnl_amount)} / {fmtPct(trade.pnl_pct)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowIntraday((v) => !v)}
|
||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
|
||||
showIntraday
|
||||
? 'border border-accent/30 bg-accent/15 text-accent'
|
||||
: 'border border-border bg-elevated text-secondary hover:border-accent/30'
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
分时
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-btn p-1 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<StockPanel
|
||||
symbol={trade.symbol}
|
||||
height={520}
|
||||
dateRange={dateRange}
|
||||
ranges={ranges}
|
||||
priceLines={priceLines}
|
||||
showLimitMarkers={false}
|
||||
showMarkerToggle={false}
|
||||
showIntraday={showIntraday}
|
||||
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -32,9 +32,7 @@ interface NavEntry {
|
||||
|
||||
const BUILTIN_PAGES: NavEntry[] = [
|
||||
{ id: '/', label: '看板', type: 'builtin', visible: true },
|
||||
{ id: '/watchlist', label: '自选', type: 'builtin', visible: true },
|
||||
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
|
||||
{ id: '/backtest', label: '回测', type: 'builtin', visible: true },
|
||||
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
|
||||
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
|
||||
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import { Layout } from './components/Layout'
|
||||
import { Watchlist } from './pages/Watchlist'
|
||||
import { Screener } from './pages/Screener'
|
||||
import { Backtest } from './pages/Backtest'
|
||||
import { Financials } from './pages/Financials'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
import { Auth } from './pages/Auth'
|
||||
@@ -70,9 +68,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'industry-analysis', element: <IndustryAnalysis /> },
|
||||
{ path: 'stock-analysis', element: <StockAnalysis /> },
|
||||
{ path: 'review', element: <Review /> },
|
||||
{ path: 'watchlist', element: <Watchlist /> },
|
||||
{ path: 'screener', element: <Screener /> },
|
||||
{ path: 'backtest', element: <Backtest /> },
|
||||
{ path: 'financials', element: <Financials /> },
|
||||
{ path: 'data', element: <Data /> },
|
||||
{ path: 'monitor', element: <Monitor /> },
|
||||
|
||||
Reference in New Issue
Block a user