feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善

- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
This commit is contained in:
shy3130
2026-08-16 23:39:07 +08:00
parent eb869c7ad2
commit 697c27bb02
129 changed files with 20324 additions and 602 deletions
+23 -6
View File
@@ -25,6 +25,7 @@ import {
Star,
ScanSearch,
History,
Pickaxe,
FileText,
Settings,
Key,
@@ -79,6 +80,7 @@ const nav = [
{ to: '/watchlist', label: '自选', icon: Star },
{ to: '/screener', label: '策略', icon: ScanSearch },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/mining', label: '挖掘', icon: Pickaxe },
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
@@ -424,12 +426,11 @@ export function Layout() {
: (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider)
const isCustomActive = activeProvider !== 'tickflow'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒; 后台标签页由 SSE 事件驱动, 不轮询)
const alertsTotalQuery = useQuery({
queryKey: ['alerts-total'],
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
refetchInterval: 15000,
refetchIntervalInBackground: true,
select: (data) => data.total,
})
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
@@ -456,11 +457,27 @@ export function Layout() {
const navItems = savedOrder.length > 0
? (() => {
const byTo = new Map(allNav.map(n => [n.to, n]))
const ordered = savedOrder
const ordered = (savedOrder
.map(id => byTo.get(id) ?? byTo.get(`/analysis/${id}`))
.filter(Boolean)
const seen = new Set(ordered.map(n => n!.to))
return [...ordered as typeof allNav, ...allNav.filter(n => !seen.has(n.to))]
.filter(Boolean)) as typeof allNav
const seen = new Set(ordered.map(n => n.to))
const merged = [...ordered]
for (const item of allNav) {
if (seen.has(item.to)) continue
// 未保存过排序的新条目: 内置页插回默认位置(排在已保存的默认前驱之后),
// 分析/扩展菜单仍追加到末尾
const defaultIndex = nav.findIndex(n => n.to === item.to)
let anchor = -1
if (defaultIndex > 0) {
for (let i = defaultIndex - 1; i >= 0 && anchor < 0; i -= 1) {
anchor = merged.findIndex(n => n.to === nav[i].to)
}
}
if (anchor >= 0) merged.splice(anchor + 1, 0, item)
else if (defaultIndex >= 0) merged.unshift(item)
else merged.push(item)
}
return merged
})()
: allNav
+383 -1
View File
@@ -473,6 +473,15 @@ export interface RegimeRow {
speculation_score?: number
resilience_score?: number
trend_score?: number
// 情绪周期阶段与梯队指标(重算后才有; 旧数据可能缺)
phase?: MarketPhase | null
first_board?: number | null
ge2_count?: number | null
ge3_count?: number | null
ge5_count?: number | null
ladder_completeness?: number | null
promo_rate?: number | null
promo_pool?: number | null
}
export interface RegimeHistory {
@@ -498,6 +507,90 @@ export interface RegimeCoverage {
latest_date: string | null
}
// ── 市场阶段(情绪周期) 与 主线 ──
export type MarketPhase = 'ice' | 'ignite' | 'rally' | 'climax' | 'ebb' | 'repair'
export const MARKET_PHASE_LABELS: Record<MarketPhase, string> = {
ice: '冰点',
ignite: '启动',
rally: '主升',
climax: '高潮',
ebb: '退潮',
repair: '修复',
}
export const MARKET_PHASE_COLORS: Record<MarketPhase, string> = {
ice: '#38bdf8', // 天蓝(冻结)
ignite: '#f59e0b', // 琥珀(升温)
rally: '#ef4444', // 红(主升)
climax: '#d946ef', // 品红(极端)
ebb: '#14b8a6', // 青(退潮)
repair: '#94a3b8', // 灰(修复)
}
export const MARKET_PHASE_ORDER: MarketPhase[] = ['ice', 'ignite', 'rally', 'climax', 'ebb', 'repair']
export interface MainlineMemberStat {
member: string
top5_days: number
score_sum: number
max_boards: number
leader_symbol: string
}
export interface PhaseSegment {
phase: MarketPhase
label: string
start: string
end: string
days: number
avg_height: number
avg_first_board: number
avg_ge2: number
avg_promo: number | null
avg_seal_rate: number
top_mainlines: MainlineMemberStat[]
}
export interface PhaseSegments {
segments: PhaseSegment[]
total: number
}
export interface MainlineRow {
date: string
kind: string
member: string
limit_up_count: number
ge2_count: number
max_boards: number
boards_sum: number
rungs_filled: number
leader_symbol: string
score: number
rank: number
}
export interface MainlineLeader {
member: string
top1_days: number
avg_score: number
max_boards: number
}
export interface MainlineFilter {
min_members: number
max_members: number
blacklist: string[]
}
export interface MainlineResult {
rows: MainlineRow[]
leaders: MainlineLeader[]
membership_note: string
filter: MainlineFilter
}
// ===== 大盘复盘 =====
export interface AiReviewReport {
id: string
@@ -846,6 +939,217 @@ export interface FactorBatchResult {
error: string | null
}
// ===== Factor / strategy mining =====
export type MiningBudgetProfile = 'exploratory' | 'balanced' | 'strict'
export type MiningRunStatus =
| 'queued'
| 'running'
| 'cancelling'
| 'succeeded'
| 'succeeded_with_budget_exhausted'
| 'failed'
| 'cancelled'
| 'interrupted'
| 'skipped_prerequisite'
export interface MiningAvailability {
asset_type: 'stock' | 'etf'
budget_profile: MiningBudgetProfile
trading_bars: number
required_bars: number
outer_folds: number
required_outer_folds: number
eligible: boolean
available_start: string | null
available_end: string | null
effective_start: string | null
effective_end: string | null
suggested_start: string | null
}
export interface MiningRequestV1 {
factor_names: string[]
strategy_ids?: string[]
symbols?: string[] | null
asset_type?: 'stock' | 'etf'
start?: string | null
end?: string | null
budget_profile?: MiningBudgetProfile
commission_pct?: number
stamp_tax_pct?: number
slippage_bps?: number
correlation_threshold?: number
max_combination_factors?: number
beam_width?: number
max_finalists?: number
force?: boolean
}
export interface MiningRunProgress {
phase: string
label?: string
done?: number
total?: number
percent?: number
elapsed_ms?: number
message?: string
}
export interface MiningRun {
run_id: string
signature: string
status: MiningRunStatus
request: MiningRequestV1
source?: 'manual' | 'scheduled'
created_at: string
updated_at: string
started_at?: string | null
finished_at?: string | null
data_as_of?: string | null
progress?: MiningRunProgress | null
error?: string | null
reused?: boolean
summary?: MiningResultSummary | null
}
export interface MiningResultSummary {
factor_count: number
selected_factor_count: number
candidate_count: number
valid_fold_count: number
skipped_fold_count: number
confidence: 'low' | 'standard' | 'high'
budget_exhausted?: boolean
elapsed_ms?: number
peak_rss_bytes?: number
}
export interface MiningFactorRow {
factor_name: string
label?: string
direction: 1 | -1
score: number | null
ic_mean: number | null
ir: number | null
coverage: number | null
turnover: number | null
spread_return?: number | null
spread_sharpe?: number | null
selected: boolean
excluded_reason?: string | null
}
export interface MiningRegimeRow {
state: 'overall' | 'strong' | 'range' | 'weak' | string
label: string
n_dates: number
total_return: number | null
sharpe: number | null
max_drawdown: number | null
}
export interface MiningFoldRow {
fold: number
label?: string
train_start?: string
train_end?: string
test_start?: string
test_end?: string
selected_factors?: string[]
total_return: number | null
sharpe: number | null
max_drawdown?: number | null
n_trades?: number | null
skipped?: boolean
reason?: string | null
evaluation_kind?: 'selected' | 'cross' | 'benchmark' | null
}
export interface MiningCandidateGate {
qualified: boolean
reasons: string[]
}
export interface MiningCandidateRow {
signature: string
name: string
kind: 'factor_combination' | 'existing_strategy'
factor_names?: string[]
strategy_id?: string | null
regime_state?: string | null
score: number | null
oos_return: number | null
oos_sharpe: number | null
oos_max_drawdown: number | null
oos_positive_fold_ratio: number | null
oos_n_trades: number | null
confidence: 'low' | 'standard' | 'high'
valid_folds?: number | null
skipped_folds?: number | null
promoted_candidate_id?: string | null
published_strategy_id?: string | null
gate?: MiningCandidateGate | null
folds?: MiningFoldRow[]
}
export interface MiningTelemetry {
elapsed_ms?: number
peak_rss_bytes?: number
panel_scans?: number
matrix_bytes?: number
cache_hits?: number
fold_reuses?: number
serialized_result_bytes?: number
phase_ms?: Record<string, number>
}
export interface MiningRequestSummary {
asset_type: string
budget_profile: string
start: string | null
end: string | null
factor_count: number
strategy_count: number
commission_pct: number | null
stamp_tax_pct: number | null
slippage_bps: number | null
correlation_threshold: number | null
}
export interface MiningResult {
run_id: string
methodology_version: string
algorithm_version: string
data_as_of: string | null
summary: MiningResultSummary
request_summary?: MiningRequestSummary | null
factors: MiningFactorRow[]
correlation: {
labels: string[]
matrix: (number | null)[][]
pair_counts?: (number | null)[][]
threshold: number
}
regimes: MiningRegimeRow[]
candidates: MiningCandidateRow[]
folds: MiningFoldRow[]
telemetry: MiningTelemetry
}
export interface MiningEvent {
id: number
type: string
timestamp?: string
payload?: Record<string, unknown>
message?: string
}
export interface MiningScheduleConfig {
mining_schedule_enabled: boolean
mining_schedule_weekday: number
mining_budget_profile: Exclude<MiningBudgetProfile, 'exploratory'>
}
export type ResearchCandidateKind = 'factor' | 'strategy'
export type ResearchCandidateStatus = 'pending' | 'validated' | 'rejected'
@@ -1751,8 +2055,28 @@ export const api = {
if (start) params.set('start', start)
if (end) params.set('end', end)
const qs = params.toString()
return request<{ ok: boolean; computed: number }>(`/api/regime/recompute${qs ? `?${qs}` : ''}`, { method: 'POST' })
return request<{ ok: boolean; computed: number; phase_days?: number; mainline_rows?: number }>(`/api/regime/recompute${qs ? `?${qs}` : ''}`, { method: 'POST' })
},
regimePhases: (start?: string, end?: string) => {
const params = new URLSearchParams()
if (start) params.set('start', start)
if (end) params.set('end', end)
const qs = params.toString()
return request<PhaseSegments>(`/api/regime/phases${qs ? `?${qs}` : ''}`)
},
regimeMainline: (start?: string, end?: string, top = 10, kind: 'concept' | 'industry' = 'concept') => {
const params = new URLSearchParams({ top: String(top), kind })
if (start) params.set('start', start)
if (end) params.set('end', end)
return request<MainlineResult>(`/api/regime/mainline?${params.toString()}`)
},
regimeMainlineRecompute: () =>
request<{ ok: boolean; rows: number }>('/api/regime/mainline/recompute', { method: 'POST' }),
mainlineFilterUpdate: (payload: { min_members?: number; max_members?: number; blacklist?: string[] }) =>
request<MainlineFilter>('/api/settings/preferences/mainline-filter', {
method: 'PUT',
body: JSON.stringify(payload),
}),
limitLadder: (asOf?: string, extColumns?: string, direction?: 'up' | 'down') => {
const params = new URLSearchParams()
@@ -1820,6 +2144,64 @@ export const api = {
body: JSON.stringify(payload),
}),
miningRuns: () =>
request<{ items: MiningRun[] }>('/api/backtest/mining/runs'),
miningAvailability: (params: {
assetType: 'stock' | 'etf'
budgetProfile: MiningBudgetProfile
start?: string
end?: string
}) => {
const query = new URLSearchParams({
asset_type: params.assetType,
budget_profile: params.budgetProfile,
})
if (params.start) query.set('start', params.start)
if (params.end) query.set('end', params.end)
return request<MiningAvailability>(`/api/backtest/mining/availability?${query}`, {
quiet: true,
})
},
miningRun: (runId: string) =>
request<MiningRun>(`/api/backtest/mining/runs/${encodeURIComponent(runId)}`),
miningStart: (payload: MiningRequestV1) =>
request<MiningRun>('/api/backtest/mining/runs', {
method: 'POST',
body: JSON.stringify(payload),
}),
miningResult: (runId: string) =>
request<MiningResult>(`/api/backtest/mining/runs/${encodeURIComponent(runId)}/result`),
miningCancel: (runId: string) =>
request<MiningRun>(`/api/backtest/mining/runs/${encodeURIComponent(runId)}/cancel`, {
method: 'POST',
}),
miningPromote: (runId: string, signature: string) =>
request<ResearchCandidate>(
`/api/backtest/mining/runs/${encodeURIComponent(runId)}/candidates/${encodeURIComponent(signature)}/promote`,
{ method: 'POST' },
),
miningPublish: (runId: string, signature: string) =>
request<{ ok: boolean; strategy_id: string }>(
`/api/backtest/mining/runs/${encodeURIComponent(runId)}/candidates/${encodeURIComponent(signature)}/publish`,
{ method: 'POST' },
),
miningConfig: () =>
request<MiningScheduleConfig>('/api/backtest/mining/config'),
updateMiningConfig: (payload: Partial<MiningScheduleConfig>) =>
request<MiningScheduleConfig>('/api/backtest/mining/config', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
researchCandidates: () =>
request<{ items: ResearchCandidate[] }>('/api/backtest/candidates'),
+376
View File
@@ -0,0 +1,376 @@
import { useSyncExternalStore } from 'react'
import { api, type MiningResult, type MiningRun, type MiningRunProgress, type MiningRunStatus } from './api'
export interface MiningTask {
runId: string | null
isPending: boolean
cancelling: boolean
reconnecting: boolean
run: MiningRun | null
progress: MiningRunProgress | null
result: MiningResult | null
previousResult: MiningResult | null
error: string | null
}
const ACTIVE_RUN_KEY = 'mining_active_run_id'
const TERMINAL_STATES = new Set<MiningRunStatus>([
'succeeded',
'succeeded_with_budget_exhausted',
'failed',
'cancelled',
'interrupted',
'skipped_prerequisite',
])
const SUCCESS_STATES = new Set<MiningRunStatus>([
'succeeded',
'succeeded_with_budget_exhausted',
])
const STATUS_POLL_INTERVAL_MS = 2000
let current: MiningTask = {
runId: null,
isPending: false,
cancelling: false,
reconnecting: false,
run: null,
progress: null,
result: null,
previousResult: null,
error: null,
}
let eventSource: EventSource | null = null
let connectionToken = 0
let statusPoll: {
runId: string
token: number
timer: ReturnType<typeof setTimeout> | null
} | null = null
const listeners = new Set<() => void>()
function emit() {
listeners.forEach(listener => listener())
}
function update(patch: Partial<MiningTask>) {
current = { ...current, ...patch }
emit()
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
function stopStatusPolling() {
if (statusPoll?.timer) clearTimeout(statusPoll.timer)
statusPoll = null
}
function closeEvents() {
connectionToken += 1
stopStatusPolling()
eventSource?.close()
eventSource = null
}
function eventPayload(event: MessageEvent): Record<string, any> {
try {
const parsed = JSON.parse(event.data)
if (parsed && typeof parsed === 'object') {
return parsed.payload && typeof parsed.payload === 'object'
? { ...parsed, ...parsed.payload }
: parsed
}
} catch { /* ignore malformed progress events */ }
return {}
}
async function refreshTerminalRun(
runId: string,
fallbackStatus?: MiningRunStatus,
knownRun?: MiningRun,
token = connectionToken,
fallbackError?: string,
) {
try {
const run = knownRun ?? await api.miningRun(runId)
let result: MiningResult | null = null
if (SUCCESS_STATES.has(run.status)) {
result = await api.miningResult(runId)
if (result.run_id !== runId) throw new Error('任务结果与运行 ID 不匹配')
}
if (current.runId !== runId || connectionToken !== token) return
localStorage.removeItem(ACTIVE_RUN_KEY)
update({
run,
progress: run.progress ?? current.progress,
result,
isPending: false,
cancelling: false,
reconnecting: false,
error: run.error || fallbackError || null,
})
} catch (error) {
if (current.runId !== runId || connectionToken !== token) return
localStorage.removeItem(ACTIVE_RUN_KEY)
const run = current.run && fallbackStatus
? { ...current.run, status: fallbackStatus, error: fallbackError || current.run.error }
: current.run
update({
run,
result: null,
isPending: false,
cancelling: false,
reconnecting: false,
error: fallbackStatus === 'cancelled'
? '任务已取消'
: fallbackError || String((error as Error).message || error),
})
}
}
function startStatusPolling(runId: string, token: number, restart = false) {
if (restart) stopStatusPolling()
if (statusPoll?.runId === runId && statusPoll.token === token) return
stopStatusPolling()
const poll = { runId, token, timer: null as ReturnType<typeof setTimeout> | null }
statusPoll = poll
const pollStatus = async () => {
if (
statusPoll !== poll
|| current.runId !== runId
|| connectionToken !== token
|| !current.isPending
) return
try {
const run = await api.miningRun(runId)
if (statusPoll !== poll || current.runId !== runId || connectionToken !== token) return
update({
run,
progress: run.progress ?? current.progress,
cancelling: current.cancelling || run.status === 'cancelling',
})
if (TERMINAL_STATES.has(run.status)) {
closeEvents()
const terminalToken = connectionToken
await refreshTerminalRun(runId, run.status, run, terminalToken)
return
}
} catch {
// EventSource keeps reconnecting; polling is only a bounded status fallback.
}
if (
statusPoll !== poll
|| current.runId !== runId
|| connectionToken !== token
|| !current.isPending
) return
poll.timer = setTimeout(() => {
poll.timer = null
void pollStatus()
}, STATUS_POLL_INTERVAL_MS)
}
void pollStatus()
}
function connect(runId: string) {
closeEvents()
const token = connectionToken
const source = new EventSource(`/api/backtest/mining/runs/${encodeURIComponent(runId)}/events`)
eventSource = source
source.onopen = () => {
if (token !== connectionToken) return
update({ reconnecting: false })
if (!current.cancelling) stopStatusPolling()
}
source.addEventListener('progress', event => {
if (token !== connectionToken) return
update({
progress: eventPayload(event as MessageEvent) as unknown as MiningRunProgress,
reconnecting: false,
})
if (!current.cancelling) stopStatusPolling()
})
const onTerminal = (event: Event) => {
if (token !== connectionToken) return
const payload = eventPayload(event as MessageEvent)
const eventType = (event as MessageEvent).type
const status = (payload.status || eventType) as MiningRunStatus
closeEvents()
const terminalToken = connectionToken
void refreshTerminalRun(
runId,
status,
undefined,
terminalToken,
typeof payload.message === 'string' ? payload.message : undefined,
)
}
for (const type of [
'succeeded',
'succeeded_with_budget_exhausted',
'failed',
'cancelled',
'interrupted',
'skipped_prerequisite',
]) {
source.addEventListener(type, onTerminal)
}
source.onerror = () => {
if (token !== connectionToken || !current.isPending) return
update({ reconnecting: true })
startStatusPolling(runId, token)
}
}
export async function startMining(payload: Parameters<typeof api.miningStart>[0]) {
closeEvents()
localStorage.removeItem(ACTIVE_RUN_KEY)
const token = connectionToken
update({
runId: null,
isPending: true,
cancelling: false,
reconnecting: false,
run: null,
progress: { phase: 'queued', label: '创建任务' },
result: null,
previousResult: current.result ?? current.previousResult,
error: null,
})
try {
const run = await api.miningStart(payload)
if (connectionToken !== token || current.runId !== null) return
localStorage.setItem(ACTIVE_RUN_KEY, run.run_id)
update({
runId: run.run_id,
run,
progress: run.progress ?? current.progress,
isPending: !TERMINAL_STATES.has(run.status),
})
if (TERMINAL_STATES.has(run.status)) {
await refreshTerminalRun(run.run_id, run.status, run, token)
} else {
connect(run.run_id)
}
} catch (error) {
if (connectionToken !== token || current.runId !== null) return
update({
isPending: false,
error: String((error as Error).message || error),
})
}
}
export async function cancelMining() {
if (!current.runId || !current.isPending || current.cancelling) return
const runId = current.runId
const token = connectionToken
update({ cancelling: true, error: null })
startStatusPolling(runId, token, true)
try {
const run = await api.miningCancel(runId)
if (current.runId !== runId || connectionToken !== token) return
update({
run,
progress: run.progress ?? current.progress,
cancelling: !TERMINAL_STATES.has(run.status),
})
if (TERMINAL_STATES.has(run.status)) {
closeEvents()
const terminalToken = connectionToken
await refreshTerminalRun(runId, run.status, run, terminalToken)
}
} catch (error) {
if (current.runId !== runId || connectionToken !== token) return
update({
cancelling: true,
reconnecting: true,
error: String((error as Error).message || error),
})
startStatusPolling(runId, token, true)
}
}
export async function attachMiningRun(runId: string): Promise<boolean> {
closeEvents()
const token = connectionToken
const previousResult = current.result ?? current.previousResult
update({
runId,
isPending: true,
cancelling: false,
reconnecting: true,
run: null,
progress: { phase: 'reconnecting', label: '读取任务状态' },
result: null,
previousResult,
error: null,
})
try {
const run = await api.miningRun(runId)
if (current.runId !== runId || connectionToken !== token) return false
update({
run,
progress: run.progress ?? null,
isPending: !TERMINAL_STATES.has(run.status),
cancelling: run.status === 'cancelling',
reconnecting: false,
error: run.error ?? null,
})
if (TERMINAL_STATES.has(run.status)) {
await refreshTerminalRun(runId, run.status, run, token)
} else {
localStorage.setItem(ACTIVE_RUN_KEY, runId)
connect(runId)
}
return true
} catch (error) {
if (current.runId !== runId || connectionToken !== token) return false
localStorage.removeItem(ACTIVE_RUN_KEY)
update({
isPending: false,
reconnecting: false,
error: String((error as Error).message || error),
})
return false
}
}
export function tryReconnectMining(): boolean {
const runId = localStorage.getItem(ACTIVE_RUN_KEY)
if (!runId) return false
void attachMiningRun(runId)
return true
}
export function clearMiningTask() {
closeEvents()
localStorage.removeItem(ACTIVE_RUN_KEY)
current = {
runId: null,
isPending: false,
cancelling: false,
reconnecting: false,
run: null,
progress: null,
result: null,
previousResult: current.result ?? current.previousResult,
error: null,
}
emit()
}
export function useMiningTask(): MiningTask {
return useSyncExternalStore(subscribe, () => current, () => current)
}
+21 -3
View File
@@ -26,7 +26,11 @@ export const QK = {
watchlistGroups: ['watchlist-groups'] as const,
watchlistQuotes: ['watchlist-quotes'] as const,
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
// 不用 watchlist- 前缀: 日K历史盘中几乎不变, 若被 SSE quotes_updated 高频失效
// (expert 1s) 会导致全自选日K每秒重拉, staleTime 形同虚设。
// 刷新点: staleTime 过期 + Watchlist 增删自选/改蜡烛天数时的手动失效;
// 当日最后一根蜡烛由 Watchlist 用 enriched 实时 OHLC 前端修补 (零额外请求)。
watchlistKlineBatch: (symbols: string) => ['kline-batch', symbols] as const,
// 不用 watchlist- 前缀: 避免被 SSE quotes_updated 高频失效(expert 1s/pro 2s)
// 导致每次都拉 TickFlow 触限流。分时图用固定 refetchInterval 刷新即可。
minuteBatch: (symbols: string) => ['minute-batch', symbols] as const,
@@ -45,8 +49,16 @@ export const QK = {
// Backtest
backtestStatus: ['backtest-status'] as const,
factorColumns: ['backtest-factor-columns'] as const,
miningRuns: ['backtest-mining-runs'] as const,
miningAvailability: (assetType: string, profile: string, start: string, end: string) =>
['backtest-mining-availability', assetType, profile, start, end] as const,
miningRun: (id: string) => ['backtest-mining-run', id] as const,
miningResult: (id: string) => ['backtest-mining-result', id] as const,
miningConfig: ['backtest-mining-config'] as const,
researchCandidates: ['research-candidates'] as const,
strategyLinkOptions: ['strategy-link-options'] as const,
strategyLinkOptions: (assetType?: 'stock' | 'etf') => assetType
? ['strategy-link-options', assetType] as const
: ['strategy-link-options'] as const,
strategyDetail: (id: string) => ['strategy-detail', id] as const,
// Data / Pipeline
@@ -96,6 +108,8 @@ export const QK = {
regimeLatest: ['regime-latest'] as const,
regimeStates: (days: number) => ['regime-states', days] as const,
regimeCoverage: ['regime-coverage'] as const,
regimePhases: (start?: string, end?: string) => ['regime-phases', start ?? '', end ?? ''] as const,
regimeMainline: (kind: string, start?: string, end?: string) => ['regime-mainline', kind, start ?? '', end ?? ''] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
@@ -107,7 +121,11 @@ export const QK = {
// 且在 monitor "重算" 窗口内读到空结果, 造成策略列表闪烁 (变 0 → 空失效 → 又出现)。
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
// 精确前缀: 只命中自选页的实时数据 (quotes/enriched)。不能用宽泛的 'watchlist' ——
// 会误伤 ['watchlist'] (自选列表) 和 ['watchlist-groups'] (分组配置, 只随手动操作变化)。
// 旧设置里的 'watchlist' 单开关由 useQuoteStream 兼容读取。
'watchlist-quotes',
'watchlist-enriched',
'quote-status',
'index-quotes',
'overview-market',
+7
View File
@@ -137,6 +137,13 @@ export function useQuoteStream(
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
// 'quote-status' 始终刷新 (全局状态)
if (p === 'quote-status') return true
// 兼容旧配置: 'watchlist' 拆成两个精确前缀后, 未单独设置时沿用旧 'watchlist' 开关
if (
(p === 'watchlist-quotes' || p === 'watchlist-enriched') &&
pages[p] === undefined
) {
return pages['watchlist'] !== false
}
return pages[p] !== false
})
qc.invalidateQueries({
+22 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { Navigate, useSearchParams } from 'react-router-dom'
import { BarChart3, BookmarkCheck, FlaskConical, ShieldCheck } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { FactorDiscovery } from './backtest/FactorDiscovery'
@@ -27,9 +28,28 @@ const MODES: Record<Tab, { title: string; subtitle: string; icon: typeof BarChar
}
export function Backtest() {
const [activeTab, setActiveTab] = useState<Tab>('strategy')
const [searchParams, setSearchParams] = useSearchParams()
const requestedTab = searchParams.get('tab')
const [candidatesOpen, setCandidatesOpen] = useState(false)
// 旧链接兼容: 挖掘已升级为一级路由 /mining, 保留 run/candidate 参数重定向
if (requestedTab === 'mining') {
const next = new URLSearchParams(searchParams)
next.delete('tab')
const search = next.toString()
return <Navigate to={search ? `/mining?${search}` : '/mining'} replace />
}
const activeTab: Tab = requestedTab && requestedTab in MODES
? requestedTab as Tab
: 'strategy'
const changeTab = (tab: Tab) => {
const next = new URLSearchParams(searchParams)
next.set('tab', tab)
setSearchParams(next, { replace: true })
}
return (
<div className="flex min-h-full flex-col bg-base">
<PageHeader
@@ -59,7 +79,7 @@ export function Backtest() {
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
onClick={() => changeTab(tab)}
aria-current={active ? 'page' : undefined}
className={`inline-flex h-7 items-center gap-1 rounded-[5px] px-1.5 text-[11px] font-medium transition-colors sm:gap-1.5 sm:px-2.5 sm:text-xs ${active
? 'bg-accent text-white shadow-sm'
-1
View File
@@ -102,7 +102,6 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) =>
queryKey: ['alerts', ''],
queryFn: () => api.alertsList({ days: 7, limit: 10 }),
refetchInterval: 10000,
refetchIntervalInBackground: true,
})
const events: AlertEvent[] = alerts.data?.alerts ?? []
+4 -1
View File
@@ -1517,7 +1517,10 @@ export function LimitUpLadder() {
const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields])
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: [QK.limitLadder(asOf || undefined), extColumnsParam, direction],
// key 必须拍平 (spread 展开): key[0] 为字符串 'limit-ladder' 才能被 SSE 前缀失效
// 命中实现实时刷新, depth_updated 事件 (invalidate ['limit-ladder']) 也才能匹配本查询。
// 嵌套数组 key 会导致前者靠 String() 侥幸命中、后者永远失配。
queryKey: [...QK.limitLadder(asOf || undefined), extColumnsParam, direction],
queryFn: () => api.limitLadder(asOf || undefined, extColumnsParam, direction),
staleTime: 5 * 60_000,
})
+37
View File
@@ -0,0 +1,37 @@
import { useState } from 'react'
import { BookmarkCheck } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { MiningWorkbench } from './backtest/MiningWorkbench'
import { ResearchCandidatesDialog } from './backtest/ResearchCandidatesDialog'
export function Mining() {
const [candidatesOpen, setCandidatesOpen] = useState(false)
return (
<div className="flex min-h-full flex-col bg-base">
<PageHeader
title="挖掘"
subtitle={<span className="hidden md:inline"></span>}
className="shrink-0 flex-wrap gap-x-4 gap-y-2 bg-base/95 px-3 lg:flex-nowrap lg:px-5"
right={(
<button
type="button"
onClick={() => setCandidatesOpen(true)}
aria-label="打开候选方案"
title="候选方案"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-btn border border-border bg-surface px-2 text-[11px] font-medium text-secondary transition-colors hover:border-accent/40 hover:text-accent sm:px-2.5 sm:text-xs"
>
<BookmarkCheck className="h-3.5 w-3.5" />
<span></span>
</button>
)}
/>
<main className="min-h-0 flex-1 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
<MiningWorkbench />
</main>
{candidatesOpen && <ResearchCandidatesDialog onClose={() => setCandidatesOpen(false)} />}
</div>
)
}
+2 -2
View File
@@ -135,8 +135,8 @@ export function Monitor() {
const alertsQuery = useQuery({
queryKey: [...QK.alerts(filter === 'all' ? undefined : filter), extColumnsParam ?? ''],
queryFn: () => api.alertsList({ days: 7, limit: 500, source: filter === 'all' ? undefined : filter, extColumns: extColumnsParam }),
// 10s 轮询仅作 SSE strategy_alert 事件的兜底; 后台标签页不再拉 500 条全量
refetchInterval: 10000,
refetchIntervalInBackground: true,
})
const total = alertsQuery.data?.total ?? 0
@@ -351,7 +351,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
const isNew = ev.ts > enterTs
return (
<motion.div
key={`${ev.ts}-${i}`}
key={`${ev.ts}-${ev.symbol ?? ''}-${ev.rule_name ?? ''}`}
initial={isNew ? { opacity: 0, y: -8, scale: 0.98 } : { opacity: 0, y: 4 }}
animate={isNew ? {
opacity: [0, 1, 1, 0.85, 1],
+395 -2
View File
@@ -12,11 +12,12 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import * as echarts from 'echarts'
import {
Activity, RefreshCw, Loader2, Gauge, TrendingUp, TrendingDown, Minus,
Pencil, CalendarDays, Repeat, Rows3, LayoutGrid,
Pencil, CalendarDays, Repeat, Rows3, LayoutGrid, Flame, Layers, Filter, X,
} from 'lucide-react'
import {
api, type RegimeRow, type RegimeState,
api, type RegimeRow, type RegimeState, type MarketPhase,
REGIME_STATE_LABELS, REGIME_STATE_COLORS,
MARKET_PHASE_LABELS, MARKET_PHASE_COLORS, MARKET_PHASE_ORDER,
} from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useChartTheme } from '@/lib/theme'
@@ -139,10 +140,43 @@ export function Regime() {
queryFn: () => api.regimeStates(days),
staleTime: 5 * 60 * 1000,
})
// 情绪周期阶段段 + 主线排行(与 history 同一时间范围)
const phases = useQuery({
queryKey: QK.regimePhases(histRange.start, histRange.end),
queryFn: () => api.regimePhases(histRange.start, histRange.end),
staleTime: 5 * 60 * 1000,
})
const [mainlineKind, setMainlineKind] = useState<'concept' | 'industry'>('concept')
const [filterOpen, setFilterOpen] = useState(false)
const mainline = useQuery({
queryKey: QK.regimeMainline(mainlineKind, histRange.start, histRange.end),
queryFn: () => api.regimeMainline(histRange.start, histRange.end, 10, mainlineKind),
staleTime: 5 * 60 * 1000,
})
const [recomputing, setRecomputing] = useState(false)
const rows: RegimeRow[] = history.data?.rows ?? []
const latest = rows.length > 0 ? rows[rows.length - 1] : null
const hasPhaseData = rows.length > 0 && rows.some(r => r.phase != null)
const segments = phases.data?.segments ?? []
// 当前阶段持续天数(末尾连续同阶段) + 当前主线(最新交易日 top3)
const phaseStreak = useMemo(() => {
if (!hasPhaseData) return null
const lastPhase = rows[rows.length - 1].phase
let streak = 1
for (let i = rows.length - 2; i >= 0; i--) {
if (rows[i].phase === lastPhase) streak++
else break
}
return { phase: lastPhase as MarketPhase, streak }
}, [rows, hasPhaseData])
const latestMainlines = useMemo(() => {
const mlRows = mainline.data?.rows ?? []
if (mlRows.length === 0) return []
const lastDate = mlRows[mlRows.length - 1].date
return mlRows.filter(r => r.date === lastDate && r.rank <= 3)
}, [mainline.data])
// ── 当前势头: 末尾连续同态天数 + score 5日斜率(改善/恶化) + 上次弱势距今 ──
const momentum = useMemo(() => {
@@ -185,6 +219,77 @@ export function Regime() {
// 阶段时间轴: 高度折线 + 2板以上宽度柱 + 晋级率曲线, 背景色带=情绪周期阶段
const phaseOption = useMemo<echarts.EChartsOption | null>(() => {
if (rows.length === 0 || !hasPhaseData) return null
const dates = rows.map(r => r.date)
const heights = rows.map(r => r.max_consecutive)
const ge2 = rows.map(r => r.ge2_count ?? null)
const promo = rows.map(r => (r.promo_rate != null ? Math.round(r.promo_rate * 100) : null))
const phaseBands: any[] = []
let bandStart = rows[0]?.date
let prevPhase = rows[0]?.phase
rows.forEach((r, i) => {
if (r.phase !== prevPhase || i === rows.length - 1) {
const bandEnd = i === rows.length - 1 ? r.date : rows[i - 1].date
if (prevPhase && MARKET_PHASE_COLORS[prevPhase as MarketPhase]) {
phaseBands.push([
{ xAxis: bandStart, itemStyle: { color: MARKET_PHASE_COLORS[prevPhase as MarketPhase], opacity: 0.10 } },
{ xAxis: bandEnd },
])
}
bandStart = r.date
prevPhase = r.phase
}
})
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis', backgroundColor: ct.tooltipBg, borderColor: ct.tooltipBorder,
textStyle: { color: ct.tooltipText },
formatter: (params: any) => {
const p0 = Array.isArray(params) ? params[0] : params
const i = dates.indexOf(p0.axisValue)
const r = rows[i]
if (!r) return ''
const phase = r.phase ? MARKET_PHASE_LABELS[r.phase] : '—'
return [
`<b>${r.date}</b> · ${phase}`,
`高度 ${r.max_consecutive} · 首板 ${r.first_board ?? '—'} · 2板+ ${r.ge2_count ?? '—'}`,
`晋级率 ${r.promo_rate != null ? (r.promo_rate * 100).toFixed(1) + '%' : '—'} · 封板率 ${r.seal_rate != null ? (r.seal_rate * 100).toFixed(1) + '%' : '—'}`,
].join('<br/>')
},
},
legend: {
data: ['高度', '2板+', '晋级率'], textStyle: { color: ct.text, fontSize: 10 }, top: 0,
},
grid: { left: 44, right: 44, top: 32, bottom: 44 },
xAxis: {
type: 'category', data: dates, boundaryGap: false,
axisLabel: { color: ct.text, fontSize: 10, formatter: (v: string) => v.slice(5) },
axisLine: { lineStyle: { color: ct.grid } },
},
yAxis: [
{ type: 'value', name: '高度/宽度', position: 'left', axisLabel: { color: ct.text, fontSize: 10 }, splitLine: { show: false }, nameTextStyle: { color: ct.text } },
{ type: 'value', name: '晋级率%', min: 0, max: 100, position: 'right', axisLabel: { color: ct.text, fontSize: 10 }, splitLine: { lineStyle: { color: ct.grid } }, nameTextStyle: { color: ct.text } },
],
dataZoom: [
{ type: 'inside', start: Math.max(0, 100 - (60 / days) * 100) },
{ type: 'slider', bottom: 6, height: 14, borderColor: ct.border, fillerColor: ct.zoomFill, textStyle: { color: ct.text } },
],
series: [
{ name: '2板+', type: 'bar', data: ge2, yAxisIndex: 0, barMaxWidth: 5,
itemStyle: { color: '#f59e0b', opacity: 0.4 }, z: 1 },
{ name: '高度', type: 'line', data: heights, smooth: true, symbol: 'none', yAxisIndex: 0,
lineStyle: { width: 1.6, color: '#ef4444' }, z: 3,
markArea: { silent: true, data: phaseBands } },
{ name: '晋级率', type: 'line', data: promo, smooth: true, symbol: 'none', yAxisIndex: 1,
lineStyle: { width: 1.2, color: '#3b82f6', type: 'dotted' }, z: 2 },
],
}
}, [rows, days, ct, hasPhaseData])
const phaseChartRef = useEChart(phaseOption, [phaseOption])
// 趋势图: 综合分主线 + 4 子维度曲线(可切换) + 状态背景色带 + 涨停数柱状
const trendOption = useMemo<echarts.EChartsOption | null>(() => {
if (rows.length === 0) return null
@@ -366,6 +471,8 @@ export function Regime() {
qc.invalidateQueries({ queryKey: ['regime-history'] }),
qc.invalidateQueries({ queryKey: ['regime-states'] }),
qc.invalidateQueries({ queryKey: ['regime-latest'] }),
qc.invalidateQueries({ queryKey: ['regime-phases'] }),
qc.invalidateQueries({ queryKey: ['regime-mainline'] }),
qc.invalidateQueries({ queryKey: QK.regimeCoverage }),
])
} catch (e) {
@@ -429,6 +536,207 @@ export function Regime() {
</div>
</div>
{/* ── 市场阶段概览 (情绪周期 + 梯队指标 + 当前主线) ── */}
{hasPhaseData && latest ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{/* 当前阶段 */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Flame className="h-3 w-3" /> · {latest.date}
</div>
<div className="mt-1.5 flex items-baseline gap-2">
<span className="text-2xl font-bold" style={{ color: MARKET_PHASE_COLORS[phaseStreak?.phase ?? 'repair'] }}>
{MARKET_PHASE_LABELS[phaseStreak?.phase ?? 'repair']}
</span>
{phaseStreak && <span className="text-xs text-muted"> {phaseStreak.streak} </span>}
</div>
<div className="mt-1.5 flex flex-wrap gap-1">
{latestMainlines.length > 0 ? latestMainlines.map(m => (
<span key={m.member} className="rounded px-1.5 py-px text-[9px] font-medium"
style={{ color: '#f59e0b', backgroundColor: '#f59e0b18' }} title={`涨停${m.limit_up_count}家 · 最高${m.max_boards}板 · 梯队${m.rungs_filled}`}>
{m.member}
</span>
)) : <span className="text-[9px] text-muted">线</span>}
</div>
</div>
{([
{ label: '市场高度', val: latest.max_consecutive, unit: '板', color: '#ef4444' },
{ label: '首板宽度', val: latest.first_board, unit: '家', color: '#f97316' },
{ label: '2板+宽度', val: latest.ge2_count, unit: '家', color: '#f59e0b' },
{ label: '晋级率', val: latest.promo_rate != null ? `${(latest.promo_rate * 100).toFixed(0)}%` : '—',
unit: '', color: '#3b82f6',
sub: latest.promo_pool != null ? `${latest.promo_pool}` : undefined },
{ label: '梯队完整度', val: latest.ladder_completeness != null ? `${(latest.ladder_completeness * 100).toFixed(0)}%` : '—',
unit: '', color: '#a855f7', sub: '2板→最高板不断档' },
] as { label: string; val: React.ReactNode; unit: string; color: string; sub?: string }[]).map(k => (
<div key={k.label} className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Activity className="h-3 w-3" /> {k.label}
</div>
<div className="mt-1.5 text-2xl font-bold" style={{ color: k.color }}>
{k.val}<span className="ml-0.5 text-xs font-normal text-muted">{k.unit}</span>
</div>
{k.sub && <div className="mt-1 text-[9px] text-muted">{k.sub}</div>}
</div>
))}
</div>
) : (
<div className="rounded-card border border-dashed border-border p-4 text-center text-xs text-muted">
() 线
</div>
)}
{/* ── 情绪周期时间轴 (阶段色带 + 高度/宽度/晋级率) ── */}
{hasPhaseData && rows.length > 0 && (
<div className={cn(cardCls, 'p-3')}>
<SectionTitle icon={Flame} title="情绪周期时间轴"
hint="高度(红) · 2板+宽度(琥珀柱) · 晋级率(蓝虚线) · 背景色带=阶段" />
<div ref={phaseChartRef} className="mt-2 h-[280px]" />
<div className="mt-1.5 flex h-6 w-full overflow-hidden rounded-md">
{rows.map(r => (
<div key={r.date} title={`${r.date} ${MARKET_PHASE_LABELS[r.phase as MarketPhase]}`}
className="flex-1 min-w-[2px] transition-opacity hover:opacity-80"
style={{ backgroundColor: MARKET_PHASE_COLORS[r.phase as MarketPhase] }} />
))}
</div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[10px] text-muted">
{MARKET_PHASE_ORDER.map(p => (
<span key={p} className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded" style={{ backgroundColor: MARKET_PHASE_COLORS[p] }} />
{MARKET_PHASE_LABELS[p]}
</span>
))}
</div>
</div>
)}
{/* ── 阶段 × 主线 (什么阶段走什么主升) ── */}
{segments.length > 0 && (
<div className={cn(cardCls, 'p-3')}>
<SectionTitle icon={Layers} title="阶段 × 主线"
hint={`${segments.length} 段 · 主线按段内 top5 天数排序`} />
<div className="mt-2 overflow-x-auto">
<table className="w-full min-w-[760px] text-left text-[11px]">
<thead>
<tr className="border-b border-border text-[10px] text-muted">
<th className="py-1.5 pr-3 font-medium"></th>
<th className="py-1.5 pr-3 font-medium"></th>
<th className="py-1.5 pr-3 font-medium text-right"></th>
<th className="py-1.5 pr-3 font-medium text-right"></th>
<th className="py-1.5 pr-3 font-medium text-right">2+</th>
<th className="py-1.5 pr-3 font-medium text-right"></th>
<th className="py-1.5 pr-3 font-medium text-right"></th>
<th className="py-1.5 font-medium">线</th>
</tr>
</thead>
<tbody>
{[...segments].reverse().map((seg, i) => (
<tr key={`${seg.start}-${seg.phase}-${i}`} className="border-b border-border/50 last:border-0">
<td className="py-1.5 pr-3">
<span className="rounded px-1.5 py-px text-[10px] font-semibold"
style={{ color: MARKET_PHASE_COLORS[seg.phase], backgroundColor: MARKET_PHASE_COLORS[seg.phase] + '20' }}>
{seg.label}
</span>
</td>
<td className="py-1.5 pr-3 font-mono text-[10px] text-secondary">
{seg.start.slice(5)} ~ {seg.end.slice(5)}
</td>
<td className="py-1.5 pr-3 text-right font-mono">{seg.days}</td>
<td className="py-1.5 pr-3 text-right font-mono">{seg.avg_height}</td>
<td className="py-1.5 pr-3 text-right font-mono">{seg.avg_ge2}</td>
<td className="py-1.5 pr-3 text-right font-mono">
{seg.avg_promo != null ? `${(seg.avg_promo * 100).toFixed(0)}%` : '—'}
</td>
<td className="py-1.5 pr-3 text-right font-mono">
{seg.avg_seal_rate != null ? `${(seg.avg_seal_rate * 100).toFixed(0)}%` : '—'}
</td>
<td className="py-1.5">
<div className="flex flex-wrap gap-1">
{seg.top_mainlines.length > 0 ? seg.top_mainlines.map(m => (
<span key={m.member} className="rounded px-1.5 py-px text-[9px]"
style={{ color: '#f59e0b', backgroundColor: '#f59e0b18' }}
title={`top5 ${m.top5_days} 天 · 最高 ${m.max_boards} 板 · 龙头 ${m.leader_symbol}`}>
{m.member}<span className="ml-1 font-mono opacity-70">{m.top5_days}d</span>
</span>
)) : <span className="text-[9px] text-muted"></span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ── 主线排行 (窗口内持续性 + 过滤设置) ── */}
<div className={cn(cardCls, 'p-3')}>
<SectionTitle icon={Layers} title="主线排行"
hint={
<span className="flex items-center gap-2">
<span className="hidden sm:inline text-[9px] text-muted">{mainline.data?.membership_note}</span>
<button
onClick={() => setFilterOpen(v => !v)}
className={cn('inline-flex items-center gap-1 rounded-btn border px-2 py-0.5 text-[10px] transition-colors',
filterOpen ? 'border-accent/50 text-accent' : 'border-border bg-base text-secondary hover:text-accent')}
>
<Filter className="h-3 w-3" />
</button>
</span>
}
/>
<div className="mt-2 flex items-center gap-2">
<div className="flex items-center rounded-btn border border-border bg-base/60 p-0.5">
{([['concept', '概念'], ['industry', '行业']] as const).map(([k, label]) => (
<button key={k} onClick={() => setMainlineKind(k)}
className={cn('h-6 rounded-[5px] px-2.5 text-xs font-medium transition-colors',
mainlineKind === k ? 'bg-accent text-white shadow-sm' : 'text-secondary hover:text-foreground')}>
{label}
</button>
))}
</div>
<span className="text-[10px] text-muted"> top1 · </span>
</div>
{filterOpen && (
<MainlineFilterPanel
filter={mainline.data?.filter}
onDone={async () => {
await qc.invalidateQueries({ queryKey: ['regime-mainline'] })
await qc.invalidateQueries({ queryKey: ['regime-phases'] })
}}
/>
)}
<div className="mt-2 overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-[11px]">
<thead>
<tr className="border-b border-border text-[10px] text-muted">
<th className="py-1.5 pr-3 font-medium">#</th>
<th className="py-1.5 pr-3 font-medium">线</th>
<th className="py-1.5 pr-3 font-medium text-right">top1 </th>
<th className="py-1.5 pr-3 font-medium text-right"></th>
<th className="py-1.5 pr-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{(mainline.data?.leaders ?? []).map((l, i) => (
<tr key={l.member} className="border-b border-border/50 last:border-0">
<td className="py-1.5 pr-3 font-mono text-muted">{i + 1}</td>
<td className="py-1.5 pr-3 font-medium text-foreground">{l.member}</td>
<td className="py-1.5 pr-3 text-right font-mono">{l.top1_days}</td>
<td className="py-1.5 pr-3 text-right font-mono">{l.avg_score}</td>
<td className="py-1.5 pr-3 text-right font-mono">{l.max_boards} </td>
</tr>
))}
{(mainline.data?.leaders ?? []).length === 0 && (
<tr><td colSpan={5} className="py-4 text-center text-[10px] text-muted">
{mainline.isLoading ? '加载中…' : '暂无主线数据 — 点击「重算」回填, 或检查过滤设置'}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* ── 最新日概览 (4 个指标卡, 去掉与看板重复的涨停/涨跌/成交额) ── */}
{latest ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -661,6 +969,91 @@ export function Regime() {
)
}
// ── 主线过滤设置面板 ──────────────────────────────────────
// 宽基/风格标签(融资融券/沪深股通等数千成分)会霸占主线榜首。默认按成员数
// 上限过滤; 用户可调阈值并按名称屏蔽特定概念, 保存后自动重算主线。
function MainlineFilterPanel({ filter, onDone }: {
filter: { min_members: number; max_members: number; blacklist: string[] } | undefined
onDone: () => Promise<void>
}) {
const [minMembers, setMinMembers] = useState(String(filter?.min_members ?? 4))
const [maxMembers, setMaxMembers] = useState(String(filter?.max_members ?? 600))
const [blacklist, setBlacklist] = useState<string[]>(filter?.blacklist ?? [])
const [input, setInput] = useState('')
const [saving, setSaving] = useState(false)
const addTag = () => {
const v = input.trim()
if (v && !blacklist.includes(v)) setBlacklist([...blacklist, v])
setInput('')
}
const save = async () => {
setSaving(true)
try {
await api.mainlineFilterUpdate({
min_members: Math.max(1, Number(minMembers) || 4),
max_members: Math.max(50, Number(maxMembers) || 600),
blacklist,
})
await api.regimeMainlineRecompute()
toast('过滤已保存, 主线已重算', 'success')
await onDone()
} catch (e) {
toast(`保存失败 · ${String((e as Error)?.message || e)}`, 'error')
} finally {
setSaving(false)
}
}
return (
<div className="mt-2 rounded-btn border border-border bg-base/40 p-2.5">
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1">
<span className="text-[10px] text-muted">()</span>
<input type="number" min={50} max={5000} value={maxMembers}
onChange={e => setMaxMembers(e.target.value)}
className="h-7 w-24 rounded-input border border-border bg-base px-2 text-xs text-foreground outline-none focus:border-accent" />
</label>
<label className="flex flex-col gap-1">
<span className="text-[10px] text-muted"></span>
<input type="number" min={1} max={200} value={minMembers}
onChange={e => setMinMembers(e.target.value)}
className="h-7 w-20 rounded-input border border-border bg-base px-2 text-xs text-foreground outline-none focus:border-accent" />
</label>
<div className="flex min-w-[220px] flex-1 flex-col gap-1">
<span className="text-[10px] text-muted">()</span>
<div className="flex items-center gap-1.5">
<input value={input} onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTag() } }}
placeholder="如: 融资融券、沪股通"
className="h-7 flex-1 rounded-input border border-border bg-base px-2 text-xs text-foreground outline-none focus:border-accent" />
</div>
{blacklist.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{blacklist.map(b => (
<span key={b} className="inline-flex items-center gap-1 rounded bg-accent/10 px-1.5 py-px text-[10px] text-accent">
{b}
<button onClick={() => setBlacklist(blacklist.filter(x => x !== b))} className="hover:text-foreground">
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
)}
</div>
<button onClick={save} disabled={saving}
className="h-7 rounded-btn bg-accent px-3 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : '保存并重算'}
</button>
</div>
<div className="mt-1.5 text-[9px] text-muted">
说明: 成分股数超过上限的概念( ~7700/~3300)/, 线; 线()
</div>
</div>
)
}
// ── 自定义天数输入弹窗 ────────────────────────────────────
function CustomDaysModal({ current, onClose, onApply }: {
current: number
+21 -14
View File
@@ -36,6 +36,9 @@ import {
type ColumnConfig,
} from '@/lib/screener-columns'
// 获取策略为占位功能, 暂时隐藏入口; 恢复时改回 true
const SHOW_STRATEGY_STORE = false
export function Screener() {
const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock')
const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
@@ -115,10 +118,12 @@ export function Screener() {
setFilter(filterMap.current.get(strategyId) ?? { ...defaultFilter })
}, [])
// 对原始结果应用过滤
const filteredRows = result
? applyFilter(result.rows, filter)
: []
// 对原始结果应用过滤 (memo: 否则每次渲染都对全部结果行过滤,
// 且新数组身份会击穿下游 displayRows 的 memo)
const filteredRows = useMemo(
() => (result ? applyFilter(result.rows, filter) : []),
[result, filter],
)
const { data: prefs } = usePreferences()
const screenerAutoRun = prefs?.screener_auto_run ?? true
@@ -698,16 +703,18 @@ export function Screener() {
<Sparkles className="h-3.5 w-3.5" />
· AI
</button>
{/* 获取策略(占位,敬请期待) */}
<button
onClick={() => setShowStore(true)}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn
border border-border bg-surface text-xs font-medium text-secondary
hover:text-accent hover:border-accent/50 transition-colors cursor-pointer"
>
<Store className="h-3.5 w-3.5" />
</button>
{/* 获取策略(占位,敬请期待)— 暂时隐藏 */}
{SHOW_STRATEGY_STORE && (
<button
onClick={() => setShowStore(true)}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn
border border-border bg-surface text-xs font-medium text-secondary
hover:text-accent hover:border-accent/50 transition-colors cursor-pointer"
>
<Store className="h-3.5 w-3.5" />
</button>
)}
</div>
}
/>
+30 -5
View File
@@ -787,7 +787,32 @@ export function Watchlist() {
staleTime: 5 * 60_000, // 5 分钟内不重请求
})
const klineData = dailyKVisible ? (klineBatch.data?.data ?? {}) : {}
// 当日蜡烛实时修补: 历史 K 线按 staleTime 周期拉取 (见 queryKeys 注释), 最后一根
// 蜡烛用每 tick 刷新的 enriched 当日 OHLC 前端覆盖/追加, 蜡烛随实时行情跳动, 零额外请求。
const klineData = useMemo(() => {
const base = dailyKVisible ? (klineBatch.data?.data ?? {}) : {}
const liveRows = enriched.data?.rows
const asOf = enriched.data?.as_of
if (!dailyKVisible || !liveRows?.length || !asOf) return base
const liveBySymbol = new Map<string, any>(liveRows.map((r: any) => [r.symbol, r]))
const patched: Record<string, KlineRow[]> = {}
for (const sym of Object.keys(base)) {
const arr = base[sym]
if (!Array.isArray(arr) || arr.length === 0) { patched[sym] = arr; continue }
const live = liveBySymbol.get(sym)
const { open, high, low, close } = live ?? {}
if (open == null || high == null || low == null || close == null) { patched[sym] = arr; continue }
const last = arr[arr.length - 1]
if (last.date === asOf) {
patched[sym] = [...arr.slice(0, -1), { ...last, open, high, low, close }]
} else if (last.date < asOf) {
patched[sym] = [...arr, { date: asOf, open, high, low, close }]
} else {
patched[sym] = arr
}
}
return patched
}, [dailyKVisible, klineBatch.data, enriched.data])
// 批量分时数据 (Pro+ 用户, 列可见时才拉)
// 刷新策略: 仅当实时行情运行 且 用户在实时监控设置里开启 minute_intraday_refresh 时
@@ -811,7 +836,7 @@ export function Watchlist() {
qc.setQueryData(QK.watchlist, data)
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
qc.invalidateQueries({ queryKey: ['kline-batch'] })
},
})
@@ -826,7 +851,7 @@ export function Watchlist() {
// 2. 清除 list 缓存,触发后台 refetch
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
qc.invalidateQueries({ queryKey: ['kline-batch'] })
},
})
@@ -836,7 +861,7 @@ export function Watchlist() {
qc.setQueryData(QK.watchlist, data)
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
qc.invalidateQueries({ queryKey: ['kline-batch'] })
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
@@ -850,7 +875,7 @@ export function Watchlist() {
qc.setQueryData(['watchlist-enriched', extColumnsParam], { rows: [], as_of: null, elapsed_ms: 0 })
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
qc.invalidateQueries({ queryKey: ['kline-batch'] })
},
})
File diff suppressed because one or more lines are too long
@@ -46,7 +46,7 @@ export function ResearchCandidatesDialog({ onClose }: { onClose: () => void }) {
const [kind, setKind] = useState<'all' | 'factor' | 'strategy'>('all')
const [linkDraft, setLinkDraft] = useState<LinkDraft | null>(null)
const candidates = useQuery({ queryKey: QK.researchCandidates, queryFn: api.researchCandidates })
const strategies = useQuery({ queryKey: QK.strategyLinkOptions, queryFn: () => api.strategyList() })
const strategies = useQuery({ queryKey: QK.strategyLinkOptions(), queryFn: () => api.strategyList() })
const factorColumns = useQuery({ queryKey: QK.factorColumns, queryFn: api.factorColumns })
const supportedFactors = useMemo(
() => new Set((factorColumns.data?.columns ?? []).map(item => item.id)),
@@ -98,7 +98,7 @@ export function ResearchCandidatesDialog({ onClose }: { onClose: () => void }) {
onSuccess: result => {
queryClient.invalidateQueries({ queryKey: QK.strategyDetail(result.strategyId) })
queryClient.invalidateQueries({ queryKey: ['screener-strategies'] })
queryClient.invalidateQueries({ queryKey: QK.strategyLinkOptions })
queryClient.invalidateQueries({ queryKey: QK.strategyLinkOptions() })
setLinkDraft(null)
toast(`已加入“${result.strategyName}”评分方案`, 'success')
},
@@ -0,0 +1,191 @@
import { useEffect, useMemo, useState } from 'react'
import type { EChartsOption } from 'echarts'
import { useChartTheme } from '@/lib/theme'
import { useECharts } from './useECharts'
export interface FactorCorrelationHeatmapProps {
labels: string[]
matrix: (number | null)[][]
pairCounts?: (number | null)[][]
threshold?: number
}
interface HeatmapDatum {
value: [number, number, number, number | null]
itemStyle?: { opacity: number }
}
interface PreparedHeatmap {
labels: string[]
data: HeatmapDatum[]
}
const EMPTY_HEATMAP: PreparedHeatmap = { labels: [], data: [] }
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char] ?? char)
}
export function FactorCorrelationHeatmap({
labels,
matrix,
pairCounts,
threshold,
}: FactorCorrelationHeatmapProps) {
const ct = useChartTheme()
const [prepared, setPrepared] = useState<PreparedHeatmap>(EMPTY_HEATMAP)
// Flattening can dominate render time for wide factor sets, so keep it out of render.
useEffect(() => {
if (!labels.length || !matrix.length) {
setPrepared(EMPTY_HEATMAP)
return
}
const nextLabels = labels.slice()
const data: HeatmapDatum[] = []
const thresholdValue = typeof threshold === 'number' && Number.isFinite(threshold)
? Math.min(1, Math.max(0, Math.abs(threshold)))
: null
for (let rowIndex = 0; rowIndex < nextLabels.length; rowIndex += 1) {
const row = matrix[rowIndex]
if (!row) continue
for (let columnIndex = 0; columnIndex < nextLabels.length; columnIndex += 1) {
const rho = row[columnIndex]
if (typeof rho !== 'number' || !Number.isFinite(rho)) continue
const rawPairCount = pairCounts?.[rowIndex]?.[columnIndex]
const pairCount = typeof rawPairCount === 'number' && Number.isFinite(rawPairCount)
? rawPairCount
: null
const passesThreshold = thresholdValue == null
|| rowIndex === columnIndex
|| Math.abs(rho) >= thresholdValue
data.push({
value: [columnIndex, rowIndex, rho, pairCount],
...(passesThreshold ? {} : { itemStyle: { opacity: 0.3 } }),
})
}
}
setPrepared({ labels: nextLabels, data })
}, [labels, matrix, pairCounts, threshold])
const option = useMemo<EChartsOption | null>(() => {
if (!prepared.data.length) return null
return {
animation: false,
grid: { left: 78, right: 16, top: 14, bottom: 72 },
tooltip: {
trigger: 'item',
confine: true,
backgroundColor: ct.tooltipBg,
borderColor: ct.tooltipBorder,
textStyle: { color: ct.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const value = params.value as HeatmapDatum['value'] | undefined
if (!value) return ''
const [columnIndex, rowIndex, rho, pairCount] = value
const rowLabel = escapeHtml(prepared.labels[rowIndex] ?? '')
const columnLabel = escapeHtml(prepared.labels[columnIndex] ?? '')
return `<div style="font-size:11px;color:${ct.text};margin-bottom:5px">${rowLabel} × ${columnLabel}</div>
<div style="display:flex;justify-content:space-between;gap:18px"><span>rho</span><span style="font-family:monospace">${rho.toFixed(4)}</span></div>
<div style="display:flex;justify-content:space-between;gap:18px"><span>配对数</span><span style="font-family:monospace">${pairCount == null ? '—' : pairCount.toLocaleString('zh-CN')}</span></div>`
},
},
xAxis: {
type: 'category',
data: prepared.labels,
axisLabel: {
color: ct.text,
fontSize: 10,
interval: 0,
rotate: prepared.labels.length > 6 ? 40 : 0,
width: 68,
overflow: 'truncate',
},
axisLine: { lineStyle: { color: ct.border } },
axisTick: { show: false },
splitArea: { show: false },
},
yAxis: {
type: 'category',
data: prepared.labels,
inverse: true,
axisLabel: {
color: ct.text,
fontSize: 10,
width: 64,
overflow: 'truncate',
},
axisLine: { lineStyle: { color: ct.border } },
axisTick: { show: false },
splitArea: { show: false },
},
visualMap: {
type: 'continuous',
min: -1,
max: 1,
dimension: 2,
orient: 'horizontal',
left: 'center',
bottom: 4,
itemWidth: 100,
itemHeight: 8,
calculable: false,
precision: 1,
text: ['1', '-1'],
textGap: 6,
textStyle: { color: ct.text, fontSize: 10 },
inRange: {
color: ['#2563eb', ct.fillSubtle, '#ef4444'],
},
},
series: [{
name: '相关性',
type: 'heatmap',
data: prepared.data,
progressive: 2000,
itemStyle: {
borderColor: ct.border,
borderWidth: 1,
},
emphasis: {
itemStyle: {
borderColor: ct.textStrong,
borderWidth: 1,
},
},
}],
}
}, [prepared, ct])
const chartRef = useECharts(option, [prepared, ct])
const isEmpty = prepared.data.length === 0
return (
<div className="relative h-[360px] w-full min-w-0 overflow-hidden">
<div
ref={chartRef}
className="h-full w-full min-w-0"
role="img"
aria-label="因子相关性热力图"
/>
{isEmpty && (
<div className="absolute inset-0 flex items-center justify-center bg-surface text-xs text-secondary">
</div>
)}
</div>
)
}
@@ -0,0 +1,201 @@
import { useMemo } from 'react'
import type { EChartsOption } from 'echarts'
import { useChartTheme } from '@/lib/theme'
import { useECharts } from './useECharts'
export interface MiningOosFold {
fold: number | string
label: string
return: number | null
sharpe: number | null
skipped?: boolean
reason?: string
}
export interface MiningOosChartProps {
folds: MiningOosFold[]
}
function finiteOrNull(value: number | null): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char] ?? char)
}
export function MiningOosChart({ folds }: MiningOosChartProps) {
const ct = useChartTheme()
const preparedFolds = useMemo(() => folds.map(fold => ({
...fold,
return: fold.skipped ? null : finiteOrNull(fold.return),
sharpe: fold.skipped ? null : finiteOrNull(fold.sharpe),
})), [folds])
const hasDisplayData = preparedFolds.some(fold => (
fold.skipped || fold.return != null || fold.sharpe != null
))
const option = useMemo<EChartsOption | null>(() => {
if (!preparedFolds.length || !hasDisplayData) return null
return {
animation: false,
grid: { left: 54, right: 48, top: 48, bottom: 52 },
legend: {
top: 2,
left: 'center',
itemWidth: 12,
itemHeight: 8,
itemGap: 14,
textStyle: { color: ct.text, fontSize: 10 },
data: ['样本外收益', 'Sharpe', '已跳过'],
},
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: { type: 'shadow' },
backgroundColor: ct.tooltipBg,
borderColor: ct.tooltipBorder,
textStyle: { color: ct.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const items = Array.isArray(params) ? params : [params]
const skippedItem = items.find(item => Array.isArray(item.value) && typeof item.value[2] === 'number')
const index = skippedItem
? skippedItem.value[2] as number
: items[0]?.dataIndex as number | undefined
const fold = index == null ? undefined : preparedFolds[index]
if (!fold) return ''
const heading = fold.label || `Fold ${fold.fold}`
if (fold.skipped) {
const reason = fold.reason ? escapeHtml(fold.reason) : '未提供原因'
return `<div style="font-size:11px;margin-bottom:5px">${escapeHtml(heading)}</div>
<div style="color:${ct.text}">已跳过</div>
<div style="max-width:260px;white-space:normal;margin-top:4px">${reason}</div>`
}
return `<div style="font-size:11px;margin-bottom:5px">${escapeHtml(heading)}</div>
<div style="display:flex;justify-content:space-between;gap:18px"><span>样本外收益</span><span style="font-family:monospace">${fold.return == null ? '—' : `${(fold.return * 100).toFixed(2)}%`}</span></div>
<div style="display:flex;justify-content:space-between;gap:18px"><span>Sharpe</span><span style="font-family:monospace">${fold.sharpe == null ? '—' : fold.sharpe.toFixed(2)}</span></div>`
},
},
xAxis: {
type: 'category',
data: preparedFolds.map(fold => fold.label || `Fold ${fold.fold}`),
axisLabel: {
interval: 0,
fontSize: 10,
width: 68,
overflow: 'truncate',
formatter: (_value: string, index: number) => preparedFolds[index]?.skipped
? `{skipped|${preparedFolds[index]?.label || `Fold ${preparedFolds[index]?.fold ?? ''}`}}`
: `{normal|${preparedFolds[index]?.label || `Fold ${preparedFolds[index]?.fold ?? ''}`}}`,
rich: {
normal: { color: ct.text },
skipped: { color: ct.text, opacity: 0.5 },
},
},
axisLine: { lineStyle: { color: ct.border } },
axisTick: { show: false },
},
yAxis: [
{
type: 'value',
name: '收益',
nameTextStyle: { color: ct.text, fontSize: 10 },
axisLabel: {
color: ct.text,
fontSize: 10,
formatter: (value: number) => `${value.toFixed(0)}%`,
},
axisLine: { show: false },
splitLine: { lineStyle: { color: ct.grid } },
},
{
type: 'value',
name: 'Sharpe',
nameTextStyle: { color: ct.text, fontSize: 10 },
axisLabel: { color: ct.text, fontSize: 10 },
axisLine: { show: false },
splitLine: { show: false },
},
],
series: [
{
name: '样本外收益',
type: 'bar',
data: preparedFolds.map(fold => fold.return == null
? null
: {
value: fold.return * 100,
itemStyle: { color: fold.return >= 0 ? '#ef4444' : '#22c55e' },
}),
barMaxWidth: 28,
},
{
name: 'Sharpe',
type: 'line',
yAxisIndex: 1,
data: preparedFolds.map(fold => fold.sharpe),
connectNulls: false,
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: '#3b82f6', width: 1.5 },
itemStyle: { color: '#3b82f6' },
z: 5,
},
{
name: '已跳过',
type: 'custom',
data: preparedFolds.flatMap((fold, index) => fold.skipped ? [[index, 0, index]] : []),
renderItem: (params: any, api: any) => {
const x = api.coord([api.value(0), 0])[0]
const coordinateSystem = params.coordSys as { y: number }
return {
type: 'text',
x,
y: coordinateSystem.y + 7,
style: {
text: '跳过',
fill: ct.text,
opacity: 0.65,
fontSize: 10,
align: 'center',
verticalAlign: 'top',
},
}
},
z: 10,
},
],
}
}, [preparedFolds, hasDisplayData, ct])
const chartRef = useECharts(option, [preparedFolds, ct])
const emptyMessage = folds.length === 0
? '暂无样本外验证数据'
: '暂无可展示的样本外指标'
return (
<div className="relative h-[304px] w-full min-w-0 overflow-hidden">
<div
ref={chartRef}
className="h-full w-full min-w-0"
role="img"
aria-label="逐折样本外收益和 Sharpe 图"
/>
{!hasDisplayData && (
<div className="absolute inset-0 flex items-center justify-center bg-surface text-xs text-secondary">
{emptyMessage}
</div>
)}
</div>
)
}
@@ -0,0 +1,182 @@
import { useMemo } from 'react'
import type { EChartsOption } from 'echarts'
import { useChartTheme } from '@/lib/theme'
import { useECharts } from './useECharts'
export interface RegimeComparisonRow {
state: string
label: string
nDates: number
sharpe: number | null
return: number | null
maxDrawdown: number | null
}
export interface RegimeComparisonChartProps {
rows: RegimeComparisonRow[]
}
function finiteOrNull(value: number | null): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char] ?? char)
}
export function RegimeComparisonChart({ rows }: RegimeComparisonChartProps) {
const ct = useChartTheme()
const preparedRows = useMemo(() => rows.map(row => {
const hasEnoughSamples = Number.isFinite(row.nDates) && row.nDates >= 2
return {
...row,
hasEnoughSamples,
sharpe: hasEnoughSamples ? finiteOrNull(row.sharpe) : null,
return: hasEnoughSamples ? finiteOrNull(row.return) : null,
maxDrawdown: hasEnoughSamples ? finiteOrNull(row.maxDrawdown) : null,
}
}), [rows])
const hasMetrics = preparedRows.some(row => (
row.return != null || row.sharpe != null || row.maxDrawdown != null
))
const option = useMemo<EChartsOption | null>(() => {
if (!preparedRows.length || !hasMetrics) return null
return {
animation: false,
color: ['#ef4444', '#22c55e', '#3b82f6'],
grid: { left: 54, right: 48, top: 48, bottom: 48 },
legend: {
top: 2,
left: 'center',
itemWidth: 12,
itemHeight: 8,
itemGap: 12,
textStyle: { color: ct.text, fontSize: 10 },
data: ['收益', '最大回撤', 'Sharpe'],
},
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: { type: 'shadow' },
backgroundColor: ct.tooltipBg,
borderColor: ct.tooltipBorder,
textStyle: { color: ct.tooltipText, fontSize: 12 },
formatter: (params: any) => {
const items = Array.isArray(params) ? params : [params]
const index = items[0]?.dataIndex as number | undefined
const row = index == null ? undefined : preparedRows[index]
if (!row) return ''
const state = row.state && row.state !== row.label
? `<span style="color:${ct.text}">${escapeHtml(row.state)}</span>`
: ''
const status = row.hasEnoughSamples ? '' : '<div style="margin-top:4px">样本不足</div>'
return `<div style="font-size:11px;margin-bottom:5px">${escapeHtml(row.label)} ${state}</div>
<div style="display:flex;justify-content:space-between;gap:18px"><span>交易日</span><span style="font-family:monospace">${row.nDates.toLocaleString('zh-CN')}</span></div>
<div style="display:flex;justify-content:space-between;gap:18px"><span>收益</span><span style="font-family:monospace">${row.return == null ? '—' : `${(row.return * 100).toFixed(2)}%`}</span></div>
<div style="display:flex;justify-content:space-between;gap:18px"><span>最大回撤</span><span style="font-family:monospace">${row.maxDrawdown == null ? '—' : `${(row.maxDrawdown * 100).toFixed(2)}%`}</span></div>
<div style="display:flex;justify-content:space-between;gap:18px"><span>Sharpe</span><span style="font-family:monospace">${row.sharpe == null ? '—' : row.sharpe.toFixed(2)}</span></div>${status}`
},
},
xAxis: {
type: 'category',
data: preparedRows.map(row => row.label),
axisLabel: {
interval: 0,
fontSize: 10,
width: 72,
overflow: 'truncate',
formatter: (_value: string, index: number) => preparedRows[index]?.hasEnoughSamples
? `{normal|${preparedRows[index]?.label ?? ''}}`
: `{muted|${preparedRows[index]?.label ?? ''}}`,
rich: {
normal: { color: ct.text },
muted: { color: ct.text, opacity: 0.45 },
},
},
axisLine: { lineStyle: { color: ct.border } },
axisTick: { show: false },
},
yAxis: [
{
type: 'value',
name: '比例',
nameTextStyle: { color: ct.text, fontSize: 10 },
axisLabel: {
color: ct.text,
fontSize: 10,
formatter: (value: number) => `${value.toFixed(0)}%`,
},
axisLine: { show: false },
splitLine: { lineStyle: { color: ct.grid } },
},
{
type: 'value',
name: 'Sharpe',
nameTextStyle: { color: ct.text, fontSize: 10 },
axisLabel: { color: ct.text, fontSize: 10 },
axisLine: { show: false },
splitLine: { show: false },
},
],
series: [
{
name: '收益',
type: 'bar',
data: preparedRows.map(row => row.return == null ? null : row.return * 100),
barMaxWidth: 24,
itemStyle: { color: '#ef4444' },
},
{
name: '最大回撤',
type: 'bar',
data: preparedRows.map(row => row.maxDrawdown == null ? null : row.maxDrawdown * 100),
barMaxWidth: 24,
itemStyle: { color: '#22c55e' },
},
{
name: 'Sharpe',
type: 'line',
yAxisIndex: 1,
data: preparedRows.map(row => row.sharpe),
connectNulls: false,
symbol: 'circle',
symbolSize: 6,
lineStyle: { color: '#3b82f6', width: 1.5 },
itemStyle: { color: '#3b82f6' },
z: 5,
},
],
}
}, [preparedRows, hasMetrics, ct])
const chartRef = useECharts(option, [preparedRows, ct])
const emptyMessage = rows.length === 0
? '暂无市场状态数据'
: '样本不足,暂无可比较指标'
return (
<div className="relative h-[304px] w-full min-w-0 overflow-hidden">
<div
ref={chartRef}
className="h-full w-full min-w-0"
role="img"
aria-label="市场状态指标比较图"
/>
{!hasMetrics && (
<div className="absolute inset-0 flex items-center justify-center bg-surface text-xs text-secondary">
{emptyMessage}
</div>
)}
</div>
)
}
@@ -21,12 +21,13 @@ export function useECharts(
// 初始化 / 销毁
useEffect(() => {
if (!chartRef.current) return
instanceRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
const handleResize = () => instanceRef.current?.resize()
window.addEventListener('resize', handleResize)
const container = chartRef.current
instanceRef.current = echarts.init(container, undefined, { renderer: 'canvas' })
const resizeObserver = new ResizeObserver(() => instanceRef.current?.resize())
resizeObserver.observe(container)
return () => {
window.removeEventListener('resize', handleResize)
resizeObserver.disconnect()
instanceRef.current?.dispose()
instanceRef.current = null
}
+25 -2
View File
@@ -35,6 +35,7 @@ const BUILTIN_PAGES: NavEntry[] = [
{ id: '/watchlist', label: '自选', type: 'builtin', visible: true },
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
{ id: '/backtest', label: '回测', type: 'builtin', visible: true },
{ id: '/mining', label: '挖掘', type: 'builtin', visible: true },
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
@@ -187,7 +188,18 @@ export function SettingsMenuSettingsPanel() {
}
}
for (const e of [...BUILTIN_PAGES, ...analysisEntries]) {
if (!seen.has(e.id)) ordered.push(e)
if (seen.has(e.id)) continue
// 未保存过排序的新条目: 内置页插回默认位置, 分析菜单追加到末尾
const defaultIndex = BUILTIN_PAGES.findIndex(p => p.id === e.id)
let anchor = -1
if (defaultIndex > 0) {
for (let i = defaultIndex - 1; i >= 0 && anchor < 0; i -= 1) {
anchor = ordered.findIndex(o => o.id === BUILTIN_PAGES[i].id)
}
}
if (anchor >= 0) ordered.splice(anchor + 1, 0, e)
else if (defaultIndex >= 0) ordered.unshift(e)
else ordered.push(e)
}
return ordered
}, [prefs?.nav_order, analysisEntries])
@@ -207,7 +219,18 @@ export function SettingsMenuSettingsPanel() {
if (e) { result.push(e); seen.add(id) }
}
for (const e of allEntries) {
if (!seen.has(e.id)) result.push(e)
if (seen.has(e.id)) continue
// 与 allEntries 同一语义: 未保存的新内置页插回默认位置而非追加到末尾
const defaultIndex = BUILTIN_PAGES.findIndex(p => p.id === e.id)
let anchor = -1
if (defaultIndex > 0) {
for (let i = defaultIndex - 1; i >= 0 && anchor < 0; i -= 1) {
anchor = result.findIndex(o => o.id === BUILTIN_PAGES[i].id)
}
}
if (anchor >= 0) result.splice(anchor + 1, 0, e)
else if (defaultIndex >= 0) result.unshift(e)
else result.push(e)
}
return result
}, [localOrder, prefs?.nav_order, allEntries])
+3
View File
@@ -18,6 +18,7 @@ import {
const Watchlist = lazy(() => import('./pages/Watchlist').then(m => ({ default: m.Watchlist })))
const Screener = lazy(() => import('./pages/Screener').then(m => ({ default: m.Screener })))
const Backtest = lazy(() => import('./pages/Backtest').then(m => ({ default: m.Backtest })))
const Mining = lazy(() => import('./pages/Mining').then(m => ({ default: m.Mining })))
const Financials = lazy(() => import('./pages/Financials').then(m => ({ default: m.Financials })))
const Data = lazy(() => import('./pages/Data').then(m => ({ default: m.Data })))
const Monitor = lazy(() => import('./pages/Monitor').then(m => ({ default: m.Monitor })))
@@ -48,6 +49,7 @@ const CORE_ROUTE_PATHS = new Set([
'/watchlist',
'/screener',
'/backtest',
'/mining',
'/financials',
'/data',
'/monitor',
@@ -119,6 +121,7 @@ export const router = createBrowserRouter([
{ path: 'watchlist', element: <Watchlist /> },
{ path: 'screener', element: <Screener /> },
{ path: 'backtest', element: <Backtest /> },
{ path: 'mining', element: <Mining /> },
{ path: 'financials', element: <Financials /> },
{ path: 'data', element: <Data /> },
{ path: 'monitor', element: <Monitor /> },