mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(frontend): 异动中心三tab、回测导出与载入复测、看板成分股弹窗与板块分时
- 异动监控重构为按交易时间线三tab: 竞价异动(风向标+当日/次日对照+追高标记, fuyao 未配置统一引导态)/盘中异动(信号筛选+连板/量比/振幅/换手表)/偏移异动 - 复盘页移除风向标卡片(AI 注入保留在后端) - 回测结果导出 CSV(BOM, 概要/净值/交易明细/分标的统计四段) - 候选方案「载入复测」: 弹窗→策略页传递, 23 项配置回填复跑 - 看板概念/行业排名行点击弹出成分股(dimensionKindForSourceField 判型), 领涨股带涨跌幅与板块徽章 - 成分股弹窗新增板块等权分时: 双线(板块/全市场)+0%基线, 伪时间戳轴 HH:MM 标签, 分钟未落盘引导态, 60s 轮询续期
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user