mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
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:
@@ -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
@@ -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'),
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user