feat(frontend): 异动中心三tab、回测导出与载入复测、看板成分股弹窗与板块分时

- 异动监控重构为按交易时间线三tab: 竞价异动(风向标+当日/次日对照+追高标记,
  fuyao 未配置统一引导态)/盘中异动(信号筛选+连板/量比/振幅/换手表)/偏移异动
- 复盘页移除风向标卡片(AI 注入保留在后端)
- 回测结果导出 CSV(BOM, 概要/净值/交易明细/分标的统计四段)
- 候选方案「载入复测」: 弹窗→策略页传递, 23 项配置回填复跑
- 看板概念/行业排名行点击弹出成分股(dimensionKindForSourceField 判型),
  领涨股带涨跌幅与板块徽章
- 成分股弹窗新增板块等权分时: 双线(板块/全市场)+0%基线, 伪时间戳轴
  HH:MM 标签, 分钟未落盘引导态, 60s 轮询续期
This commit is contained in:
shy3130
2026-08-30 19:05:32 +08:00
parent fb7785ceb9
commit 75c2ab29f7
9 changed files with 1129 additions and 109 deletions
@@ -1,12 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Building2, ChevronRight, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
import { Link } from 'react-router-dom'
import {
createChart,
LineStyle,
type IChartApi,
type ISeriesApi,
type LineData,
type Time,
} from 'lightweight-charts'
import { Activity, Building2, ChevronRight, Database, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { boardTag } from '@/components/stock-table/primitives'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { api, type DimensionIntradayPoint, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { useChartTheme } from '@/lib/theme'
export type DimensionKind = 'concept' | 'industry'
@@ -218,6 +228,16 @@ function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit<P
<Summary label="平均涨跌" value={fmtPct(stats.average)} className={priceColorClass(stats.average)} />
</div>
{source && (
<DimensionIntradaySection
configId={source.configId}
field={source.field}
value={target.value}
date={target.date}
kind={target.kind}
/>
)}
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
@@ -300,3 +320,217 @@ function Summary({ label, value, className }: { label: string; value: string | n
</div>
)
}
// ---------------------------------------------------------------------------
// 板块分时 (等权): 点击触发 + 60s 轮询续期, 不预计算
// ---------------------------------------------------------------------------
const INTRADAY_SECTOR_COLOR: Record<DimensionKind, string> = {
concept: '#F97316',
industry: '#0EA5E9',
}
const INTRADAY_MARKET_COLOR = '#94A3B8'
// 横轴伪时间戳基点 (2020-01-01 UTC), 每点 +60s 保持均匀间距
const INTRADAY_BASE_TS = 1577836800
function lastNonNull(points: DimensionIntradayPoint[], key: 'sector' | 'market'): number | null {
for (let i = points.length - 1; i >= 0; i--) {
const value = points[i]?.[key]
if (value != null) return value
}
return null
}
function DimensionIntradaySection({ configId, field, value, date, kind }: {
configId: string
field: string
value: string
date?: string
kind: DimensionKind
}) {
const query = useQuery({
queryKey: QK.dimensionIntraday(configId, field, value, date),
queryFn: () => api.dimensionIntraday(configId, { field, value, date }),
staleTime: 15_000,
refetchInterval: 60_000,
})
const data = query.data
return (
<section className="shrink-0 border-b border-border bg-surface/30">
<div className="flex items-center gap-2 px-4 pt-2">
<Activity className="h-3 w-3 text-muted" />
<span className="text-[10px] font-medium text-muted"> · </span>
{data?.member_count != null && data.members_with_minute != null && (
<span className="rounded bg-elevated px-1 py-px font-mono text-[9px] text-muted" title="有当日分钟数据的成分股数">
{data.members_with_minute}/{data.member_count}
</span>
)}
{data?.basis && data.basis !== 'prev_close' && (
<span
className="rounded bg-amber-500/10 px-1 py-px text-[9px] text-amber-600 dark:text-amber-400"
title="前一交易日收盘缺失, 部分标的以当日首根分钟价为基准, 曲线起点约为 0"
>
基准:当日首价
</span>
)}
{data?.status === 'ok' && (
<div className="ml-auto flex items-center gap-2.5 font-mono text-[10px]">
<span className="inline-flex items-center gap-1">
<span className="h-[3px] w-3 rounded-full" style={{ background: INTRADAY_SECTOR_COLOR[kind] }} />
<span className="text-muted"></span>
<span className={priceColorClass(lastNonNull(data.points, 'sector'))}>
{fmtPct(lastNonNull(data.points, 'sector'))}
</span>
</span>
<span className="inline-flex items-center gap-1">
<span className="h-[3px] w-3 rounded-full" style={{ background: INTRADAY_MARKET_COLOR }} />
<span className="text-muted"></span>
<span className={priceColorClass(lastNonNull(data.points, 'market'))}>
{fmtPct(lastNonNull(data.points, 'market'))}
</span>
</span>
{data.date && <span className="text-muted">{data.date}</span>}
</div>
)}
</div>
{query.isLoading ? (
<div className="mx-4 mb-2 mt-1.5 h-[132px] animate-pulse rounded-md bg-elevated/50" />
) : query.isError ? (
<div className="mx-4 mb-2 mt-1 grid h-[72px] place-items-center rounded-md border border-dashed border-border px-4 text-center text-[11px] text-muted">
:{String((query.error as Error).message)}
</div>
) : data?.status === 'no_data' ? (
<div className="mx-4 mb-2 mt-1 flex h-[96px] flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border">
<Database className="h-4 w-4 text-muted" />
<p className="text-[11px] text-muted">, </p>
<p className="text-[10px] text-muted/70"> TickFlow Pro+ / Expert , </p>
<Link to="/data" className="text-[10px] text-accent hover:text-accent/80"> </Link>
</div>
) : !data || data.status === 'empty' || data.points.length < 2 ? (
<div className="grid h-[44px] place-items-center text-[11px] text-muted">
{data?.reason === 'no_member_bars' ? '成分股当日无分钟数据 (ETF 等标的无分钟落盘)' : '暂无成分股分时数据'}
</div>
) : (
<IntradayChart points={data.points} kind={kind} />
)}
</section>
)
}
function IntradayChart({ points, kind }: { points: DimensionIntradayPoint[]; kind: DimensionKind }) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const sectorRef = useRef<ISeriesApi<'Line'> | null>(null)
const marketRef = useRef<ISeriesApi<'Line'> | null>(null)
const ct = useChartTheme()
const ctRef = useRef(ct)
ctRef.current = ct
// v4 不支持字符串时间: 用均匀伪时间戳作横轴, 标签经 formatter 映射回 HH:MM
const labelsRef = useRef<string[]>([])
const labelAt = (time: number) => labelsRef.current[time - INTRADAY_BASE_TS] ?? ''
useEffect(() => {
const el = containerRef.current
if (!el) return
const chart = createChart(el, {
width: el.clientWidth,
height: 132,
layout: {
background: { color: 'transparent' },
textColor: ctRef.current.text,
fontFamily: 'JetBrains Mono, monospace',
fontSize: 10,
},
grid: {
vertLines: { color: ctRef.current.grid },
horzLines: { color: ctRef.current.grid },
},
rightPriceScale: { borderColor: ctRef.current.border, scaleMargins: { top: 0.12, bottom: 0.04 } },
timeScale: {
borderColor: ctRef.current.border,
rightOffset: 2,
barSpacing: 4,
tickMarkFormatter: (time: number) => labelAt(time),
},
localization: {
timeFormatter: (time: number) => labelAt(time),
},
crosshair: {
vertLine: { labelVisible: false },
horzLine: { labelVisible: true },
},
handleScroll: false,
handleScale: false,
})
const sector = chart.addLineSeries({
color: INTRADAY_SECTOR_COLOR[kind],
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: true,
priceFormat: { type: 'custom', formatter: (v: number) => `${(v * 100).toFixed(2)}%`, minMove: 0.0001 },
crosshairMarkerRadius: 3,
})
const market = chart.addLineSeries({
color: INTRADAY_MARKET_COLOR,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerRadius: 2,
})
sector.createPriceLine({
price: 0,
color: ctRef.current.border,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
axisLabelVisible: false,
})
chartRef.current = chart
sectorRef.current = sector
marketRef.current = market
const observer = new ResizeObserver(() => {
chart.applyOptions({ width: el.clientWidth })
})
observer.observe(el)
return () => {
observer.disconnect()
chart.remove()
chartRef.current = null
sectorRef.current = null
marketRef.current = null
}
}, [kind])
useEffect(() => {
chartRef.current?.applyOptions({
layout: { textColor: ct.text },
grid: { vertLines: { color: ct.grid }, horzLines: { color: ct.grid } },
rightPriceScale: { borderColor: ct.border },
timeScale: { borderColor: ct.border },
})
}, [ct])
useEffect(() => {
const sectorSeries = sectorRef.current
const marketSeries = marketRef.current
if (!sectorSeries || !marketSeries) return
labelsRef.current = points.map(p => p.time)
const toData = (key: 'sector' | 'market'): LineData[] =>
points
.map((p, i) => ({ time: (INTRADAY_BASE_TS + i * 60) as Time, value: p[key] }))
.filter((d): d is LineData => d.value != null)
sectorSeries.setData(toData('sector'))
marketSeries.setData(toData('market'))
chartRef.current?.timeScale().fitContent()
}, [points])
return (
<div className="px-2 pb-2 pt-1">
<div ref={containerRef} className="w-full" />
</div>
)
}
+78 -1
View File
@@ -380,6 +380,8 @@ export interface OverviewDimensionRankItem {
up_count: number
down_count: number
amount: number
/** 该维度组首个命中的扩展字段 "configId.field" (成分股弹窗直连; 无扩展源时缺失) */
source_field?: string | null
leader?: {
symbol?: string | null
name?: string | null
@@ -645,6 +647,27 @@ export interface DragonTigerPayload {
hot_money?: { trade_date?: string | null; count?: number | null; hot_money_items?: DragonTigerHotMoney[] }
}
// ===== 盘前风向标 (fuyao 专有, 复盘页) =====
export interface AuctionBenchmarkItem {
thscode: string
ticker?: string | null
name?: string | null
auction_pct?: number | null // 竞价涨跌幅 (百分数原值, 如 9.97 = +9.97%)
tags?: string[] // 同花顺概念标签
day0_oc?: number | null // 当日开盘买→收盘卖 (小数制, 服务端由本地日K enrich)
day0_pct?: number | null // 当日全天涨跌幅 (小数制)
d1_pct?: number | null // 次日收盘→收盘 (小数制; 最新交易日无次日为 null)
}
export interface AuctionBenchmarkPayload {
state: 'ok' | 'fallback_prev' | 'source_unavailable' | 'no_data'
requested_date?: string | null
trade_date?: string | null
count?: number
message?: string
items?: AuctionBenchmarkItem[]
}
// ===== Strategy Engine =====
export interface StrategyParamDef {
id: string
@@ -819,6 +842,28 @@ export interface AbnormalOverview {
rows: AbnormalRow[]
}
// ===== 盘中异动 (enriched 当日信号聚合, 异动监控「盘中」tab) =====
export type IntradaySignalKey = 'limit_up' | 'broken' | 'recovery' | 'limit_down'
| 'new_high' | 'new_low' | 'volume_surge'
export interface AbnormalIntradayRow {
symbol: string
name?: string | null
close?: number | null
change_pct?: number | null // 今日涨跌幅 (小数制)
amplitude?: number | null // 日振幅 (小数制)
vol_ratio_5d?: number | null // 5日量比
turnover_rate?: number | null // 换手率 (百分数原值)
consecutive_limit_ups?: number | null
signals: IntradaySignalKey[] // 命中信号 (按优先级排序)
}
export interface AbnormalIntradayPayload {
cache_date?: string | null
counts?: Partial<Record<IntradaySignalKey, number>>
rows?: AbnormalIntradayRow[]
}
export interface MonitorRule {
id: string
name: string
@@ -2521,6 +2566,12 @@ export const api = {
return request<DimensionMembersResult>(`/api/ext-data/${encodeURIComponent(id)}/dimension-members?${qs.toString()}`)
},
dimensionIntraday: (id: string, opts: { field: string; value: string; date?: string }) => {
const qs = new URLSearchParams({ field: opts.field, value: opts.value })
if (opts.date) qs.set('date', opts.date)
return request<DimensionIntradayResult>(`/api/ext-data/${encodeURIComponent(id)}/dimension-intraday?${qs.toString()}`)
},
analysisMenus: () =>
request<{ items: AnalysisMenu[] }>('/api/analysis-menus'),
@@ -2811,6 +2862,12 @@ export const api = {
`/api/market-recap/dragon-tiger${date ? `?date=${encodeURIComponent(date)}` : ''}`,
),
/** 盘前风向标 (fuyao 专有; 同花顺竞价筛选名单, 含当日/次日真实收益) */
auctionBenchmark: (date?: string) =>
request<AuctionBenchmarkPayload>(
`/api/market-recap/auction-benchmark${date ? `?date=${encodeURIComponent(date)}` : ''}`,
),
reviewReportSave: (r: {
as_of: string; focus?: string; content: string
summary?: string; emotion_score?: number | null; emotion_label?: string
@@ -2981,12 +3038,16 @@ export const api = {
body: JSON.stringify({ description }),
}),
// ===== Abnormal Moves (异动边缘) =====
// ===== Abnormal Moves (异动监控: 竞价/盘中/偏移) =====
abnormalOverview: (minCloseness = 0.5, limit = 200) =>
request<AbnormalOverview>(
`/api/abnormal/overview?min_closeness=${minCloseness}&limit=${limit}`,
),
/** 盘中异动: enriched 当日信号命中行 (涨停/炸板/翘板/跌停/新高/新低/放量) */
abnormalIntraday: (limit = 500) =>
request<AbnormalIntradayPayload>(`/api/abnormal/intraday?limit=${limit}`),
// ===== Monitor Rules (监控规则) =====
monitorRulesList: () =>
request<{ rules: MonitorRule[] }>('/api/monitor-rules'),
@@ -3336,6 +3397,22 @@ export interface DimensionMembersResult {
rows: Record<string, any>[]
}
export interface DimensionIntradayPoint {
time: string
sector: number | null
market: number | null
}
export interface DimensionIntradayResult {
status: 'ok' | 'no_data' | 'empty'
reason?: string | null
date?: string | null
basis?: 'prev_close' | 'first_close' | 'mixed' | null
member_count?: number
members_with_minute?: number
points: DimensionIntradayPoint[]
}
export interface AnalysisColumn {
field: string
label?: string
+3
View File
@@ -29,6 +29,8 @@ export const QK = {
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
// 异动边缘总览 (开启监控时才查询, 参数为 min_closeness/limit)
abnormalOverview: (minCloseness: number, limit: number) => ['abnormal-overview', minCloseness, limit] as const,
// 盘中异动信号聚合 (异动监控「盘中」tab)
abnormalIntraday: (limit: number) => ['abnormal-intraday', limit] as const,
// 不用 watchlist- 前缀: 日K历史盘中几乎不变, 若被 SSE quotes_updated 高频失效
// (expert 1s) 会导致全自选日K每秒重拉, staleTime 形同虚设。
// 刷新点: staleTime 过期 + Watchlist 增删自选/改蜡烛天数时的手动失效;
@@ -71,6 +73,7 @@ export const QK = {
extData: ['ext-data'] as const,
extDataRows: (id: string, date?: string, limit?: number, columns?: string) => ['ext-data-rows', id, date, limit, columns] as const,
dimensionMembers: (id: string, field: string, value: string, date?: string) => ['dimension-members', id, field, value, date] as const,
dimensionIntraday: (id: string, field: string, value: string, date?: string) => ['dimension-intraday', id, field, value, date] as const,
analysisMenus: ['analysis-menus'] as const,
analysisMenu: (id: string) => ['analysis-menu', id] as const,
+586 -87
View File
@@ -1,8 +1,15 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { FlaskConical, HelpCircle, History, Power, RefreshCw, Search, Settings2 } from 'lucide-react'
import { api, type AbnormalOverview, type AbnormalRow, type AbnormalStatus } from '@/lib/api'
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
import {
Activity, ChevronRight, Compass, FlaskConical, HelpCircle, History, Power,
Radar, RefreshCw, Ruler, Search, Settings2,
} from 'lucide-react'
import {
api, type AbnormalIntradayRow, type AbnormalOverview, type AbnormalRow,
type AbnormalStatus, type AuctionBenchmarkItem, type AuctionBenchmarkPayload,
type IntradaySignalKey,
} from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPrice, fmtPct, priceColorClass } from '@/lib/format'
@@ -11,13 +18,17 @@ import { PageHeader } from '@/components/PageHeader'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
/**
* 异动监控 — 按交易所异动规则口径 (3日±20%/±30%/±40%, 10日+100%, 30日+200%)
* 实时计算个股「偏离值/阈值」接近度, 找出处于异动边缘的标的。
* 异动监控 — 全时段异动中心, 按交易时间线分三个 tab:
*
* 计算量可控: 主开关默认关闭, 开启后才发起轮询 (每 60s 一次); 关闭后不再计算,
* 但保留展示上次计算结果 (含计算时间, 取自 localStorage)。
* 规则口径通过标题栏「?」展开查看。告警走系统监控体系: 在「监控中心」创建
* 异动监控规则后由后端持续评估, 统一触发记录/站内通知/飞书·企微推送
* - 竞价异动 (盘前 9:15-9:25): 同花顺短线风向标名单 + 全市场竞价扫描 (待采集任务)
* - 盘中异动 (盘中实时): enriched 当日信号聚合 — 涨停/炸板/翘板/跌停/新高/新低/放量
* - 偏移异动 (多日累计): 交易所异动规则口径 (3日±20%/30%/40%, 10日+100%, 30日+200%)
* 实时计算个股「偏离值/阈值」接近度, 找出处于异动边缘的标的
*
* 偏移异动计算量可控: 主开关默认关闭, 开启后才发起轮询 (每 60s 一次); 关闭后
* 保留展示上次计算结果 (含计算时间, 取自 localStorage)。规则口径通过工具栏「?」
* 展开查看。告警走系统监控体系: 在「监控中心」创建异动监控规则后由后端持续评估,
* 统一触发记录/站内通知/飞书·企微推送。
*/
const WINDOW_KEYS = ['3d', '10d', '30d'] as const
@@ -39,10 +50,527 @@ const BOARDS = ['主板', '创业板', '科创板', '北交所'] as const
const REFRESH_MS = 60_000
type AbnormalTab = 'auction' | 'intraday' | 'deviation'
const TAB_META: Array<{ key: AbnormalTab; label: string; icon: typeof Compass; desc: string }> = [
{ key: 'auction', label: '竞价异动', icon: Compass, desc: '盘前 9:15-9:25 · 同花顺风向标 + 竞价扫描' },
{ key: 'intraday', label: '盘中异动', icon: Activity, desc: '当日量价信号 · 涨停/炸板/翘板/新高新低/放量' },
{ key: 'deviation', label: '偏移异动', icon: Ruler, desc: '多日累计偏离值 · 交易所异动规则接近度' },
]
// ---- 盘中信号元数据 (标签 + 配色, 与后端 _INTRADAY_SIGNALS 优先级同序) ----
const SIGNAL_KEYS: IntradaySignalKey[] = ['limit_up', 'broken', 'recovery', 'limit_down', 'new_high', 'new_low', 'volume_surge']
const SIGNAL_META: Record<IntradaySignalKey, { label: string; cls: string }> = {
limit_up: { label: '涨停', cls: 'text-bull bg-bull/10 border-bull/25' },
broken: { label: '炸板', cls: 'text-orange-400 bg-orange-400/10 border-orange-400/25' },
recovery: { label: '翘板', cls: 'text-cyan-400 bg-cyan-400/10 border-cyan-400/25' },
limit_down: { label: '跌停', cls: 'text-bear bg-bear/10 border-bear/25' },
new_high: { label: '60日新高', cls: 'text-amber-400 bg-amber-400/10 border-amber-400/25' },
new_low: { label: '60日新低', cls: 'text-sky-400 bg-sky-400/10 border-sky-400/25' },
volume_surge: { label: '放量', cls: 'text-violet-400 bg-violet-400/10 border-violet-400/25' },
}
export function AbnormalMoves() {
const [tab, setTab] = useState<AbnormalTab>('intraday')
const [preview, setPreview] = useState<{ symbol: string; name: string } | null>(null)
return (
// 整页占满视口: 头部/tab固定, 只有各 tab 内容区滚动
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0">
<PageHeader
title="异动监控"
subtitle="竞价 · 盘中 · 偏移 · 全时段异动中心"
right={
<Link
to="/monitor"
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
title="在监控中心创建「异动监控」规则: 后台持续评估, 触发时统一走触发记录/站内通知/飞书·企微推送, 无需保持本页打开"
>
<Settings2 className="h-3 w-3" />
</Link>
}
/>
</div>
{/* tab 条: 交易时间线 竞价(盘前) → 盘中 → 偏移(多日) */}
<div className="flex shrink-0 flex-wrap items-center gap-3 px-5 pt-3">
<div className="inline-flex items-center gap-0.5 rounded-full border border-border/50 bg-base/70 p-0.5">
{TAB_META.map(t => {
const Icon = t.icon
const active = tab === t.key
return (
<button
key={t.key}
type="button"
aria-pressed={active}
onClick={() => setTab(t.key)}
className={`inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs transition-all ${
active
? 'bg-accent/15 font-medium text-accent shadow-sm'
: 'text-secondary hover:text-foreground'
}`}
>
<Icon className="h-3.5 w-3.5" />
{t.label}
</button>
)
})}
</div>
<span className="text-[10px] text-muted">{TAB_META.find(t => t.key === tab)?.desc}</span>
</div>
<div className="flex min-h-0 flex-1 flex-col px-5 pb-4 pt-3">
{tab === 'auction' && (
<AuctionView onOpenStock={(s, n) => setPreview({ symbol: s, name: n ?? s })} />
)}
{tab === 'intraday' && (
<IntradayView onPreview={r => setPreview({ symbol: r.symbol, name: r.name ?? r.symbol })} />
)}
{tab === 'deviation' && (
<DeviationView onPreview={r => setPreview({ symbol: r.symbol, name: r.name ?? r.symbol })} />
)}
</div>
{preview && (
<StockPreviewDialog
symbol={preview.symbol}
name={preview.name}
onClose={() => setPreview(null)}
/>
)}
</div>
)
}
// ================================================================
// 竞价异动 tab
// ================================================================
/** 追高风险阈值: 60日回测高开≥5%子集当日开盘买 -1.97% (温和高开才是名单 alpha 来源) */
const _BENCH_CHASE_RISK_PCT = 5
function AuctionView({ onOpenStock }: {
onOpenStock: (symbol: string, name?: string | null) => void
}) {
const q = useQuery({
queryKey: ['auction-benchmark', 'latest'],
queryFn: () => api.auctionBenchmark(),
staleTime: 5 * 60_000,
retry: 1,
})
// fuyao 未配置: 整个 tab 的统一引导态 (风向标与全市场扫描都依赖 fuyao),
// 不再展示零散的降级卡/占位卡 — 与偏移 tab「监控未开启」空态同款式
if (q.data?.state === 'source_unavailable') {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div className="m-auto rounded-card border border-border bg-surface p-8 text-center">
<span className="mx-auto grid h-10 w-10 place-items-center rounded-full bg-cyan-500/10 text-cyan-500 ring-1 ring-cyan-500/20">
<Compass className="h-5 w-5" />
</span>
<div className="mt-3 text-sm font-medium text-foreground"></div>
<p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-muted">
() fuyao ,
fuyao API Key 使
</p>
<Link
to="/settings?tab=data-sources"
className="mt-5 inline-flex h-9 items-center gap-2 rounded-btn bg-accent px-4 text-xs font-medium text-base transition-colors hover:bg-accent/90"
>
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
)
}
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 overflow-y-auto">
<BenchmarkCard q={q} onOpenStock={onOpenStock} />
{/* 全市场竞价扫描: 采集任务启用后填充 (接口与批量能力已验证) */}
<div className="rounded-card border border-dashed border-border bg-surface/50 px-4 py-4">
<div className="flex items-start gap-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded bg-elevated/60">
<Radar className="h-4 w-4 text-muted/50" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground"></span>
<span className="rounded-full border border-border bg-elevated px-2 py-px text-[9px] leading-tight text-muted">
</span>
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-muted">
9:25 ( 5547 2 ), 5% 10
,
</p>
</div>
</div>
</div>
<p className="px-1 text-[10px] leading-relaxed text-muted/70">
( 5~6 )60 日回测: 名单当日开盘买入均值 +0.54%
( +0.44%), 5% -1.97% , ,
</p>
</div>
)
}
function BenchmarkCard({ q, onOpenStock }: {
q: UseQueryResult<AuctionBenchmarkPayload, Error>
onOpenStock: (symbol: string, name?: string | null) => void
}) {
const d = q.data
if (q.isLoading) {
return (
<div className="flex items-center gap-3 rounded-card border border-border bg-surface/80 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 animate-pulse place-items-center rounded bg-elevated">
<Compass className="h-4 w-4 text-muted/50" />
</span>
<div className="flex items-center gap-2">
<span className="h-3 w-14 animate-pulse rounded-full bg-elevated/80" />
<span className="h-3 w-24 animate-pulse rounded-full bg-elevated/60" />
<span className="h-3 w-20 animate-pulse rounded-full bg-elevated/40" />
</div>
</div>
)
}
// source_unavailable (fuyao 未配置) 由 AuctionView 统一引导态处理, 此处不再分支
if (!d || d.state === 'no_data') {
return (
<div className="flex items-center gap-3 rounded-card border border-border bg-surface/50 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded bg-elevated/60">
<Compass className="h-4 w-4 text-muted/50" />
</span>
<span className="text-[11px] text-muted">{d?.message ? ` (${d.message.slice(0, 40)})` : ''}</span>
<button onClick={() => q.refetch()} className="ml-auto text-[10px] text-accent hover:underline"></button>
</div>
)
}
const items = d.items ?? []
const isFallback = d.state === 'fallback_prev'
const ocs = items.map(i => i.day0_oc).filter((v): v is number => v != null)
const avgOc = ocs.length ? ocs.reduce((a, b) => a + b, 0) / ocs.length : null
return (
<div className="rounded-card border border-border bg-surface/80">
{/* 头部 */}
<div className="flex items-center gap-3 px-4 py-3">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded bg-cyan-500/15 text-cyan-500 ring-1 ring-cyan-500/20">
<Compass className="h-4 w-4" />
</span>
<span className="leading-tight">
<span className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-foreground"></span>
{isFallback && (
<span
className="rounded border border-warning/30 bg-warning/10 px-1.5 py-px text-[9px] leading-tight text-warning"
title="目标日名单不可用, 已自动显示上一期"
>
</span>
)}
</span>
<span className="mt-0.5 block text-[10px] text-muted">
{d.trade_date} · {items.length} ·
{avgOc != null && (
<> · <span className={priceColorClass(avgOc)}>{fmtPct(avgOc)}</span></>
)}
</span>
</span>
<span className="ml-auto text-right text-[9px] leading-tight text-muted/70">
<br />
</span>
</div>
{/* 名单 */}
{items.length === 0 ? (
<p className="border-t border-border/60 px-4 py-3 text-center text-[11px] text-muted"></p>
) : (
<div>
<div className="flex items-center gap-2 border-t border-border/60 bg-elevated/50 px-4 py-1.5 text-[9px] font-medium uppercase tracking-wider text-muted/70">
<span className="w-14 shrink-0"></span>
<span className="min-w-0 flex-1"></span>
<span className="w-16 shrink-0 text-right"></span>
<span className="w-16 shrink-0 text-right"></span>
</div>
{items.map((i: AuctionBenchmarkItem) => {
const gap = i.auction_pct ?? null
const chase = (gap ?? 0) >= _BENCH_CHASE_RISK_PCT
return (
<button
key={i.thscode}
type="button"
onClick={() => onOpenStock(i.thscode, i.name)}
className="flex w-full items-center gap-2 border-t border-border/30 px-4 py-2 text-left text-[11px] transition-colors hover:bg-accent/[0.05]"
title={`查看 ${i.name ?? i.thscode} 详情 · 竞价 ${gap ?? '—'}%`}
>
<span className={('w-14 shrink-0 font-mono tabular-nums ' + priceColorClass(gap)).trim()}>
{gap == null ? '—' : fmtPct(gap / 100)}
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate text-foreground">{i.name ?? i.thscode}</span>
<span className="shrink-0 font-mono text-[9px] text-muted">{i.ticker ?? i.thscode}</span>
{(() => { const b = boardTag(i.thscode); return b && (
<span className={`shrink-0 inline-flex items-center rounded border px-1 text-[8px] font-bold leading-tight ${b.color}`}>
{b.label}
</span>
) })()}
{chase && (
<span
className="shrink-0 rounded border border-danger/30 bg-danger/10 px-1 text-[8px] font-medium leading-tight text-danger"
title="60日回测: 高开≥5%子集当日开盘买入平均 -1.97% (次日 +1.79%) — 追高陷阱"
>
</span>
)}
{(i.tags ?? []).slice(0, 2).map(t => (
<span key={t} className="max-w-24 truncate rounded-full bg-base/70 px-1.5 py-px text-[9px] text-muted" title={t}>
{t}
</span>
))}
</span>
<span
className={('w-16 shrink-0 text-right font-mono tabular-nums ' + priceColorClass(i.day0_oc)).trim()}
title={i.day0_pct != null ? `全天 ${fmtPct(i.day0_pct)}` : undefined}
>
{i.day0_oc == null ? '—' : fmtPct(i.day0_oc)}
</span>
<span className={('w-16 shrink-0 text-right font-mono tabular-nums ' + (i.d1_pct == null ? 'text-muted' : priceColorClass(i.d1_pct))).trim()}>
{i.d1_pct == null ? '—' : fmtPct(i.d1_pct)}
</span>
</button>
)
})}
</div>
)}
</div>
)
}
// ================================================================
// 盘中异动 tab
// ================================================================
function IntradayView({ onPreview }: {
onPreview: (r: AbnormalIntradayRow) => void
}) {
const [sigFilter, setSigFilter] = useState<'all' | IntradaySignalKey>('all')
const [boardFilter, setBoardFilter] = useState<'all' | (typeof BOARDS)[number]>('all')
const [query, setQuery] = useState('')
const [excludeSt, setExcludeSt] = useState(true)
const q = useQuery({
queryKey: QK.abnormalIntraday(500),
queryFn: () => api.abnormalIntraday(500),
refetchInterval: REFRESH_MS,
})
const data = q.data
const counts = data?.counts ?? {}
const rows = useMemo(() => {
let list = data?.rows ?? []
if (sigFilter !== 'all') list = list.filter(r => r.signals.includes(sigFilter))
if (boardFilter !== 'all') {
// boardTag: 创/科/北有徽章, 主板返回 null
const want = boardFilter === '主板' ? null
: boardFilter === '创业板' ? '创'
: boardFilter === '科创板' ? '科' : '北'
list = list.filter(r => (boardTag(r.symbol)?.label ?? null) === want)
}
if (excludeSt) list = list.filter(r => !(r.name ?? '').toUpperCase().includes('ST'))
const s = query.trim().toLowerCase()
if (s) list = list.filter(r => `${r.symbol} ${r.name ?? ''}`.toLowerCase().includes(s))
return list
}, [data, sigFilter, boardFilter, excludeSt, query])
const total = (data?.rows ?? []).length
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{/* 信号筛选 chips (带各类型计数) + 工具行 */}
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
<SigChip active={sigFilter === 'all'} onClick={() => setSigFilter('all')} label="全部" count={total} />
{SIGNAL_KEYS.map(k => (
<SigChip
key={k}
active={sigFilter === k}
onClick={() => setSigFilter(k)}
label={SIGNAL_META[k].label}
count={counts[k] ?? 0}
cls={SIGNAL_META[k].cls}
/>
))}
<span className="ml-1 text-[10px] text-muted">
{data?.cache_date ?? '—'}
{q.isFetching && ' · 更新中…'}
</span>
<div className="ml-auto flex items-center gap-2">
<SegmentedControl
value={boardFilter}
onChange={v => setBoardFilter(v)}
options={[
{ value: 'all' as const, label: '全板块' },
...BOARDS.map(b => ({ value: b, label: b })),
]}
/>
<button
type="button"
onClick={() => q.refetch()}
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
title="立即刷新"
>
<RefreshCw className={`h-3 w-3 ${q.isFetching ? 'animate-spin' : ''}`} />
</button>
<label className="flex items-center gap-1.5 text-[11px] text-secondary" title="过滤 ST/*ST 风险警示股票">
<input type="checkbox" checked={excludeSt} onChange={e => setExcludeSt(e.target.checked)} className="h-3 w-3 accent-accent" />
ST
</label>
<div className="relative">
<Search className="absolute left-2 top-1.5 h-3.5 w-3.5 text-muted" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索代码/名称"
className="h-7 w-40 rounded border border-border bg-base pl-7 pr-2 text-[11px] text-foreground"
/>
</div>
</div>
</div>
{/* 主表 */}
<div className="min-h-0 flex-1 overflow-auto rounded-card border border-border bg-surface">
<table className="w-full min-w-[900px] text-xs">
<thead className="sticky top-0 z-10 bg-surface">
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted">
<th className="w-10 px-2 py-2 text-right">#</th>
<th className="px-2 py-2 text-left"> / </th>
<th className="px-2 py-2 text-right"></th>
<th className="px-2 py-2 text-right"></th>
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-right" title="当日成交量 / 前5日平均成交量"></th>
<th className="px-2 py-2 text-right" title="当日高低价差 / 前收盘价"></th>
<th className="px-2 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{q.isLoading ? (
<tr><td colSpan={8} className="px-3 py-10 text-center text-muted"></td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={8} className="px-3 py-10 text-center text-muted">{data ? '当前筛选下没有命中标的' : '暂无数据'}</td></tr>
) : (
rows.map((r, i) => (
<IntradayRowView key={r.symbol} row={r} rank={i + 1} onPreview={() => onPreview(r)} />
))
)}
</tbody>
</table>
</div>
</div>
)
}
function SigChip({ active, onClick, label, count, cls }: {
active: boolean
onClick: () => void
label: string
count: number
cls?: string
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
active
? (cls ?? 'border-accent/40 bg-accent/12 text-accent')
: 'border-border bg-elevated text-secondary hover:text-foreground'
}`}
>
<span className="font-mono text-xs font-semibold tabular-nums">{count}</span>
{label}
</button>
)
}
function IntradayRowView({ row, rank, onPreview }: {
row: AbnormalIntradayRow
rank: number
onPreview: () => void
}) {
const board = boardTag(row.symbol)
const clu = row.consecutive_limit_ups ?? 0
return (
<tr className="group border-b border-border/40 transition-colors last:border-0 hover:bg-elevated/50">
<td className="px-2 py-1.5 text-right font-mono text-[10px] text-muted/70">{rank}</td>
<td className="px-2 py-1.5">
<button
type="button"
onClick={onPreview}
title="查看个股详情"
className="flex min-w-0 items-center gap-1.5 text-left"
>
<span className="shrink-0 font-mono text-xs text-foreground transition-colors duration-150 group-hover:text-accent">{row.symbol}</span>
<span className="min-w-0 max-w-40 truncate text-xs text-secondary transition-colors duration-150 group-hover:text-foreground">{row.name ?? '—'}</span>
{board && (
<span className={`shrink-0 rounded border px-1 text-[9px] font-bold leading-tight ${board.color}`}>
{board.label}
</span>
)}
{clu > 1 && (
<span className="shrink-0 rounded border border-amber-500/30 bg-amber-500/10 px-1 text-[9px] font-bold leading-tight text-amber-500" title={`连续 ${clu} 日涨停`}>
{clu}
</span>
)}
</button>
</td>
<td className="px-2 py-1.5 text-right font-mono text-xs text-secondary">{fmtPrice(row.close)}</td>
<td className={`px-2 py-1.5 text-right font-mono text-xs font-medium ${priceColorClass(row.change_pct)}`}>
{fmtPct(row.change_pct)}
</td>
<td className="px-2 py-1.5">
<div className="flex flex-wrap items-center gap-1">
{row.signals.map(s => (
<span key={s} className={`rounded border px-1 text-[9px] font-medium leading-tight ${SIGNAL_META[s].cls}`}>
{SIGNAL_META[s].label}
</span>
))}
</div>
</td>
<td className="px-2 py-1.5 text-right font-mono text-xs tabular-nums text-secondary">
{row.vol_ratio_5d != null ? row.vol_ratio_5d.toFixed(2) : '—'}
</td>
<td className="px-2 py-1.5 text-right font-mono text-xs tabular-nums text-secondary">
{row.amplitude != null ? fmtPct(row.amplitude, 2) : '—'}
</td>
<td className="px-2 py-1.5 text-right font-mono text-xs tabular-nums text-secondary">
{row.turnover_rate != null ? `${Number(row.turnover_rate).toFixed(2)}%` : '—'}
</td>
</tr>
)
}
// ================================================================
// 偏移异动 tab (原有异动边缘监控, 逻辑保持不变)
// ================================================================
function DeviationView({ onPreview }: {
onPreview: (r: AbnormalRow) => void
}) {
// 主开关: 默认关闭, 开启后才轮询计算 (仅控制本页计算, 后台告警由监控规则驱动)
const [enabled, setEnabled] = useState(() => storage.abnormalEnabled.get(false))
// 规则口径面板 (标题栏「?」)
// 规则口径面板 (工具栏「?」)
const [rulesOpen, setRulesOpen] = useState(false)
// 上次计算结果: 开启时每次成功计算都落本地, 关闭后仍展示
const [lastResult, setLastResult] = useState<AbnormalOverview | null>(
@@ -56,7 +584,6 @@ export function AbnormalMoves() {
const [watchlistOnly, setWatchlistOnly] = useState(false)
// 默认过滤 ST/*ST 风险警示股票 (口径与后端 is_st_name 一致: 名称含 ST)
const [excludeSt, setExcludeSt] = useState(true)
const [preview, setPreview] = useState<{ symbol: string; name: string } | null>(null)
const overview = useQuery({
queryKey: QK.abnormalOverview(minCloseness, 300),
@@ -124,70 +651,8 @@ export function AbnormalMoves() {
const updating = overview.isFetching
return (
// 整页占满视口: 头部/筛选固定, 只有表格列表区滚动
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0">
<PageHeader
title="异动监控"
subtitle="3日异常波动 / 10日·30日严重异常波动 · 偏离值接近度"
right={
<div className="flex items-center gap-2">
<button
type="button"
aria-pressed={rulesOpen}
aria-label="查看异动规则口径"
title="交易所异动规则口径 (阈值 / 偏离值计算方式)"
onClick={() => setRulesOpen(v => !v)}
className={`inline-flex h-7 w-7 items-center justify-center rounded border transition-colors ${
rulesOpen
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-base text-secondary hover:text-foreground'
}`}
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
{enabled && (
<button
type="button"
onClick={() => overview.refetch()}
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
title="立即刷新"
>
<RefreshCw className={`h-3 w-3 ${updating ? 'animate-spin' : ''}`} />
</button>
)}
<Link
to="/monitor"
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
title="在监控中心创建「异动监控」规则: 后台持续评估, 触发时统一走触发记录/站内通知/飞书·企微推送, 无需保持本页打开"
>
<Settings2 className="h-3 w-3" />
</Link>
{/* 主开关: 开启后才开始轮询计算 */}
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="启用异动监控计算"
onClick={() => toggleEnabled(!enabled)}
className={`inline-flex h-7 items-center gap-2 rounded border px-2.5 text-[11px] font-medium transition-colors ${
enabled
? 'border-accent/40 bg-accent/12 text-accent'
: 'border-border bg-base text-secondary hover:text-foreground'
}`}
>
<Power className="h-3 w-3" />
{enabled ? '监控中 · 每60秒计算' : '开启监控'}
</button>
</div>
}
/>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 px-5 py-4">
{/* 规则口径面板 (标题栏「?」展开) */}
<div className="flex min-h-0 flex-1 flex-col gap-3">
{/* 规则口径面板 (工具栏「?」展开) */}
{rulesOpen && (
<div className="shrink-0 rounded-card border border-border bg-surface p-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -252,7 +717,7 @@ export function AbnormalMoves() {
</div>
)}
{/* 统计 + 筛选 */}
{/* 统计 + 控制 */}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<StatusChip label="已触发" count={counts?.triggered} tone="danger" />
<StatusChip label="异动边缘" count={counts?.edge} tone="warning" />
@@ -264,6 +729,49 @@ export function AbnormalMoves() {
{data ? ` · 基准指数今日 ${(data.bench_rt_pct * 100).toFixed(2)}%` : ''}
</span>
)}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
aria-pressed={rulesOpen}
aria-label="查看异动规则口径"
title="交易所异动规则口径 (阈值 / 偏离值计算方式)"
onClick={() => setRulesOpen(v => !v)}
className={`inline-flex h-7 w-7 items-center justify-center rounded border transition-colors ${
rulesOpen
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-base text-secondary hover:text-foreground'
}`}
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
{enabled && (
<button
type="button"
onClick={() => overview.refetch()}
className="inline-flex h-7 items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-secondary transition-colors hover:text-foreground"
title="立即刷新"
>
<RefreshCw className={`h-3 w-3 ${updating ? 'animate-spin' : ''}`} />
</button>
)}
{/* 主开关: 开启后才开始轮询计算 */}
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="启用异动监控计算"
onClick={() => toggleEnabled(!enabled)}
className={`inline-flex h-7 items-center gap-2 rounded border px-2.5 text-[11px] font-medium transition-colors ${
enabled
? 'border-accent/40 bg-accent/12 text-accent'
: 'border-border bg-base text-secondary hover:text-foreground'
}`}
>
<Power className="h-3 w-3" />
{enabled ? '监控中 · 每60秒计算' : '开启监控'}
</button>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
@@ -377,7 +885,7 @@ export function AbnormalMoves() {
key={r.symbol}
row={r}
rank={i + 1}
onPreview={() => setPreview({ symbol: r.symbol, name: r.name ?? r.symbol })}
onPreview={() => onPreview(r)}
/>
))
)}
@@ -386,15 +894,6 @@ export function AbnormalMoves() {
</div>
</>
)}
</div>
{preview && (
<StockPreviewDialog
symbol={preview.symbol}
name={preview.name}
onClose={() => setPreview(null)}
/>
)}
</div>
)
@@ -508,7 +1007,7 @@ function AbnormalRowView({ row, rank, onPreview }: {
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-elevated">
<div
className={`h-full rounded-full transition-all ${meta.bar}`}
style={{ width: `${Math.min(100, (dominant?.closeness ?? 0) * 100)}%}` }}
style={{ width: `${Math.min(100, (dominant?.closeness ?? 0) * 100)}%` }}
/>
</div>
<span className="font-mono text-[10px] tabular-nums text-secondary">
+19 -2
View File
@@ -6,6 +6,7 @@ import { FactorDiscovery } from './backtest/FactorDiscovery'
import { ResearchCandidatesDialog } from './backtest/ResearchCandidatesDialog'
import { RobustnessValidation } from './backtest/RobustnessValidation'
import { StrategyBacktest } from './backtest/StrategyBacktest'
import { type ResearchCandidate } from '@/lib/api'
type Tab = 'factor' | 'strategy' | 'robustness'
@@ -31,6 +32,8 @@ export function Backtest() {
const [searchParams, setSearchParams] = useSearchParams()
const requestedTab = searchParams.get('tab')
const [candidatesOpen, setCandidatesOpen] = useState(false)
// 候选「载入复测」: 弹窗选定 → 关闭弹窗切到策略页 → StrategyBacktest 消费后清空
const [pendingLoad, setPendingLoad] = useState<ResearchCandidate | null>(null)
// 旧链接兼容: 挖掘已升级为一级路由 /mining, 保留 run/candidate 参数重定向
if (requestedTab === 'mining') {
@@ -99,11 +102,25 @@ export function Backtest() {
<main className="min-h-0 flex-1 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
{activeTab === 'factor' && <FactorDiscovery />}
{activeTab === 'strategy' && <StrategyBacktest />}
{activeTab === 'strategy' && (
<StrategyBacktest
loadCandidate={pendingLoad}
onLoadConsumed={() => setPendingLoad(null)}
/>
)}
{activeTab === 'robustness' && <RobustnessValidation />}
</main>
{candidatesOpen && <ResearchCandidatesDialog onClose={() => setCandidatesOpen(false)} />}
{candidatesOpen && (
<ResearchCandidatesDialog
onClose={() => setCandidatesOpen(false)}
onLoadStrategy={candidate => {
setPendingLoad(candidate)
setCandidatesOpen(false)
if (activeTab !== 'strategy') changeTab('strategy')
}}
/>
)}
</div>
)
}
+53 -13
View File
@@ -7,6 +7,7 @@ import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct } from '@/lib/format'
import { DimensionMembersDialog, dimensionKindForSourceField, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
@@ -465,18 +466,36 @@ function StockList({ title, rows, mode, onStockClick }: {
)
}
function RankColumn({ title, rows, tone, onStockClick }: {
function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
title: string; rows: OverviewDimensionRankItem[]; tone: 'bull' | 'bear';
onStockClick?: (symbol: string, name?: string) => void;
onStockClick?: (symbol: string, name?: string) => void
onDimensionClick?: (target: DimensionMembersTarget) => void
}) {
return (
<div className="min-w-0 space-y-1">
<div className={`text-[10px] font-medium ${tone === 'bull' ? 'text-bull' : 'text-bear'}`}>{title}</div>
{rows.slice(0, 5).map((r, idx) => (
<div key={`${title}-${r.name}-${idx}`} className="grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded-md bg-elevated/40 px-1.5 py-1 border border-transparent hover:border-border/60 transition-colors">
{rows.slice(0, 5).map((r, idx) => {
const kind = r.source_field ? dimensionKindForSourceField(r.source_field) : null
const clickable = !!(r.source_field && kind && onDimensionClick)
return (
<div
key={`${title}-${r.name}-${idx}`}
onClick={() => clickable && onDimensionClick!({
kind: kind!,
value: r.name,
sourceField: r.source_field!,
})}
title={clickable ? `查看「${r.name}」成分股` : undefined}
className={`grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded-md bg-elevated/40 px-1.5 py-1 border border-transparent transition-colors ${
clickable ? 'cursor-pointer hover:border-accent/40 hover:bg-elevated/70' : 'hover:border-border/60'
}`}
>
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
<div className="min-w-0">
<div className="truncate text-[11px] text-foreground" title={r.name}>{r.name}</div>
<div className="truncate text-[11px] text-foreground" title={r.name}>
{r.name}
{clickable && <span className="ml-1 text-[8px] text-muted/50"></span>}
</div>
<div className="mt-0.5 flex items-center gap-1">
<span className="shrink-0 font-mono text-[9px] text-muted">{r.count}</span>
<span className="text-muted">·</span>
@@ -489,6 +508,11 @@ function RankColumn({ title, rows, tone, onStockClick }: {
) : (
<span className="truncate text-[10px] text-muted">{r.leader?.name ?? '—'}</span>
)}
{r.leader?.change_pct != null && (
<span className={`shrink-0 font-mono text-[9px] tabular-nums ${pctClass(r.leader.change_pct)}`}>
{fmtStockPct(r.leader.change_pct)}
</span>
)}
{r.leader?.symbol && (() => {
const board = boardTag(r.leader!.symbol!)
return board ? (
@@ -501,24 +525,26 @@ function RankColumn({ title, rows, tone, onStockClick }: {
</div>
<div className={`font-mono text-[10px] font-semibold ${pctClass(r.avg_pct)}`}>{fmtStockPct(r.avg_pct)}</div>
</div>
))}
)
})}
{rows.length === 0 && <div className="rounded border border-dashed border-border py-4 text-center text-xs text-muted"></div>}
</div>
)
}
function HotRankCard({ title, rank, configUrl, onStockClick }: {
function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }: {
title: string; rank?: OverviewMarket['concept_rank']; configUrl: string;
onStockClick?: (symbol: string, name?: string) => void;
onStockClick?: (symbol: string, name?: string) => void
onDimensionClick?: (target: DimensionMembersTarget) => void
}) {
const hasData = (rank?.leading?.length ?? 0) > 0 || (rank?.lagging?.length ?? 0) > 0
return (
<section className="rounded-card border border-border bg-surface/80 p-1.5 shadow-[0_1px_2px_hsl(var(--border)/0.4)] backdrop-blur-sm transition-shadow hover:shadow-[0_2px_8px_hsl(var(--border)/0.5)]">
<SectionTitle icon={Flame} title={title} hint="领涨/领跌" />
<SectionTitle icon={Flame} title={title} hint="领涨/领跌 · 点击板块看成分股" />
{hasData ? (
<div className="grid grid-cols-2 gap-2">
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" onStockClick={onStockClick} />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" onStockClick={onStockClick} />
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" onStockClick={onStockClick} onDimensionClick={onDimensionClick} />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" onStockClick={onStockClick} onDimensionClick={onDimensionClick} />
</div>
) : (
<div className="py-4 text-center">
@@ -540,6 +566,8 @@ export function Dashboard() {
const [selectedDate, setSelectedDate] = useState<string | undefined>()
const [manualFetching, setManualFetching] = useState(false)
const [previewStock, setPreviewStock] = useState<{symbol: string; name?: string; alert?: AlertEvent} | null>(null)
// 板块成分股弹窗 (概念/行业热度卡片行点击)
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
// 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次
const [showWelcomeModal, setShowWelcomeModal] = useState(false)
const dataStatus = useDataStatus({ staleTime: 60_000 })
@@ -820,8 +848,12 @@ export function Dashboard() {
</div>
<div className="grid grid-cols-1 gap-1.5 md:grid-cols-2">
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis"
onStockClick={(symbol, name) => setPreviewStock({symbol, name})}
onDimensionClick={setDimensionTarget} />
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis"
onStockClick={(symbol, name) => setPreviewStock({symbol, name})}
onDimensionClick={setDimensionTarget} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-4">
@@ -867,6 +899,14 @@ export function Dashboard() {
} : null}
onClose={() => setPreviewStock(null)}
/>
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
setDimensionTarget(null)
setPreviewStock({ symbol, name })
}}
/>
</div>
)
}
+3
View File
@@ -308,6 +308,7 @@ export function Review() {
{/* ===== 市场摘要条(轻量上下文,非重复看板)===== */}
<MarketSummaryBar data={data} />
{/* ===== 龙虎榜 (fuyao 专有, 资金动向上下文; 复盘日联动) ===== */}
<DragonTigerCard date={dtDate} onOpenStock={setPreviewSymbol} />
@@ -1135,6 +1136,8 @@ function _DtSeatList({ seats, onOpenStock }: {
)
}
/** 追高风险阈值: 60日回测高开≥5%子集当日开盘买 -1.97% (温和高开才是名单 alpha 来源) */
function DragonTigerCard({ date, onOpenStock }: {
date?: string
onOpenStock: (symbol: string) => void
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowDown, ArrowUp, BookmarkCheck, CheckCircle2, Clock3, Link2, Loader2, Trash2, X, XCircle } from 'lucide-react'
import { ArrowDown, ArrowUp, BookmarkCheck, CheckCircle2, Clock3, Link2, Loader2, RotateCcw, Trash2, X, XCircle } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { toast } from '@/components/Toast'
import { api, type ResearchCandidate, type ResearchCandidateStatus, type ScoringDirection } from '@/lib/api'
@@ -41,7 +41,11 @@ function metricSummary(item: ResearchCandidate) {
].filter(Boolean).join(' · ') || '暂无指标摘要'
}
export function ResearchCandidatesDialog({ onClose }: { onClose: () => void }) {
export function ResearchCandidatesDialog({ onClose, onLoadStrategy }: {
onClose: () => void
/** 策略候选「载入复测」: 把保存的 config 回填到回测表单 (由回测页接线) */
onLoadStrategy?: (candidate: ResearchCandidate) => void
}) {
const queryClient = useQueryClient()
const [kind, setKind] = useState<'all' | 'factor' | 'strategy'>('all')
const [linkDraft, setLinkDraft] = useState<LinkDraft | null>(null)
@@ -219,6 +223,17 @@ export function ResearchCandidatesDialog({ onClose }: { onClose: () => void }) {
))}
</select>
<div className="flex items-center justify-end gap-1">
{item.kind === 'strategy' && onLoadStrategy && (
<button
type="button"
onClick={() => onLoadStrategy(item)}
className="inline-flex h-8 items-center gap-1.5 rounded-btn px-2 text-[11px] text-accent transition-colors hover:bg-accent/10"
title="把此候选保存的回测配置 (策略/区间/参数/费率/仓位/环境过滤) 回填到回测表单, 可直接复测"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{item.kind === 'factor' && (
<button
type="button"
@@ -1,10 +1,11 @@
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers, BookmarkPlus } from 'lucide-react'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers, BookmarkPlus, Download } from 'lucide-react'
import {
api,
type StrategyBacktestResult,
type ResearchCandidate,
type StrategyBacktestTrade,
type StrategyDetail,
type StrategyParamDef,
@@ -525,6 +526,12 @@ function fmtDuration(ms: number): string {
return `${m}${rest}`
}
/** CSV 字段转义: 含逗号/引号/换行的字段加引号并翻倍内部引号 */
function csvEsc(v: string | number | null | undefined): string {
const s = v == null ? '' : String(v)
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
const METRIC_HELP = {
avgReturn: {
title: '平均收益',
@@ -905,7 +912,11 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri
)
}
export function StrategyBacktest() {
export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
/** 候选方案「载入复测」: 回填保存的回测配置 (消费后由父组件清空) */
loadCandidate?: ResearchCandidate | null
onLoadConsumed?: () => void
}) {
const queryClient = useQueryClient()
const signalNames = useSignalNames()
const [saved] = useState(() => storage.strategyBacktestLast.get(null))
@@ -954,6 +965,48 @@ export function StrategyBacktest() {
// 跨会话/拉新代码后自动渲染一个可能对应已失效策略的旧结果会造成困惑
// (切页不卸载组件,内存中的 result 仍保留,无需靠 localStorage 恢复)。
const [result, setResult] = useState<StrategyBacktestResult | null>(null)
// 候选方案「载入复测」: 把保存的 23 项回测配置回填到表单 (字段缺失时保留当前值)
useEffect(() => {
if (!loadCandidate) return
const cfg = (loadCandidate.config ?? {}) as Record<string, any>
if (cfg.strategy_id) setSelectedStrategy(String(cfg.strategy_id))
if (cfg.asset_type === 'stock' || cfg.asset_type === 'etf') setAssetType(cfg.asset_type)
if (cfg.symbols != null) {
setSymbols(Array.isArray(cfg.symbols) ? cfg.symbols.join(',') : String(cfg.symbols))
}
if (cfg.start) setStart(String(cfg.start).slice(0, 10))
if (cfg.end) setEnd(String(cfg.end).slice(0, 10))
if (cfg.entry_fill === 'close_t' || cfg.entry_fill === 'open_t+1') setEntryFill(cfg.entry_fill)
if (cfg.exit_fill === 'close_t' || cfg.exit_fill === 'open_t+1' || cfg.exit_fill === 'signal_next_minute') {
setExitFill(cfg.exit_fill)
}
if (cfg.commission_pct != null) setFees(String(Math.round(Number(cfg.commission_pct) * 10000)))
if (cfg.stamp_tax_pct != null) setStampTax(String(Number(cfg.stamp_tax_pct) * 1000))
if (cfg.slippage_bps != null) setSlippage(String(cfg.slippage_bps))
if (cfg.max_positions != null) setMaxPositions(String(cfg.max_positions))
if (cfg.max_exposure_pct != null) setMaxExposure(String(Math.round(Number(cfg.max_exposure_pct) * 100)))
if (cfg.initial_capital != null) setInitialCapital(String(cfg.initial_capital))
if (cfg.position_sizing === 'equal' || cfg.position_sizing === 'score_weight') {
setPositionSizing(cfg.position_sizing)
}
if (cfg.mode === 'position' || cfg.mode === 'full') setSimMode(cfg.mode)
if (cfg.holding_days != null) setHoldingDays(String(cfg.holding_days))
if (cfg.minute_fill != null) setHighGranularity(Boolean(cfg.minute_fill))
if (cfg.params && typeof cfg.params === 'object') setStrategyParams(cfg.params)
if (cfg.overrides && typeof cfg.overrides === 'object') setOverrides(cfg.overrides)
const rf = cfg.regime_filter
if (rf && typeof rf === 'object' && !Array.isArray(rf)) {
setRegimeStates(Array.isArray(rf.states) ? rf.states.map(String) : [])
setRegimeMinScore(rf.min_score != null ? Number(rf.min_score) : '')
} else {
setRegimeStates([])
setRegimeMinScore('')
}
toast(`已载入「${loadCandidate.name}」配置,可直接复测`, 'success')
onLoadConsumed?.()
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅在切换候选时执行一次性回填
}, [loadCandidate])
const [resultTab, setResultTab] = useState<'daily' | 'trades' | 'picks'>('daily')
const [dailyPage, setDailyPage] = useState(0)
const [tradePage, setTradePage] = useState(0)
@@ -1158,6 +1211,67 @@ export function StrategyBacktest() {
? strategyReturn - benchmarkReturn
: null
/** 导出回测结果 CSV (带 BOM, Excel 可直接打开): 概要 + 净值曲线 + 交易明细 + 分标的统计 */
const exportResultCsv = () => {
if (!result) return
const s = result.stats ?? {}
const name = result.strategy_info?.name ?? result.strategy_info?.id ?? '策略'
const start = String(result.config?.start ?? resultStartDate).slice(0, 10)
const end = String(result.config?.end ?? resultEndDate).slice(0, 10)
const pct = (v: unknown) => (v == null ? '' : fmtPct(Number(v)))
const num = (v: unknown) => (v == null || Number.isNaN(Number(v)) ? '' : String(v))
const lines: string[] = []
lines.push('# 概要', '指标,数值')
lines.push(`策略名称,${name}`)
if (result.strategy_info?.id) lines.push(`策略ID,${result.strategy_info.id}`)
lines.push(`回测区间,${start} ~ ${end}`)
lines.push(`净值曲线天数,${result.equity_curve?.length ?? 0}`)
lines.push(`完成交易数,${result.trades?.length ?? 0}`)
lines.push(`总收益,${pct(strategyReturn)}`)
lines.push(`年化收益,${pct(s.annual_return)}`)
lines.push(`同期基准,${pct(benchmarkReturn)}`)
lines.push(`超额收益,${pct(excessReturn)}`)
for (const [label, key] of [
['夏普比率', 'sharpe'], ['索提诺', 'sortino'], ['最大回撤', 'max_drawdown'],
['胜率', 'win_rate'], ['平均收益', 'avg_return'], ['中位数收益', 'median_return'],
['盈亏比', 'profit_factor'], ['最终权益', 'final_equity'], ['平均持仓天数', 'avg_duration'],
] as const) {
const v = s[key as keyof typeof s]
if (v != null) lines.push(`${label},${key.includes('return') || key === 'win_rate' || key === 'max_drawdown' ? pct(v) : num(v)}`)
}
const ddMap = new Map((result.drawdown_curve ?? []).map(r => [r.date, r.value]))
const benchMap = new Map((result.benchmark_curve ?? []).map(r => [r.date, r.close ?? r.value]))
lines.push('', '# 净值曲线', 'date,equity,cash,positions,exposure,drawdown,benchmark')
for (const r of result.equity_curve ?? []) {
lines.push([r.date, num(r.value), num(r.cash), num(r.positions), num(r.exposure),
num(ddMap.get(r.date)), num(benchMap.get(r.date))].join(','))
}
lines.push('', '# 交易明细',
'symbol,name,entry_date,entry_price,exit_date,exit_price,pnl_pct,duration,exit_reason,shares,entry_value,exit_value,pnl_amount')
for (const t of result.trades ?? []) {
lines.push([t.symbol, t.name ?? '', t.entry_date, num(t.entry_price), t.exit_date,
num(t.exit_price), num(t.pnl_pct), num(t.duration), t.exit_reason ?? '',
num(t.shares), num(t.entry_value), num(t.exit_value), num(t.pnl_amount)].map(csvEsc).join(','))
}
lines.push('', '# 分标的统计', 'symbol,n_trades,total_return,win_rate,best,worst')
for (const p of result.per_symbol_stats ?? []) {
lines.push([p.symbol, num(p.n_trades), num(p.total_return), num(p.win_rate),
num(p.best), num(p.worst)].join(','))
}
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `回测_${name.replace(/[\\/:*?"<>|]/g, '_')}_${start}_${end}.csv`
a.click()
URL.revokeObjectURL(url)
}
const applyRange = (months: number) => {
setStart(monthsAgo(months))
setEnd(formatDate(new Date()))
@@ -1981,6 +2095,15 @@ export function StrategyBacktest() {
</span>
)}
<span className="text-[10px] text-secondary"> {result.config?.holding_days ?? 5} </span>
<button
type="button"
onClick={exportResultCsv}
title="导出回测结果 CSV (概要 + 净值曲线 + 交易明细 + 分标的统计)"
className="ml-1 inline-flex h-6 shrink-0 items-center gap-1 rounded border border-border bg-base px-2 text-[10px] text-secondary transition-colors hover:border-accent/40 hover:text-accent"
>
<Download className="h-3 w-3" />
</button>
<span className="ml-auto text-[11px] text-muted font-mono">
{String(result.config?.start).slice(0,10)} ~ {String(result.config?.end).slice(0,10)}
</span>
@@ -2097,6 +2220,15 @@ export function StrategyBacktest() {
<span className="num">{fmtDuration(result.elapsed_ms)}</span>
</span>
)}
<button
type="button"
onClick={exportResultCsv}
title="导出回测结果 CSV (概要 + 净值曲线 + 交易明细 + 分标的统计)"
className="ml-1 inline-flex h-6 shrink-0 items-center gap-1 rounded border border-border bg-base px-2 text-[10px] text-secondary transition-colors hover:border-accent/40 hover:text-accent"
>
<Download className="h-3 w-3" />
</button>
</div>
)}