diff --git a/frontend/src/components/DimensionMembersDialog.tsx b/frontend/src/components/DimensionMembersDialog.tsx index 8f72e58..b80845e 100644 --- a/frontend/src/components/DimensionMembersDialog.tsx +++ b/frontend/src/components/DimensionMembersDialog.tsx @@ -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

+ {source && ( + + )} +

@@ -300,3 +320,217 @@ function Summary({ label, value, className }: { label: string; value: string | n
) } + +// --------------------------------------------------------------------------- +// 板块分时 (等权): 点击触发 + 60s 轮询续期, 不预计算 +// --------------------------------------------------------------------------- + +const INTRADAY_SECTOR_COLOR: Record = { + 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 ( +
+
+ + 分时走势 · 等权 + {data?.member_count != null && data.members_with_minute != null && ( + + {data.members_with_minute}/{data.member_count}只 + + )} + {data?.basis && data.basis !== 'prev_close' && ( + + 基准:当日首价 + + )} + {data?.status === 'ok' && ( +
+ + + 板块 + + {fmtPct(lastNonNull(data.points, 'sector'))} + + + + + 全市场 + + {fmtPct(lastNonNull(data.points, 'market'))} + + + {data.date && {data.date}} +
+ )} +
+ + {query.isLoading ? ( +
+ ) : query.isError ? ( +
+ 分时加载失败:{String((query.error as Error).message)} +
+ ) : data?.status === 'no_data' ? ( +
+ +

分钟数据未落盘, 暂无分时走势

+

需 TickFlow Pro+ 盘后分钟同步 / Expert 盘中增量, 或自定义分钟源

+ 前往数据页 → +
+ ) : !data || data.status === 'empty' || data.points.length < 2 ? ( +
+ {data?.reason === 'no_member_bars' ? '成分股当日无分钟数据 (ETF 等标的无分钟落盘)' : '暂无成分股分时数据'} +
+ ) : ( + + )} +
+ ) +} + +function IntradayChart({ points, kind }: { points: DimensionIntradayPoint[]; kind: DimensionKind }) { + const containerRef = useRef(null) + const chartRef = useRef(null) + const sectorRef = useRef | null>(null) + const marketRef = useRef | null>(null) + const ct = useChartTheme() + const ctRef = useRef(ct) + ctRef.current = ct + // v4 不支持字符串时间: 用均匀伪时间戳作横轴, 标签经 formatter 映射回 HH:MM + const labelsRef = useRef([]) + 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 ( +
+
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9cf8905..b95f8b1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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> + rows?: AbnormalIntradayRow[] +} + export interface MonitorRule { id: string name: string @@ -2521,6 +2566,12 @@ export const api = { return request(`/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(`/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( + `/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( `/api/abnormal/overview?min_closeness=${minCloseness}&limit=${limit}`, ), + /** 盘中异动: enriched 当日信号命中行 (涨停/炸板/翘板/跌停/新高/新低/放量) */ + abnormalIntraday: (limit = 500) => + request(`/api/abnormal/intraday?limit=${limit}`), + // ===== Monitor Rules (监控规则) ===== monitorRulesList: () => request<{ rules: MonitorRule[] }>('/api/monitor-rules'), @@ -3336,6 +3397,22 @@ export interface DimensionMembersResult { rows: Record[] } +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 diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 8ec35a2..a05eefd 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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, diff --git a/frontend/src/pages/AbnormalMoves.tsx b/frontend/src/pages/AbnormalMoves.tsx index a5fd15c..bc91f70 100644 --- a/frontend/src/pages/AbnormalMoves.tsx +++ b/frontend/src/pages/AbnormalMoves.tsx @@ -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 = { + 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('intraday') + const [preview, setPreview] = useState<{ symbol: string; name: string } | null>(null) + + return ( + // 整页占满视口: 头部/tab固定, 只有各 tab 内容区滚动 +
+
+ + + 告警规则 + + } + /> +
+ + {/* tab 条: 交易时间线 竞价(盘前) → 盘中 → 偏移(多日) */} +
+
+ {TAB_META.map(t => { + const Icon = t.icon + const active = tab === t.key + return ( + + ) + })} +
+ {TAB_META.find(t => t.key === tab)?.desc} +
+ +
+ {tab === 'auction' && ( + setPreview({ symbol: s, name: n ?? s })} /> + )} + {tab === 'intraday' && ( + setPreview({ symbol: r.symbol, name: r.name ?? r.symbol })} /> + )} + {tab === 'deviation' && ( + setPreview({ symbol: r.symbol, name: r.name ?? r.symbol })} /> + )} +
+ + {preview && ( + setPreview(null)} + /> + )} +
+ ) +} + +// ================================================================ +// 竞价异动 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 ( +
+
+ + + +
竞价数据源未配置
+

+ 竞价异动 (同花顺盘前风向标与全市场竞价扫描) 依赖 fuyao 数据源, + 复盘页的龙虎榜同样来自该数据源。在「设置 → 数据源」配置 fuyao API Key 后即可使用。 +

+ + 前往配置数据源 + + +
+
+ ) + } + + return ( +
+ + + {/* 全市场竞价扫描: 采集任务启用后填充 (接口与批量能力已验证) */} +
+
+ + + +
+
+ 全市场竞价扫描 + + 待采集任务启用 + +
+

+ 9:25 竞价终态后扫描全市场 (实测 5547 只约 2 秒), 自动筛出高开 ≥5% 且竞价量比 ≥10 + 的标的并按日落盘积累历史。竞价明细无历史接口, 数据从采集启用之日起积累。 +

+
+
+
+ +

+ 风向标为同花顺盘前竞价筛选名单 (每日约 5~6 只)。60 日回测: 名单当日开盘买入均值 +0.54% + (超额 +0.44%), 但高开 ≥5% 子集当日 -1.97% — 追高是陷阱, 次日无显著优势, 仅作当日观察。 +

+
+ ) +} + +function BenchmarkCard({ q, onOpenStock }: { + q: UseQueryResult + onOpenStock: (symbol: string, name?: string | null) => void +}) { + const d = q.data + + if (q.isLoading) { + return ( +
+ + + +
+ + + +
+
+ ) + } + + // source_unavailable (fuyao 未配置) 由 AuctionView 统一引导态处理, 此处不再分支 + + if (!d || d.state === 'no_data') { + return ( +
+ + + + 盘前风向标暂不可用{d?.message ? ` (${d.message.slice(0, 40)})` : ''} + +
+ ) + } + + 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 ( +
+ {/* 头部 */} +
+ + + + + + 盘前风向标 + {isFallback && ( + + 显示上一期 + + )} + + + {d.trade_date} · {items.length} 只 · 同花顺竞价筛选 + {avgOc != null && ( + <> · 当日开盘买均值 {fmtPct(avgOc)} + )} + + + + 次日无优势
仅当日观察 +
+
+ + {/* 名单 */} + {items.length === 0 ? ( +

本期无名单数据

+ ) : ( +
+
+ 竞价 + 股票 + 当日 + 次日 +
+ {items.map((i: AuctionBenchmarkItem) => { + const gap = i.auction_pct ?? null + const chase = (gap ?? 0) >= _BENCH_CHASE_RISK_PCT + return ( + + ) + })} +
+ )} +
+ ) +} + +// ================================================================ +// 盘中异动 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 ( +
+ {/* 信号筛选 chips (带各类型计数) + 工具行 */} +
+ setSigFilter('all')} label="全部" count={total} /> + {SIGNAL_KEYS.map(k => ( + setSigFilter(k)} + label={SIGNAL_META[k].label} + count={counts[k] ?? 0} + cls={SIGNAL_META[k].cls} + /> + ))} + + 数据截至 {data?.cache_date ?? '—'} + {q.isFetching && ' · 更新中…'} + +
+ setBoardFilter(v)} + options={[ + { value: 'all' as const, label: '全板块' }, + ...BOARDS.map(b => ({ value: b, label: b })), + ]} + /> + + +
+ + setQuery(e.target.value)} + placeholder="搜索代码/名称" + className="h-7 w-40 rounded border border-border bg-base pl-7 pr-2 text-[11px] text-foreground" + /> +
+
+
+ + {/* 主表 */} +
+ + + + + + + + + + + + + + + {q.isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + rows.map((r, i) => ( + onPreview(r)} /> + )) + )} + +
#代码 / 名称现价今日信号量比振幅换手
正在加载盘中信号…
{data ? '当前筛选下没有命中标的' : '暂无数据'}
+
+
+ ) +} + +function SigChip({ active, onClick, label, count, cls }: { + active: boolean + onClick: () => void + label: string + count: number + cls?: string +}) { + return ( + + ) +} + +function IntradayRowView({ row, rank, onPreview }: { + row: AbnormalIntradayRow + rank: number + onPreview: () => void +}) { + const board = boardTag(row.symbol) + const clu = row.consecutive_limit_ups ?? 0 + return ( + + {rank} + + + + {fmtPrice(row.close)} + + {fmtPct(row.change_pct)} + + +
+ {row.signals.map(s => ( + + {SIGNAL_META[s].label} + + ))} +
+ + + {row.vol_ratio_5d != null ? row.vol_ratio_5d.toFixed(2) : '—'} + + + {row.amplitude != null ? fmtPct(row.amplitude, 2) : '—'} + + + {row.turnover_rate != null ? `${Number(row.turnover_rate).toFixed(2)}%` : '—'} + + + ) +} + +// ================================================================ +// 偏移异动 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( @@ -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 ( - // 整页占满视口: 头部/筛选固定, 只有表格列表区滚动 -
-
- - - {enabled && ( - - )} - - - 告警规则 - - {/* 主开关: 开启后才开始轮询计算 */} - -
- } - /> -
-
- - {/* 规则口径面板 (标题栏「?」展开) */} +
+ {/* 规则口径面板 (工具栏「?」展开) */} {rulesOpen && (
@@ -252,7 +717,7 @@ export function AbnormalMoves() {
)} - {/* 统计 + 筛选 */} + {/* 统计 + 控制 */}
@@ -264,6 +729,49 @@ export function AbnormalMoves() { {data ? ` · 基准指数今日 ${(data.bench_rt_pct * 100).toFixed(2)}%` : ''} )} +
+ + {enabled && ( + + )} + {/* 主开关: 开启后才开始轮询计算 */} + +
@@ -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() {
)} - -
- {preview && ( - setPreview(null)} - /> - )}
) @@ -508,7 +1007,7 @@ function AbnormalRowView({ row, rank, onPreview }: {
diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx index 3b405fd..4994750 100644 --- a/frontend/src/pages/Backtest.tsx +++ b/frontend/src/pages/Backtest.tsx @@ -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(null) // 旧链接兼容: 挖掘已升级为一级路由 /mining, 保留 run/candidate 参数重定向 if (requestedTab === 'mining') { @@ -99,11 +102,25 @@ export function Backtest() {
{activeTab === 'factor' && } - {activeTab === 'strategy' && } + {activeTab === 'strategy' && ( + setPendingLoad(null)} + /> + )} {activeTab === 'robustness' && }
- {candidatesOpen && setCandidatesOpen(false)} />} + {candidatesOpen && ( + setCandidatesOpen(false)} + onLoadStrategy={candidate => { + setPendingLoad(candidate) + setCandidatesOpen(false) + if (activeTab !== 'strategy') changeTab('strategy') + }} + /> + )}
) } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 8cbd3ad..0406dd8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 (
{title}
- {rows.slice(0, 5).map((r, idx) => ( -
+ {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 ( +
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' + }`} + > {idx + 1}
-
{r.name}
+
+ {r.name} + {clickable && } +
{r.count}只 · @@ -489,6 +508,11 @@ function RankColumn({ title, rows, tone, onStockClick }: { ) : ( {r.leader?.name ?? '—'} )} + {r.leader?.change_pct != null && ( + + {fmtStockPct(r.leader.change_pct)} + + )} {r.leader?.symbol && (() => { const board = boardTag(r.leader!.symbol!) return board ? ( @@ -501,24 +525,26 @@ function RankColumn({ title, rows, tone, onStockClick }: {
{fmtStockPct(r.avg_pct)}
- ))} + ) + })} {rows.length === 0 &&
暂无数据
}
) } -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 (
- + {hasData ? (
- - + +
) : (
@@ -540,6 +566,8 @@ export function Dashboard() { const [selectedDate, setSelectedDate] = useState() const [manualFetching, setManualFetching] = useState(false) const [previewStock, setPreviewStock] = useState<{symbol: string; name?: string; alert?: AlertEvent} | null>(null) + // 板块成分股弹窗 (概念/行业热度卡片行点击) + const [dimensionTarget, setDimensionTarget] = useState(null) // 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次 const [showWelcomeModal, setShowWelcomeModal] = useState(false) const dataStatus = useDataStatus({ staleTime: 60_000 }) @@ -820,8 +848,12 @@ export function Dashboard() {
- setPreviewStock({symbol, name})} /> - setPreviewStock({symbol, name})} /> + setPreviewStock({symbol, name})} + onDimensionClick={setDimensionTarget} /> + setPreviewStock({symbol, name})} + onDimensionClick={setDimensionTarget} />
@@ -867,6 +899,14 @@ export function Dashboard() { } : null} onClose={() => setPreviewStock(null)} /> + setDimensionTarget(null)} + onStockClick={(symbol, name) => { + setDimensionTarget(null) + setPreviewStock({ symbol, name }) + }} + />
) } diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx index 53e894d..894f179 100644 --- a/frontend/src/pages/Review.tsx +++ b/frontend/src/pages/Review.tsx @@ -308,6 +308,7 @@ export function Review() { {/* ===== 市场摘要条(轻量上下文,非重复看板)===== */} + {/* ===== 龙虎榜 (fuyao 专有, 资金动向上下文; 复盘日联动) ===== */} @@ -1135,6 +1136,8 @@ function _DtSeatList({ seats, onOpenStock }: { ) } +/** 追高风险阈值: 60日回测高开≥5%子集当日开盘买 -1.97% (温和高开才是名单 alpha 来源) */ + function DragonTigerCard({ date, onOpenStock }: { date?: string onOpenStock: (symbol: string) => void diff --git a/frontend/src/pages/backtest/ResearchCandidatesDialog.tsx b/frontend/src/pages/backtest/ResearchCandidatesDialog.tsx index 2aa7b74..7c8c711 100644 --- a/frontend/src/pages/backtest/ResearchCandidatesDialog.tsx +++ b/frontend/src/pages/backtest/ResearchCandidatesDialog.tsx @@ -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(null) @@ -219,6 +223,17 @@ export function ResearchCandidatesDialog({ onClose }: { onClose: () => void }) { ))}
+ {item.kind === 'strategy' && onLoadStrategy && ( + + )} {item.kind === 'factor' && ( {String(result.config?.start).slice(0,10)} ~ {String(result.config?.end).slice(0,10)} @@ -2097,6 +2220,15 @@ export function StrategyBacktest() { {fmtDuration(result.elapsed_ms)} )} +
)}