复制 local 代码到 serve

This commit is contained in:
2026-07-04 16:59:25 +08:00
parent cee04c1b46
commit 0474e5fb46
302 changed files with 78290 additions and 0 deletions
+336
View File
@@ -0,0 +1,336 @@
import { useSyncExternalStore } from 'react'
import { api } from './api'
/**
* AI 财务分析 —— 全局任务/报告 store(与 UI 解耦)。
*
* 设计要点:
* 1. 流式接收逻辑在这里,与弹窗组件解耦 → 用户关闭/最小化弹窗,后台流照常累积。
* 2. useSyncExternalStore 订阅 → 任意组件(弹窗、气泡、历史面板)实时同步。
* 3. "活跃任务"上限 MAX_ACTIVE=3:同时进行的任务最多 3 个,超出拒绝新建。
* (历史报告名额 MAX_REPORTS=20 在后端裁剪,与活跃任务名额分离)
* 4. 同 symbol 已有活跃任务 → 直接聚焦那个,不新建第 2 个。
* 5. 任务完成(收到 done 或 content 非空且流结束)→ 自动存后端 + 移入历史 + 弹窗可恢复为"历史模式"。
*/
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
export interface ActiveTask {
id: string // 任务 id(前端生成,与最终 report id 解耦)
symbol: string
name: string
focus: string
phase: Phase
content: string // 累积的 Markdown
error: string
meta: { summary?: string; periods?: number } | null
createdAt: number // ms 时间戳
savedReportId?: string // 完成后存到后端的报告 id
doneAt?: number // 进入 done/error 态的时间戳(用于气泡过期清理)
dismissed?: boolean // 用户已从气泡点击查看过 → 不再在气泡显示
}
export interface HistoryReport {
id: string
symbol: string
name: string
focus: string
content: string
periods?: number
summary?: string
created_at: string
}
const MAX_ACTIVE = 3
// ===== 全局状态 =====
let activeTasks: ActiveTask[] = []
let history: HistoryReport[] = []
let historyLoaded = false
const listeners = new Set<() => void>()
// 当前"前台"展示的任务:
// - 活跃任务 id(正在生成/刚完成,对话框打开)
// - 或 'history:<id>'(查看历史报告)
// - 或 null(对话框关闭/最小化)
let activeDialogTaskId: string | null = null
let dialogMinimized = false // 对话框是否最小化为气泡
function emit() { listeners.forEach(fn => fn()) }
function subscribe(fn: () => void) {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
function normalizeAiError(msg: string) {
return msg.includes('API Key') || msg.includes('api_key')
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
: msg
}
// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。
// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。
let _activeSnap: ActiveTask[] = []
let _historySnap: HistoryReport[] = []
interface DialogSnap { taskId: string | null; minimized: boolean }
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
function rebuildSnap() {
_activeSnap = activeTasks
_historySnap = history
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
}
function getActiveSnapshot() { return _activeSnap }
function getHistorySnapshot() { return _historySnap }
function getDialogSnapshot() { return _dialogSnap }
function patchTask(id: string, patch: Partial<ActiveTask>) {
activeTasks = activeTasks.map(t => {
if (t.id !== id) return t
const next = { ...t, ...patch }
// 首次进入 done/error 态时记录 doneAt
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
next.doneAt = Date.now()
}
return next
})
rebuildSnap()
emit()
}
// ===== 公开:查询 hooks =====
export function useBubbleTasks(): ActiveTask[] {
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
// 同时订阅对话框状态:最小化/打开/关闭会改变气泡可见性,需独立触发重渲染。
// (否则最小化时 activeTasks 引用未变,useSyncExternalStore 不会重渲染,胶囊不出现)
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
const ds = _dialogSnap
return all.filter(t => {
// 进行中:始终显示(dismissed 仅作用于完成态,不影响生成中的任务再次最小化)
if (t.phase === 'loading' || t.phase === 'streaming') {
// 除非对话框正打开看着它(非最小化)
return !(ds.taskId === t.id && !ds.minimized)
}
// 完成/失败态:常驻显示,直到用户主动点击查看(dismissed)。
// 不设自动过期 —— 胶囊是持续可见的状态指示器,历史报告列表是查看入口。
if (t.dismissed) return false // 用户已点击查看过 → 移除
if (!ds.minimized && ds.taskId === t.id) return false // 对话框正展示 → 不重复
return true
})
}
/** 兼容旧调用名(Layout 等处可能引用) */
export const useActiveTasks = useBubbleTasks
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
return { reports, loaded: historyLoaded }
}
export function useDialogState() {
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
}
/** 当前对话框要展示的任务(活跃或历史),null=未打开。 */
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
const ds = useDialogState()
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
if (!ds.taskId) return { task: null, mode: null }
if (ds.taskId.startsWith('history:')) {
const rid = ds.taskId.slice('history:'.length)
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
}
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
}
// ===== 公开:动作 =====
/** 拉取历史报告(惰性,首次需要时调用)。 */
export async function loadHistory(): Promise<void> {
try {
const res = await api.financialReportsList()
history = res.reports ?? []
historyLoaded = true
rebuildSnap()
emit()
} catch {
// 静默失败,列表会显示空
}
}
/**
* 查询某只股票最近一次的历史分析报告(用于二次确认提示)。
* 若历史未加载,先触发拉取。
* @returns 最近一条报告,或 null
*/
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
// history 已按 created_at 降序,取第一条匹配
return history.find(r => r.symbol === symbol) ?? null
}
/**
* 启动一个新的 AI 分析任务。
* @returns 任务 id;若超出上限或已有活跃任务,返回 { error }。
*/
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
// 同 symbol 已有活跃任务 → 直接聚焦它
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
if (existing) {
activeDialogTaskId = existing.id
dialogMinimized = false
rebuildSnap()
emit()
return { id: existing.id }
}
// 上限检查
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
if (ongoing.length >= MAX_ACTIVE) {
return { error: `同时进行的分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
}
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const task: ActiveTask = {
id, symbol, name, focus,
phase: 'loading', content: '', error: '',
meta: null, createdAt: Date.now(),
}
activeTasks = [...activeTasks, task]
activeDialogTaskId = id
dialogMinimized = false
rebuildSnap()
emit()
// 启动流式接收(后台运行,不阻塞)
runStream(id, symbol, focus)
return { id }
}
async function runStream(id: string, symbol: string, focus: string) {
try {
let firstDelta = true
for await (const chunk of api.financialAnalyzeStream(symbol, focus)) {
// 任务可能已被取消(不在列表里了)→ 终止
const cur = activeTasks.find(t => t.id === id)
if (!cur) return
switch (chunk.type) {
case 'meta':
patchTask(id, { meta: { summary: chunk.summary, periods: chunk.periods } })
break
case 'delta':
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
patchTask(id, { content: cur.content + (chunk.content ?? '') })
break
case 'error':
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
return
case 'done':
// 标记完成,稍后持久化(content 可能还在最后几个 delta 里,以 done 时为准)
patchTask(id, { phase: 'done' })
break
}
}
// 流正常结束 → 持久化报告
const final = activeTasks.find(t => t.id === id)
if (final && final.phase !== 'error' && final.content) {
try {
const res = await api.financialReportSave({
symbol: final.symbol,
name: final.name,
focus: final.focus,
content: final.content,
periods: final.meta?.periods,
summary: final.meta?.summary ?? '',
})
if (res.report) {
patchTask(id, { savedReportId: res.report.id })
// 加到历史列表头部
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
historyLoaded = true
rebuildSnap()
emit()
// 任务完成:不自动弹出对话框,只在胶囊显示"已完成"态,用户想看再点。
// (若对话框正打开看此任务,内容已实时更新;最小化/在别处则胶囊亮起完成态)
}
} catch {
// 持久化失败不影响前端已展示的内容
}
}
} catch (e: any) {
const msg = String(e?.message ?? '分析失败')
patchTask(id, {
phase: 'error',
error: normalizeAiError(msg),
})
}
}
/** 打开对话框(活跃任务或历史报告)。 */
export function openDialog(taskId: string) {
activeDialogTaskId = taskId
dialogMinimized = false
rebuildSnap()
emit()
}
/** 最小化对话框 → 变成气泡。 */
export function minimizeDialog() {
dialogMinimized = true
rebuildSnap()
emit()
}
/** 关闭对话框(活跃任务继续在后台跑,仅移除对话框视图)。
* 对历史报告:仅关闭视图。
*/
export function closeDialog() {
activeDialogTaskId = null
dialogMinimized = false
rebuildSnap()
emit()
}
/** 从气泡恢复对话框。
* 仅对已完成/失败的任务标记 dismissed(看过结果就不必再弹);
* 生成中的任务不标记 —— 用户再次最小化时气泡应重新出现。
*/
export function restoreDialog(taskId: string) {
const t = activeTasks.find(x => x.id === taskId)
if (t && (t.phase === 'done' || t.phase === 'error')) {
patchTask(taskId, { dismissed: true })
}
activeDialogTaskId = taskId
dialogMinimized = false
rebuildSnap()
emit()
}
/** 重试一个失败/已完成的任务(以新任务方式重新分析)。 */
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
return startAnalysis(task.symbol, task.name, task.focus)
}
/** 删除历史报告。 */
export async function deleteReport(reportId: string): Promise<void> {
try {
await api.financialReportDelete(reportId)
history = history.filter(r => r.id !== reportId)
rebuildSnap()
emit()
} catch {
// 静默
}
}
/** 打开历史报告到对话框。 */
export function openHistoryReport(reportId: string) {
activeDialogTaskId = `history:${reportId}`
dialogMinimized = false
rebuildSnap()
emit()
}
+341
View File
@@ -0,0 +1,341 @@
/**
* 概念/行业分析 — 数据适配层
*
* 处理两种扩展数据结构:
* - 结构 A(个股维度):每行一只股票,维度字段(如 concept)存该股票所属的概念/行业
* - 结构 B(板块维度):每行一个概念/行业,带成分股列表(如 constituents: [...]
*
* 两种结构统一输出为 DimensionGroup[],供页面组件消费。
*/
import type { ExtDataConfig, ExtDataField, ExtDataRowsResult } from '@/lib/api'
// ===== 公共类型 =====
export interface StockRow {
symbol: string
code?: string
name?: string
[key: string]: unknown
}
export interface DimensionGroup {
/** 维度名称(概念名/行业名) */
key: string
/** 成分股数量 */
count: number
/** 成分股原始行 */
stocks: StockRow[]
/** 聚合指标(如涨跌幅均值等) */
metrics: Record<string, number | null>
}
export interface ResolvedDimension {
/** 是否成功解析 */
ok: boolean
/** 数据结构类型 */
structure: 'per_stock' | 'per_dimension' | 'unknown'
/** 维度字段名 */
dimensionField: string
/** 所有解析出的分组 */
groups: DimensionGroup[]
/** 原始全部行(结构 A 下为原始行,结构 B 下展平后的全部成分股) */
allStocks: StockRow[]
/** 解析提示 */
hint?: string
}
// ===== 结构探测 =====
const SEPARATORS = /[、,;|/\s]+/
const CONSTITUENT_KEYS = [
'constituents', '成分股', 'stocks', 'members', 'codes', 'list',
'symbol_list', 'stock_list', 'member_list',
]
const DIMENSION_NAME_KEYS = [
'name', '概念名称', '概念', '行业名称', '行业', '板块名称', '板块',
'concept', 'industry', 'sector', 'theme', 'title', 'label',
]
/** 检测行是否是"板块维度"结构(含成分股列表字段) */
function detectConstituentField(fields: ExtDataField[]): string | null {
return fields.find(f =>
CONSTITUENT_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k))
)?.name ?? null
}
/** 检测维度名称字段 */
function detectDimensionNameField(fields: ExtDataField[]): string | null {
return fields.find(f =>
DIMENSION_NAME_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k))
)?.name ?? null
}
/** 从候选名中选取最佳维度字段(结构 A) */
export function pickDimensionField(
fields: ExtDataField[],
candidates: string[],
): string {
const nonMeta = fields.filter(f =>
!['symbol', 'code', 'name', '股票简称', '股票代码', 'date'].includes(f.name)
)
for (const c of candidates) {
const m = nonMeta.find(f =>
f.name.toLowerCase().includes(c.toLowerCase()) ||
f.label?.toLowerCase().includes(c.toLowerCase())
)
if (m) return m.name
}
// 回退:第一个非数值字段
return nonMeta.find(f => f.dtype !== 'int' && f.dtype !== 'float')?.name ?? nonMeta[0]?.name ?? ''
}
/** 判断字段是否为数值类型 */
function isNumericField(f: ExtDataField): boolean {
return f.dtype === 'int' || f.dtype === 'float'
}
// ===== 结构 A 解析:个股维度 =====
function parsePerStock(
rows: Record<string, any>[],
dimensionField: string,
numericFields: string[],
): DimensionGroup[] {
const map = new Map<string, StockRow[]>()
for (const row of rows) {
const raw = row[dimensionField]
if (raw == null) continue
const text = String(raw).trim()
if (!text) continue
// 支持多值分隔(如 "人工智能,芯片,5G"
const values = text.split(SEPARATORS).map(s => s.trim()).filter(Boolean)
const stock: StockRow = { ...row, symbol: row.symbol ?? row.code ?? '' }
for (const v of values) {
const list = map.get(v) ?? []
list.push(stock)
map.set(v, list)
}
}
return [...map.entries()]
.map(([key, stocks]) => ({
key,
count: stocks.length,
stocks,
metrics: computeMetrics(stocks, numericFields),
}))
.sort((a, b) => b.count - a.count)
}
// ===== 结构 B 解析:板块维度 =====
function parsePerDimension(
rows: Record<string, any>[],
constituentField: string,
nameField: string,
numericFields: string[],
): DimensionGroup[] {
const allStocks: StockRow[] = []
const groups = rows.map(row => {
const key = String(row[nameField] ?? row[constituentField] ?? '').trim()
if (!key) return null
// 成分股可能是字符串数组、对象数组、逗号分隔字符串
const rawList = row[constituentField]
const stocks = parseConstituents(rawList)
stocks.forEach(s => { if (s.symbol) allStocks.push(s) })
// 维度自身的数值指标也保留
const metrics = computeMetrics(stocks, numericFields)
// 补上行级别的数值
for (const f of numericFields) {
if (typeof row[f] === 'number') {
metrics[`__dim_${f}`] = row[f]
}
}
return { key, count: stocks.length, stocks, metrics } as DimensionGroup
}).filter((g): g is DimensionGroup => g !== null && g.key !== '')
return groups.sort((a, b) => b.count - a.count)
}
/** 解析成分股字段(支持多种格式) */
function parseConstituents(raw: unknown): StockRow[] {
if (raw == null) return []
if (typeof raw === 'string') {
// 逗号/分隔符分隔的股票代码字符串
return raw.split(SEPARATORS).map(s => s.trim()).filter(Boolean).map(s => ({
symbol: normalizeSymbol(s),
code: s,
}))
}
if (Array.isArray(raw)) {
return raw.map(item => {
if (typeof item === 'string') {
return { symbol: normalizeSymbol(item), code: item }
}
if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, any>
return {
symbol: obj.symbol ?? obj.code ?? obj. ?? '',
code: obj.code ?? obj.symbol ?? '',
name: obj.name ?? obj. ?? obj. ?? '',
...obj,
}
}
return { symbol: String(item) }
})
}
return []
}
function normalizeSymbol(s: string): string {
// 尝试补全为 6 位代码
if (/^\d{6}$/.test(s)) return s
return s
}
// ===== 聚合指标计算 =====
function computeMetrics(
stocks: StockRow[],
numericFields: string[],
): Record<string, number | null> {
const result: Record<string, number | null> = {}
for (const f of numericFields) {
const vals = stocks
.map(s => s[f])
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
if (vals.length === 0) {
result[f] = null
} else {
result[f] = vals.reduce((a, b) => a + b, 0) / vals.length
}
}
return result
}
// ===== 主入口:自动探测 + 解析 =====
export function resolveDimension(
data: ExtDataRowsResult | null | undefined,
config: ExtDataConfig | null | undefined,
candidateFields: string[],
): ResolvedDimension {
if (!data || !config || !data.rows.length) {
return { ok: false, structure: 'unknown', dimensionField: '', groups: [], allStocks: [] }
}
const fields = data.fields ?? config.fields
const rows = data.rows
const numericFields = fields.filter(f => isNumericField(f)).map(f => f.name)
// 先检测是否为结构 B(板块维度)
const constituentField = detectConstituentField(fields)
if (constituentField) {
const nameField = detectDimensionNameField(fields) ?? 'name'
const groups = parsePerDimension(rows, constituentField, nameField, numericFields)
const allStocks = groups.flatMap(g => g.stocks)
return {
ok: true,
structure: 'per_dimension',
dimensionField: nameField,
groups,
allStocks,
hint: `检测到板块维度结构(成分股字段: ${constituentField}`,
}
}
// 结构 A(个股维度)
const dimensionField = pickDimensionField(fields, candidateFields)
if (!dimensionField) {
return {
ok: false,
structure: 'unknown',
dimensionField: '',
groups: [],
allStocks: rows as StockRow[],
hint: '未找到合适的维度字段',
}
}
const groups = parsePerStock(rows, dimensionField, numericFields)
const allStocks = rows as StockRow[]
return {
ok: true,
structure: 'per_stock',
dimensionField,
groups,
allStocks,
hint: `${dimensionField} 分组,共 ${groups.length} 个维度`,
}
}
// ===== 行情数据关联 =====
export interface QuoteMap {
symbol: string
price?: number
pct?: number
change_pct?: number
name?: string
[key: string]: unknown
}
/** 构建 symbol → quote 的快速查找 */
export function buildQuoteMap(quotes: QuoteMap[]): Map<string, QuoteMap> {
const map = new Map<string, QuoteMap>()
for (const q of quotes) {
if (q.symbol) map.set(q.symbol, q)
// 也用纯数字代码做索引
const code = q.symbol?.replace(/\.\w+$/, '')
if (code) map.set(code, q)
}
return map
}
/** 为分组计算行情聚合指标 */
export function computeQuoteMetrics(
stocks: StockRow[],
quoteMap: Map<string, QuoteMap>,
): {
avgPct: number | null
upCount: number
downCount: number
flatCount: number
totalVolume: number
} {
let up = 0, down = 0, flat = 0, totalVol = 0
let sumPct = 0, countPct = 0
for (const s of stocks) {
const sym = String(s.symbol ?? '')
const q = quoteMap.get(sym) ?? quoteMap.get(sym.replace(/\.\w+$/, ''))
if (!q) continue
const pct = q.pct ?? q.change_pct
if (pct != null && typeof pct === 'number' && Number.isFinite(pct)) {
sumPct += pct
countPct++
if (pct > 0) up++
else if (pct < 0) down++
else flat++
}
totalVol += (typeof q.price === 'number' ? 1 : 0) // 简化计数
}
return {
avgPct: countPct > 0 ? sumPct / countPct : null,
upCount: up,
downCount: down,
flatCount: flat,
totalVolume: totalVol,
}
}
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
import { useSyncExternalStore } from 'react'
import type { StrategyBacktestResult } from './api'
/**
* 全局回测任务管理 (SSE 模式 + 任务缓存 + 重连支持)。
*
* 特性:
* - 实时进度: EventSource 监听后端 SSE, 推送 day/total/equity
* - 可取消: POST /strategy/cancel/{job_key}, 后端 cancel_event
* - 切页/刷新保持: 后端按参数 hash 缓存任务, 重连不重启
* - 切页: 模块级 store 保持, EventSource 随组件卸载断开, 回来后重连
* - 刷新: localStorage 存 job 参数, 刷新后重新连接到同一任务
*/
export interface BacktestProgress {
day: number
total: number
date: string
equity: number
}
export interface BacktestTask {
id: number
isPending: boolean
result: StrategyBacktestResult | null
progress: BacktestProgress | null
error: string | null
}
let current: BacktestTask | null = null
const listeners = new Set<() => void>()
let taskSeq = 0
let eventSource: EventSource | null = null
const RECONNECT_KEY = 'backtest_reconnect'
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function getSnapshot() {
return current
}
function getServerSnapshot() {
return null
}
/** 查询字符串构建 */
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') sp.set(k, String(v))
}
return sp.toString()
}
/** 连接 SSE (新建或重连都用这个) */
function connectSSE(url: string): void {
const id = current?.id ?? ++taskSeq
// 关闭旧连接
if (eventSource) {
eventSource.close()
eventSource = null
}
const es = new EventSource(url)
eventSource = es
es.addEventListener('progress', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const prog = JSON.parse(e.data) as BacktestProgress
current = { ...current, progress: prog }
emit()
} catch { /* ignore */ }
})
es.addEventListener('done', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const result = JSON.parse(e.data) as StrategyBacktestResult
current = { ...current, isPending: false, result, error: null }
emit()
} catch {
current = { ...current, isPending: false, error: '结果解析失败' }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
})
es.addEventListener('error', (e: MessageEvent) => {
if (current?.id !== id) return
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
if (e.data) {
try {
const msg = JSON.parse(e.data)?.message ?? '回测出错'
current = { ...current, isPending: false, error: msg }
emit()
} catch {
current = { ...current, isPending: false, error: '回测出错' }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
}
// 无 data: 连接异常断开, EventSource 会自动重连, 不改变状态
})
}
/** 启动一次 SSE 回测任务 */
export function startBacktest(params: {
strategy_id: string
symbols?: string[] | null
start?: string | null
end?: string | null
matching?: string
entry_fill?: string
exit_fill?: string
fees_pct?: number
slippage_bps?: number
max_positions?: number
max_exposure_pct?: number
initial_capital?: number
position_sizing?: string
params?: Record<string, any> | null
overrides?: Record<string, any> | null
mode?: 'position' | 'full'
holding_days?: number
}): void {
// 取消之前的任务状态
if (eventSource) {
eventSource.close()
eventSource = null
}
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
const qs = buildQuery({
strategy_id: params.strategy_id,
symbols: params.symbols?.join(','),
start: params.start ?? undefined,
end: params.end ?? undefined,
matching: params.matching,
entry_fill: params.entry_fill,
exit_fill: params.exit_fill,
fees_pct: params.fees_pct,
slippage_bps: params.slippage_bps,
max_positions: params.max_positions,
max_exposure_pct: params.max_exposure_pct,
initial_capital: params.initial_capital,
position_sizing: params.position_sizing,
params: params.params ? JSON.stringify(params.params) : undefined,
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
mode: params.mode,
holding_days: params.holding_days,
})
// 存 reconnect 信息 (刷新后用)
localStorage.setItem(RECONNECT_KEY, qs)
connectSSE(`/api/backtest/strategy/stream?${qs}`)
}
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
export async function stopBacktest(): Promise<void> {
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
const qs = localStorage.getItem(RECONNECT_KEY)
if (qs) {
// 解析出参数, 用 fetch 调 cancel
try {
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
// 后端会找到同一个 job 并返回它的 job_key? 不行。
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
await fetch('/api/backtest/strategy/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qs }),
}).catch(() => {})
} catch { /* ignore */ }
}
if (eventSource) {
eventSource.close()
eventSource = null
}
if (current?.isPending) {
current = { ...current, isPending: false, error: '已取消' }
emit()
}
localStorage.removeItem(RECONNECT_KEY)
}
/** 清除任务状态 (隐藏提示) */
export function clearBacktest(): void {
current = null
emit()
}
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
export function tryReconnect(): boolean {
const qs = localStorage.getItem(RECONNECT_KEY)
if (!qs) return false
// 有未完成的任务, 重连
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
connectSSE(`/api/backtest/strategy/stream?${qs}`)
return true
}
/** React hook: 读取当前全局回测任务状态 */
export function useBacktestTask(): BacktestTask | null {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
+25
View File
@@ -0,0 +1,25 @@
// 板块判断工具函数
export const BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] as const
export type BoardType = (typeof BOARDS)[number]
/** 根据股票代码判断板块 */
export function getBoardType(symbol: string): BoardType | null {
if (/^(300|301)/.test(symbol)) return '创业板'
if (/^688/.test(symbol)) return '科创板'
if (/\.BJ$/.test(symbol)) return '北交所'
if (/^60[0135]/.test(symbol)) return '沪主板'
if (/^00[012]/.test(symbol)) return '深主板'
return null
}
/** 板块简称标签: 主板返回空字符串(不显示), 创/科/北 等返回简称 */
export function boardTag(symbol: string): string {
const b = getBoardType(symbol)
if (!b) return ''
if (b === '沪主板' || b === '深主板') return ''
if (b === '创业板') return '创'
if (b === '科创板') return '科'
if (b === '北交所') return '北'
return ''
}
@@ -0,0 +1,106 @@
// capability 内部名 → 用户能理解的中文标签
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'quote.by_symbol': { name: '自选股实时监控', hint: 'Free 可按标的查询实时行情,用于少量自选股监控' },
'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' },
'quote.pool': { name: '标的池查询', hint: '按沪深300等池子拿行情' },
'kline.daily.by_symbol': { name: '日 K(按标的)', hint: '单只股票历史日 K' },
'kline.daily.batch': { name: '日 K(批量)', hint: '一次拿多只股票的日 K — 选股 / 信号扫描 必需' },
'kline.minute.by_symbol': { name: '分钟 K(按标的)', hint: '单股 1m/5m/15m/30m/60m K 线' },
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
}
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
// none = None 档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
export const TIER_RANK: Record<string, number> = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 }
export const EXPERT_RANK = TIER_RANK.expert
export function tierRank(label: string): number {
const base = (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
return TIER_RANK[base] ?? -1
}
export function isExpertOrAbove(label: string): boolean {
return tierRank(label) >= EXPERT_RANK
}
/** 档位完整样式(tag 背景 + 圆点 + 文字渐变), 与左侧菜单 TierBadge 一致 */
export interface TierStyle {
tagBg: { background: string }
dotStyle: { background: string }
labelTextStyle: { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string }
desc: string
}
const TIER_STYLE: Record<string, TierStyle> = {
none: {
desc: '未配置 Key · 仅历史日K',
tagBg: { background: 'rgba(113,113,122,0.15)' },
dotStyle: { background: '#52525b' },
labelTextStyle: { color: '#71717a' },
},
free: {
desc: '历史日K · 自选实时',
tagBg: { background: 'rgba(113,113,122,0.3)' },
dotStyle: { background: '#71717a' },
labelTextStyle: { color: '#a1a1aa' },
},
starter: {
desc: '除权因子 · 全市场实时',
tagBg: { background: 'rgba(59,130,246,0.2)' },
dotStyle: { background: '#3b82f6' },
labelTextStyle: { color: '#60a5fa' },
},
pro: {
desc: '分钟K · 盘口',
tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' },
dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
expert: {
desc: 'WebSocket · 财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
}
/** 从档位 label 提取基础档位名(小写): "Expert +" → "expert" */
export function tierBaseName(label: string): string {
return (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase()
}
/** 返回档位完整样式 */
export function tierStyle(label: string): TierStyle {
return TIER_STYLE[tierBaseName(label)] ?? TIER_STYLE.free
}
/** 所有档位(有序, 供档位列表渲染) */
export const ALL_TIERS = ['none', 'free', 'starter', 'pro', 'expert'] as const
/** 返回档位标签的渐变文字样式(用于大字显示, 如 Keys 页档位) */
export function tierTextStyle(label: string): { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } {
return tierStyle(label).labelTextStyle
}
/** 渲染档位 tag(与左侧菜单一致的胶囊样式) */
export function TierTag({ label, className = '' }: { label: string; className?: string }) {
const t = tierStyle(label)
const base = tierBaseName(label)
// none 档显示英文「None」,其余档显示英文档名
const display = base === 'none' ? 'None' : base
return (
<span
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
style={t.tagBg}
>
<span className="truncate capitalize" style={t.labelTextStyle}>{display}</span>
</span>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 全局颜色别名 — 集中管理项目中重复使用的色彩组合。
*
* 不修改 Tailwind 配置和 CSS 变量,仅作为语义化引用。
* 使用方式:
* import { color } from '@/lib/colors'
* className={`${color.select.bg} ${color.select.text}`}
* // → bg-sky-500 text-sky-400
*/
export const color = {
/** 选中/激活态 — sky 色系 */
select: {
bg: 'bg-sky-500',
text: 'text-sky-400',
border: 'border-sky-400/40',
bgLight: 'bg-sky-400/10',
borderHover: 'hover:border-sky-400/30',
},
/** 选股条件区 section 标题色 */
filterSection: 'text-sky-400',
/** 评分权重正常指示 — emerald 色系 */
ok: 'text-emerald-400',
/** 评分权重异常警告 — amber 色系 */
scoreWarn: 'text-amber-400',
/** 交易参数区 section 标题色 */
tradeSection: 'text-emerald-400',
} as const
+81
View File
@@ -0,0 +1,81 @@
// 数字 / 价格 / 涨跌幅 格式化(§6.0.2 等宽数字)
export function fmtPrice(v: number | null | undefined, digits = 2): string {
if (v == null || Number.isNaN(v)) return '—'
return v.toFixed(digits)
}
export function fmtPct(v: number | null | undefined, digits = 2): string {
if (v == null || Number.isNaN(v)) return '—'
const sign = v > 0 ? '+' : ''
return `${sign}${(v * 100).toFixed(digits)}%`
}
export function fmtVolume(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
if (v >= 1e8) return `${(v / 1e8).toFixed(2)}亿`
if (v >= 1e4) return `${(v / 1e4).toFixed(2)}`
return v.toFixed(0)
}
// A 股语义色:红涨绿跌 → 仅用于价格相关元素
export function priceColorClass(v: number | null | undefined): string {
if (v == null || Number.isNaN(v) || v === 0) return 'text-muted'
return v > 0 ? 'text-bull' : 'text-bear'
}
export function fmtBigNum(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
if (v >= 1_000_000_000_000) return `${(v / 1_000_000_000_000).toFixed(2)}万亿`
if (v >= 100_000_000) return `${(v / 100_000_000).toFixed(2)}亿`
if (v >= 10_000) return `${(v / 10_000).toFixed(0)}`
return v.toFixed(0)
}
export function fmtDate(s: string | Date | null | undefined): string {
if (s == null) return '—'
const d = typeof s === 'string' ? new Date(s) : s
if (isNaN(d.getTime())) return String(s)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// ===== Data 页面工具函数 =====
export function formatNumber(n: number): string {
if (n >= 100_000_000) return `${(n / 100_000_000).toFixed(1)}亿`
if (n >= 10_000) return `${(n / 10_000).toFixed(1)}`
return n.toLocaleString()
}
export function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`
const m = Math.floor(seconds / 60)
const s = seconds % 60
if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`
const h = Math.floor(m / 60)
const rm = m % 60
return rm > 0 ? `${h}h ${rm}m` : `${h}h`
}
export function formatScheduleDatePart(iso: string): string {
const d = new Date(iso)
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function formatScheduleTimePart(iso: string): string {
const d = new Date(iso)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export function isToday(iso: string): boolean {
const d = new Date(iso)
const now = new Date()
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
}
export function formatLogTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
}
+199
View File
@@ -0,0 +1,199 @@
/**
* 通用列表列配置底座。
*
* 业务页面只负责定义内置列、分组和持久化 adapter;拖拽、显隐、扩展列参数等
* 公共能力集中在这里,避免每个股票列表重复实现。
*/
export type ColumnSource =
| { type: 'builtin'; key: string }
| { type: 'ext'; configId: string; fieldName: string; fieldLabel?: string }
| { type: 'computed'; key: string }
/** 扩展列字符串值渲染配置 */
export interface ExtColumnDisplayConfig {
/** 显示模式: tag=分隔为标签, text=纯文本 */
displayMode: 'tag' | 'text'
/** 自定义分隔符,留空使用默认 [、,,;;-] */
separator?: string
/** 列最大宽度 CSS 值,如 "200px" */
maxWidth?: string
/** 标签显示上限,0 或 undefined=全部显示 */
maxTags?: number
/** 隐藏的标签索引(0-based),在 maxTags 范围内按位置隐藏 */
hiddenIndices?: number[]
/** 标签排列方向: horizontal=横向(默认), vertical=竖向 */
tagLayout?: 'horizontal' | 'vertical'
}
/** 日k列渲染配置(builtin: candle 列专用) */
export interface CandleColumnConfig {
/** 开启(显示蜡烛图)时单元格宽度 px */
enabledWidth?: number
/** 开启时单元格高度 px */
enabledHeight?: number
/** 关闭(收起)时单元格宽度 px */
disabledWidth?: number
/** 关闭时单元格高度 px */
disabledHeight?: number
/** 显示最近多少个交易日的日k */
days?: number
}
/** 日k列配置默认值(与改造前 MiniCandlestick 硬编码值一致) */
export const DEFAULT_CANDLE_CONFIG: Required<CandleColumnConfig> = {
enabledWidth: 100,
enabledHeight: 80,
disabledWidth: 40,
disabledHeight: 40,
days: 12,
}
/** 数值边界(设置过大取上限,过小取最小值) */
const CANDLE_BOUNDS = {
enabledWidth: { min: 40, max: 300 },
enabledHeight: { min: 32, max: 200 },
disabledWidth: { min: 20, max: 200 },
disabledHeight:{ min: 20, max: 200 },
days: { min: 1, max: 60 },
} as const
function clampNum(v: unknown, bounds: { min: number; max: number }, fallback: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? v : fallback
return Math.min(bounds.max, Math.max(bounds.min, n))
}
/**
* 合并用户配置与默认值,并对越界数值做钳制(过大取上限,过小取最小值)。
* 返回字段齐全的配置,调用方可直接解构使用。
*/
export function resolveCandleConfig(cfg: CandleColumnConfig | undefined): Required<CandleColumnConfig> {
const c = cfg ?? {}
return {
enabledWidth: clampNum(c.enabledWidth, CANDLE_BOUNDS.enabledWidth, DEFAULT_CANDLE_CONFIG.enabledWidth),
enabledHeight: clampNum(c.enabledHeight, CANDLE_BOUNDS.enabledHeight, DEFAULT_CANDLE_CONFIG.enabledHeight),
disabledWidth: clampNum(c.disabledWidth, CANDLE_BOUNDS.disabledWidth, DEFAULT_CANDLE_CONFIG.disabledWidth),
disabledHeight: clampNum(c.disabledHeight, CANDLE_BOUNDS.disabledHeight, DEFAULT_CANDLE_CONFIG.disabledHeight),
days: clampNum(c.days, CANDLE_BOUNDS.days, DEFAULT_CANDLE_CONFIG.days),
}
}
export interface ColumnConfig {
id: string // 唯一标识,如 "builtin:price" 或 "ext:my_table:score"
source: ColumnSource
label: string // 用户看到的表头名
visible: boolean // 是否显示
pinned?: boolean // 固定列不可隐藏(代码/名称、操作)
align?: 'left' | 'center' | 'right'
/** 扩展列显示配置(仅 ext 类型生效) */
extDisplay?: ExtColumnDisplayConfig
/** 日k列渲染配置(仅 builtin: candle 列生效) */
candleConfig?: CandleColumnConfig
/** 信息条场景:是否单独占一行显示(仅 StockInfoBar 生效,表格场景忽略) */
standalone?: boolean
}
export interface ColumnGroup {
id: string
label: string
icon?: string
/** builtin/computed source key 列表 */
keys: string[]
}
export const DEFAULT_ACTION_COLUMN_ID = 'builtin:action'
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action */
export function serializeColumns(
columns: ColumnConfig[],
actionColumnId = DEFAULT_ACTION_COLUMN_ID,
): ColumnConfig[] {
return columns.filter(c => !c.pinned && c.id !== actionColumnId)
}
export interface MergeColumnsOptions {
actionColumnId?: string
pinnedFirstIds?: string[]
}
/** 合并用户保存的列与默认列,保留用户顺序并补齐新增默认列。 */
export function mergeColumns(
saved: ColumnConfig[] | null | undefined,
defaults: ColumnConfig[],
options: MergeColumnsOptions = {},
): ColumnConfig[] {
const actionColumnId = options.actionColumnId ?? DEFAULT_ACTION_COLUMN_ID
const pinnedFirstIds = options.pinnedFirstIds ?? ['builtin:symbol']
const normalizedSaved = Array.isArray(saved) ? saved : []
const result: ColumnConfig[] = []
const savedMap = new Map(normalizedSaved.map(c => [c.id, c]))
const defaultMap = new Map(defaults.map(c => [c.id, c]))
// 1. 按用户保存顺序排列
for (const col of normalizedSaved) {
if (!col || col.id === actionColumnId) continue
const def = defaultMap.get(col.id)
if (def) {
// 内置列: label/source/align/pinned 以默认定义为准;visible 使用用户配置;
// 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay、信息条 standalone)需保留,否则刷新后丢失
result.push({
...def,
visible: col.visible,
...(col.candleConfig ? { candleConfig: col.candleConfig } : {}),
...(col.extDisplay ? { extDisplay: col.extDisplay } : {}),
...(col.standalone ? { standalone: col.standalone } : {}),
})
} else if (col.source?.type === 'ext') {
// ext 列: 保留用户配置,清理旧 label 中的括号后缀
let extCol = col
if (col.label.includes('(') || col.label.includes('')) {
extCol = {
...col,
label: col.source.fieldLabel || col.label.replace(/[(].*/, '').trim() || col.source.fieldName,
}
}
result.push(extCol)
}
}
// 2. 补充新增的默认列
for (const def of defaults) {
if (!savedMap.has(def.id)) result.push(def)
}
// 3. 固定优先列放到最前,例如代码/名称
for (let i = pinnedFirstIds.length - 1; i >= 0; i -= 1) {
const id = pinnedFirstIds[i]
const idx = result.findIndex(c => c.id === id)
if (idx > 0) {
const [col] = result.splice(idx, 1)
result.unshift(col)
}
}
return result
}
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口。 */
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
return columns
.filter(c => c.visible && c.source.type === 'ext')
.map(c => `${(c.source as { type: 'ext'; configId: string; fieldName: string }).configId}.${(c.source as { type: 'ext'; configId: string; fieldName: string }).fieldName}`)
.join(',')
}
/** 根据 ext schema 数据创建 ext 列配置。 */
export function createExtColumn(
configId: string,
_configLabel: string,
fieldName: string,
fieldLabel?: string,
): ColumnConfig {
return {
id: `ext:${configId}:${fieldName}`,
source: { type: 'ext', configId, fieldName, fieldLabel },
label: fieldLabel || fieldName,
visible: false,
align: 'center',
}
}
+118
View File
@@ -0,0 +1,118 @@
import { useSyncExternalStore } from 'react'
/**
* 监控中心未读触发记录徽标 — 全局 store + localStorage 持久化。
*
* 核心逻辑:
* - 在监控中心页面时, 每次收到新推送都同步更新 lastSeen (看到=已读)
* - 离开监控中心后, 新推送才计入未读
* - 刷新页面从 localStorage 恢复 lastSeen, 未读 = 期间新增
*/
const STORAGE_KEY = 'monitor_last_seen_total'
let currentTotal = 0
let lastSeenTotal = readSeen()
let onMonitorPage = false // 当前是否在监控中心页面
let pendingSeen = false // Monitor mount 请求 markSeen, 等 currentTotal 就绪
const listeners = new Set<() => void>()
function readSeen(): number {
try {
const v = localStorage.getItem(STORAGE_KEY)
if (v === null) return -1 // 从未设置过 → 未初始化
return parseInt(v, 10) || 0
} catch {
return -1
}
}
function writeSeen(v: number) {
try { localStorage.setItem(STORAGE_KEY, String(v)) } catch { /* ignore */ }
}
function syncSeen() {
if (lastSeenTotal !== currentTotal) {
lastSeenTotal = currentTotal
writeSeen(currentTotal)
}
}
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
function getSnapshot() {
return Math.max(0, currentTotal - lastSeenTotal)
}
/** 轮询更新最新总数 (Layout 层调用)。 */
export function setCurrentTotal(total: number): void {
// total=0 视为未初始化, 不更新 (避免渲染期 data=undefined 传 0 重置 lastSeen)
if (total <= 0) return
// 首次初始化: lastSeen <= 0 (从未设置 -1, 或历史为 0) → 把已读基线设为当前总数
// 否则 lastSeen=0 + total=1 会被误算成"1条未读" (首次进入就显示徽标的 bug)
if (lastSeenTotal <= 0) {
lastSeenTotal = total
writeSeen(total)
}
// 总数减少 (清空) → 同步重置
if (total < lastSeenTotal) {
lastSeenTotal = total
writeSeen(total)
}
const changed = total !== currentTotal
currentTotal = total
// 消费 pending markSeen
if (pendingSeen) {
pendingSeen = false
syncSeen()
}
// ★ 在监控中心页面期间: 收到新推送立即同步 (看到=已读, 不计入未读)
else if (onMonitorPage && changed) {
syncSeen()
}
emit()
}
/** 进入监控页时调用。 */
export function markSeen(): void {
onMonitorPage = true
if (currentTotal > 0) {
syncSeen()
emit()
} else {
pendingSeen = true
}
}
/** 离开监控页时调用 (停止同步, 之后新增才计入未读)。 */
export function leaveMonitorPage(): void {
onMonitorPage = false
pendingSeen = false
// 不在此处 syncSeen — lastSeen 保持页面期间最后一次同步的值即可
// (避免 currentTotal 此刻还没刷新到最新, 写入偏小的值)
}
/** 记录被清空时调用。 */
export function resetBadge(): void {
currentTotal = 0
lastSeenTotal = 0
pendingSeen = false
writeSeen(0)
emit()
}
/** 读取当前未读数。 */
export function useUnreadAlerts(): number {
return useSyncExternalStore(subscribe, getSnapshot, () => 0)
}
+154
View File
@@ -0,0 +1,154 @@
/**
* 通知声效 — 用 Web Audio API 合成, 无需音频文件。
*
* 声效列表 (纯代码合成, 不同频率/波形/节奏):
* - ding: 清脆"叮" (默认, 适合一般提醒)
* - chime: 两音阶风铃 (适合策略信号)
* - alert: 急促警报 (适合重要/异动)
* - soft: 柔和低音 (适合价格提醒)
* - none: 无声
*/
let _audioCtx: AudioContext | null = null
function getCtx(): AudioContext | null {
try {
if (!_audioCtx) {
_audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
}
if (_audioCtx.state === 'suspended') _audioCtx.resume()
return _audioCtx
} catch {
return null
}
}
/** 播放单个音符 */
function playTone(ctx: AudioContext, freq: number, start: number, duration: number, type: OscillatorType = 'sine', gain: number = 0.15) {
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.type = type
osc.frequency.value = freq
osc.connect(g)
g.connect(ctx.destination)
const t = ctx.currentTime + start
g.gain.setValueAtTime(0, t)
g.gain.linearRampToValueAtTime(gain, t + 0.01)
g.gain.exponentialRampToValueAtTime(0.001, t + duration)
osc.start(t)
osc.stop(t + duration + 0.05)
}
const SOUND_PRESETS: Record<string, (ctx: AudioContext) => void> = {
// 清脆"叮" — 一个正弦波短音
ding: (ctx) => {
playTone(ctx, 880, 0, 0.3, 'sine', 0.2)
},
// 两音阶风铃 — 高低两个音
chime: (ctx) => {
playTone(ctx, 660, 0, 0.25, 'sine', 0.15)
playTone(ctx, 990, 0.12, 0.35, 'sine', 0.15)
},
// 急促警报 — 三个快速方波
alert: (ctx) => {
playTone(ctx, 800, 0, 0.1, 'square', 0.12)
playTone(ctx, 800, 0.15, 0.1, 'square', 0.12)
playTone(ctx, 1000, 0.3, 0.15, 'square', 0.12)
},
// 柔和低音 — 低频三角波
soft: (ctx) => {
playTone(ctx, 440, 0, 0.5, 'triangle', 0.15)
playTone(ctx, 330, 0.2, 0.5, 'triangle', 0.12)
},
// 上升音阶 — C-E-G-C 递进 (积极感)
rise: (ctx) => {
playTone(ctx, 523, 0, 0.12, 'sine', 0.18) // C5
playTone(ctx, 659, 0.1, 0.12, 'sine', 0.18) // E5
playTone(ctx, 784, 0.2, 0.12, 'sine', 0.18) // G5
playTone(ctx, 1047, 0.3, 0.3, 'sine', 0.2) // C6
},
// 下降音阶 — C-A-F-D (消极/警示感)
fall: (ctx) => {
playTone(ctx, 523, 0, 0.15, 'sine', 0.18) // C5
playTone(ctx, 440, 0.12, 0.15, 'sine', 0.18) // A4
playTone(ctx, 349, 0.24, 0.15, 'sine', 0.18) // F4
playTone(ctx, 294, 0.36, 0.3, 'sine', 0.18) // D4
},
// 电子提示音 — 锯齿波短促
electronic: (ctx) => {
playTone(ctx, 1200, 0, 0.08, 'sawtooth', 0.1)
playTone(ctx, 1600, 0.06, 0.08, 'sawtooth', 0.1)
playTone(ctx, 1200, 0.12, 0.15, 'sawtooth', 0.1)
},
// 水滴 — 极高频短音, 清脆
drop: (ctx) => {
playTone(ctx, 1800, 0, 0.06, 'sine', 0.15)
playTone(ctx, 2400, 0.04, 0.1, 'sine', 0.12)
},
// 钟声 — 低频持续共鸣
bell: (ctx) => {
playTone(ctx, 523, 0, 0.8, 'sine', 0.15)
playTone(ctx, 784, 0.02, 0.8, 'sine', 0.1) // 泛音
playTone(ctx, 1047, 0.04, 0.6, 'sine', 0.06) // 高泛音
},
// 乒乓 — 两个交替音
pingpong: (ctx) => {
playTone(ctx, 1000, 0, 0.08, 'sine', 0.15)
playTone(ctx, 700, 0.1, 0.08, 'sine', 0.15)
playTone(ctx, 1000, 0.2, 0.08, 'sine', 0.15)
playTone(ctx, 700, 0.3, 0.15, 'sine', 0.15)
},
// 魔法 — 快速上升扫频感
magic: (ctx) => {
playTone(ctx, 400, 0, 0.05, 'sine', 0.12)
playTone(ctx, 600, 0.04, 0.05, 'sine', 0.12)
playTone(ctx, 800, 0.08, 0.05, 'sine', 0.12)
playTone(ctx, 1100, 0.12, 0.05, 'sine', 0.12)
playTone(ctx, 1400, 0.16, 0.2, 'sine', 0.15)
},
}
/** 播放通知声效 (从 localStorage 读配置) */
export function playNotificationSound() {
try {
const enabled = localStorage.getItem('alert_sound_enabled')
if (enabled === '0') return // 关闭声效
const sound = localStorage.getItem('alert_sound') || 'ding'
if (sound === 'none') return
const ctx = getCtx()
if (!ctx) return
const preset = SOUND_PRESETS[sound]
if (preset) preset(ctx)
} catch {
// 音频不可用时静默
}
}
/** 声效选项 (供设置页下拉) */
export const SOUND_OPTIONS = [
{ key: 'ding', label: '清脆叮' },
{ key: 'chime', label: '风铃' },
{ key: 'rise', label: '上升音阶' },
{ key: 'fall', label: '下降音阶' },
{ key: 'alert', label: '急促警报' },
{ key: 'electronic', label: '电子音' },
{ key: 'drop', label: '水滴' },
{ key: 'bell', label: '钟声' },
{ key: 'pingpong', label: '乒乓' },
{ key: 'magic', label: '魔法' },
{ key: 'soft', label: '柔和' },
{ key: 'none', label: '无声' },
]
/** 预览声效 (设置页点"试听"用) */
export function previewSound(sound: string) {
try {
const ctx = getCtx()
if (!ctx) return
const preset = SOUND_PRESETS[sound]
if (preset) preset(ctx)
} catch { /* ignore */ }
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 集中管理所有 React Query key。
*
* - 新增查询只需在此加一行,所有消费方自动引用。
* - SSE invalidation 基于 SSE_INVALIDATE_PREFIXES 列表,新增 key 无需改 useQuoteStream。
*/
// ===== Query Key 工厂 =====
export const QK = {
// 全局 / 共享 (Layout 预取)
capabilities: ['capabilities'] as const,
settings: ['settings'] as const,
endpoints: ['endpoints'] as const,
version: ['version'] as const,
preferences: ['preferences'] as const,
quoteStatus: ['quote-status'] as const,
quoteInterval: ['quote-interval'] as const,
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
indexQuotes: ['index-quotes'] as const,
indexList: ['index-list'] as const,
// Watchlist
watchlist: ['watchlist'] as const,
watchlistQuotes: ['watchlist-quotes'] as const,
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
instrumentSearch: (q: string) => ['instrument-search', q] as const,
// Screener
screener: ['screener'] as const,
screenerStrategies: ['screener-strategies'] as const,
screenerCached: (ext?: string) => ['screener-cached', ext] as const,
screenerKlineBatch: (symbols: string) => ['screener-kline-batch', symbols] as const,
marketSnapshot: ['market-snapshot'] as const,
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
// Backtest
backtestStatus: ['backtest-status'] as const,
// Data / Pipeline
dataStatus: ['data-status'] as const,
pipelineJobs: ['pipeline-jobs'] as const,
pipelineJob: (id: string) => ['pipeline-job', id] as const,
extData: ['ext-data'] as const,
extDataRows: (id: string, date?: string, limit?: number, columns?: string) => ['ext-data-rows', id, date, limit, columns] as const,
analysisMenus: ['analysis-menus'] as const,
analysisMenu: (id: string) => ['analysis-menu', id] as const,
// Kline
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
['kline', symbol, start, end, extColumns ?? ''] as const,
stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
klineMinute: (symbol: string, date: string) =>
['kline-minute', symbol, date] as const,
indexDaily: (symbol: string, start: string, end: string) =>
['index-daily', symbol, start, end] as const,
indexMinute: (symbol: string, date: string) =>
['index-minute', symbol, date] as const,
// Schema
extDataSchemaAll: ['ext-data-schema-all'] as const,
tableSchema: (table: string) => ['table-schema', table] as const,
// Custom Signals
customSignals: ['custom-signals'] as const,
customSignalsOptions: ['custom-signals-options'] as const,
// Monitor (监控规则 + 触发记录)
monitorRules: ['monitor-rules'] as const,
monitorRuleOptions: ['monitor-rule-options'] as const,
alerts: (source?: string) => ['alerts', source ?? ''] as const,
// AI 大盘复盘
reviewReports: ['review-reports'] as const,
// 概念涨幅轮动矩阵
rpsRotation: (days: number) => ['rps-rotation', days] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
// 新增需要 SSE 推送的查询,只需在此加一行
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
'quote-status',
'index-quotes',
'overview-market',
'limit-ladder',
'screener',
] as const
+220
View File
@@ -0,0 +1,220 @@
/**
* 复盘生成状态的全局单例 store —— 脱离 Review 组件生命周期。
*
* 解决的问题:生成中切换到其他页面,Review 组件卸载会丢失 phase/content。
* 本 store 把流式生成的状态提到模块级,组件卸载后流仍在后台继续跑,
* 回到页面订阅即可恢复显示。
*
* 设计:
* - 模块级 state(phase/content/meta/focus),唯一的生成实例
* - AbortController 存模块级 ref,组件卸载不中断流
* - 订阅者列表(notify 机制),Review mount 时订阅、unmount 时退订
*/
import { api } from '@/lib/api'
export type ReviewPhase = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
export interface ReviewMeta {
as_of?: string
emotion_score?: number
emotion_label?: string
summary?: string
}
export interface ReviewState {
phase: ReviewPhase
content: string
error: string
meta: ReviewMeta | null
focus: string
}
const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null, focus: '' }
// ===== 模块级单例状态(组件卸载不销毁)=====
let state: ReviewState = { ...INITIAL }
let abortCtrl: AbortController | null = null
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
// 用于区分两条流, 避免互相丢弃事件或重复归档。
let generatingSource: 'manual' | 'sse' | null = null
// ===== 订阅机制 =====
type Listener = () => void
const listeners = new Set<Listener>()
function notify() {
for (const l of listeners) l()
}
export function getReviewState(): ReviewState {
return state
}
export function subscribeReview(listener: Listener): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
// 暴露给组件直接读取最新 meta(用于自动归档,避免闭包取旧值)
export function getReviewMeta(): ReviewMeta | null {
return state.meta
}
/** 是否正在生成(loading 或 streaming) */
export function isReviewGenerating(): boolean {
return state.phase === 'loading' || state.phase === 'streaming'
}
/**
* 启动复盘生成。返回后流在后台独立运行,组件卸载不影响。
* @param asOf 复盘日期
* @param focus 用户追加的复盘关注点
* @param onDone 完成回调(供调用方做自动归档)
*/
export async function startReviewGeneration(
asOf: string | undefined,
focus: string,
onDone?: (fullContent: string, meta: ReviewMeta | null) => void,
): Promise<void> {
// 已在生成中,不重复启动
if (isReviewGenerating()) return
generatingSource = 'manual'
state = { phase: 'loading', content: '', error: '', meta: null, focus }
notify()
abortCtrl = new AbortController()
let buf = ''
let failed = false
let doneMeta: ReviewMeta | null = null
try {
for await (const evt of api.reviewStream(asOf, focus)) {
if (abortCtrl.signal.aborted) break
if (evt.type === 'meta') {
doneMeta = evt
state = { ...state, meta: evt }
notify()
} else if (evt.type === 'delta' && evt.content) {
buf += evt.content
state = { ...state, content: buf, phase: 'streaming' }
notify()
} else if (evt.type === 'error') {
failed = true
state = { ...state, error: evt.message ?? '复盘失败', phase: 'error' }
notify()
return
} else if (evt.type === 'done') {
state = { ...state, phase: 'done' }
notify()
}
}
// 流正常结束但无 done 事件,按 done 处理
if (buf && !failed) {
state = { ...state, phase: 'done' }
notify()
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
if (buf && !failed) {
onDone?.(buf, doneMeta)
}
}
} catch (e: any) {
if (!abortCtrl.signal.aborted) {
state = { ...state, error: e?.message ?? '复盘失败', phase: 'error' }
notify()
}
} finally {
abortCtrl = null
generatingSource = null
}
}
/** 中断当前生成(供"查看历史"等场景主动中断流)。 */
export function abortReviewGeneration(): void {
abortCtrl?.abort()
abortCtrl = null
}
/** 设置当前查看的历史报告(把 store 状态切到 done + 该报告内容)。 */
export function setViewingReport(report: {
content: string
as_of?: string
emotion_score?: number | null
emotion_label?: string
summary?: string
}): void {
abortCtrl?.abort()
abortCtrl = null
state = {
phase: 'done',
content: report.content,
error: '',
meta: {
as_of: report.as_of,
emotion_score: report.emotion_score ?? undefined,
emotion_label: report.emotion_label,
summary: report.summary,
},
focus: state.focus,
}
notify()
}
/** 重置到 idle(清空当前显示)。 */
export function resetReview(): void {
state = { ...INITIAL }
notify()
}
/**
* 喂入一条来自 SSE 的复盘事件(定时生成时后端推来的)。
*
* 用途: 定时复盘在后端流式生成, 通过 /api/intraday/stream 的 review_progress 事件
* 把 meta/delta/done 等实时推给前端, 前端调本函数把事件写进 store ——
* 这样开着复盘页的用户能看到「边生成边显示」, 和手动点生成完全一致。
*
* 事件格式与 recap_market_stream 产出一致:
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
*
* 与手动生成的并发:
* - 若手动正在生成(isReviewGenerating), 忽略 SSE 事件(手动流优先, 避免冲突)。
* - done 带 archived=true(定时场景后端已归档): 不重复调归档接口, 仅切到 done 态。
* - retry: 后端 LLM 断流重试, 清空已累积内容重新开始。
*/
export function feedReviewEvent(evt: any): void {
if (!evt || typeof evt !== 'object') return
const t = evt.type
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
if (generatingSource === 'manual') return
if (t === 'meta') {
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
generatingSource = 'sse'
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
notify()
} else if (t === 'delta' && evt.content) {
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
if (generatingSource !== 'sse') return
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
notify()
} else if (t === 'retry') {
if (generatingSource !== 'sse') return
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
state = { ...state, content: '', phase: 'streaming' }
notify()
} else if (t === 'error') {
if (generatingSource !== 'sse') return
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
notify()
generatingSource = null
} else if (t === 'done') {
if (generatingSource !== 'sse') return
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
state = { ...state, phase: 'done' }
notify()
generatingSource = null
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 策略结果列表自定义列配置。
*
* 内置列与分组与自选页 (watchlist-columns) 保持一致,额外保留策略特有的
* 「策略」「评分」两列。通用列模型/合并/扩展列参数来自 list-columns。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam,
mergeColumns,
serializeColumns,
type ColumnConfig,
type ColumnGroup,
type ColumnSource,
type ExtColumnDisplayConfig,
type CandleColumnConfig,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
export { buildExtColumnsParam, mergeColumns, serializeColumns }
export const SCREENER_BUILTIN_COLUMNS: ColumnConfig[] = [
// 固定列
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '标的', visible: true, pinned: true, align: 'left' },
// 策略特有列
{ id: 'builtin:strategies', source: { type: 'builtin', key: 'strategies' }, label: '策略', visible: true, align: 'left' },
{ id: 'builtin:score', source: { type: 'builtin', key: 'score' }, label: '评分', visible: true, align: 'right' },
// 价格
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'right' },
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'right' },
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'right' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'right' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: false, align: 'right' },
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: true, align: 'right' },
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'right' },
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'right' },
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'right' },
// 均线
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'right' },
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'right' },
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'right' },
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'right' },
// 区间
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'right' },
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'right' },
// 技术指标
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'right' },
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'right' },
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'right' },
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'right' },
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'right' },
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'right' },
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'right' },
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'right' },
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'right' },
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'right' },
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'right' },
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'right' },
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'right' },
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'right' },
// 动量
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'right' },
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum_60d' }, label: '60D 动量', visible: true, align: 'right' },
// 连板
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
// 信号 & 图表
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'left' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
// 财务指标 (与自选页对齐; 当前后端 enriched 未返回这些字段,默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'right' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'right' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'right' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'right' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'right' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'right' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'right' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'right' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'right' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'right' },
]
export const SCREENER_COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'core', label: '核心', icon: '🎯', keys: ['strategies', 'score', 'signals'] },
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
]
export async function saveScreenerColumnConfig(columns: ColumnConfig[]): Promise<void> {
const saveable = serializeColumns(columns)
storage.screenerResultColumns.set(saveable)
try {
const { api } = await import('@/lib/api')
await api.updateScreenerResultColumns(saveable)
} catch {
// 后端不可用时 localStorage 仍有效
}
}
export async function loadScreenerColumnConfig(): Promise<ColumnConfig[]> {
try {
const { api } = await import('@/lib/api')
const res = await api.screenerResultColumns()
if (res.columns && res.columns.length > 0) {
const merged = mergeColumns(res.columns, SCREENER_BUILTIN_COLUMNS)
storage.screenerResultColumns.set(serializeColumns(merged))
return merged
}
} catch {
// 后端不可用,继续尝试 localStorage
}
const saved = storage.screenerResultColumns.get([]) as ColumnConfig[]
if (saved.length > 0) return mergeColumns(saved, SCREENER_BUILTIN_COLUMNS)
return [...SCREENER_BUILTIN_COLUMNS]
}
+170
View File
@@ -0,0 +1,170 @@
/**
* 买卖触发器信号定义 — 选股页弹窗 / 回测页共用。
*
* 信号 ID 必须与后端 backtest/strategy.py:_build_signal_mask 对齐
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
*/
export type SignalKind = 'entry' | 'exit' | 'both'
export interface BuiltinSignalDefinition {
id: string
name: string
kind: SignalKind
category: string
description: string
}
/** 内置原子信号清单 (权威展示来源, 两页统一) */
export const BUILTIN_SIGNAL_DEFINITIONS: BuiltinSignalDefinition[] = [
{
id: 'signal_ma_golden_5_20',
name: 'MA5上穿MA20',
kind: 'entry',
category: '均线',
description: '短期均线 MA5 上穿中期均线 MA20,常用于趋势转强确认。',
},
{
id: 'signal_ma_dead_5_20',
name: 'MA5下穿MA20',
kind: 'exit',
category: '均线',
description: '短期均线 MA5 下穿中期均线 MA20,常用于趋势转弱或止盈止损。',
},
{
id: 'signal_ma_golden_20_60',
name: 'MA20上穿MA60',
kind: 'entry',
category: '均线',
description: '中期均线 MA20 上穿长期均线 MA60,偏中线趋势信号。',
},
{
id: 'signal_macd_golden',
name: 'MACD金叉',
kind: 'entry',
category: 'MACD',
description: 'MACD DIF 上穿 DEA,表示动能可能由弱转强。',
},
{
id: 'signal_macd_dead',
name: 'MACD死叉',
kind: 'exit',
category: 'MACD',
description: 'MACD DIF 下穿 DEA,表示动能可能由强转弱。',
},
{
id: 'signal_ma20_breakout',
name: '突破MA20',
kind: 'entry',
category: '趋势',
description: '收盘价向上突破 MA20,常用于趋势突破买点。',
},
{
id: 'signal_ma20_breakdown',
name: '跌破MA20',
kind: 'exit',
category: '趋势',
description: '收盘价向下跌破 MA20,常用于趋势破位卖点。',
},
{
id: 'signal_n_day_high',
name: '60日新高',
kind: 'entry',
category: '趋势',
description: '收盘价创近 60 日新高,表示阶段强势或突破。',
},
{
id: 'signal_n_day_low',
name: '60日新低',
kind: 'exit',
category: '趋势',
description: '收盘价创近 60 日新低,表示阶段弱势或风险释放。',
},
{
id: 'signal_boll_breakout_upper',
name: '突破布林上轨',
kind: 'entry',
category: 'BOLL',
description: '价格突破布林上轨,偏强势突破或加速信号。',
},
{
id: 'signal_boll_breakdown_lower',
name: '跌破布林下轨',
kind: 'exit',
category: 'BOLL',
description: '价格跌破布林下轨,偏弱势破位或超跌风险信号。',
},
{
id: 'signal_volume_surge',
name: '放量',
kind: 'both',
category: '量价',
description: '成交量显著放大,可作为买入确认、卖出确认或告警条件。',
},
{
id: 'signal_limit_up',
name: '涨停',
kind: 'entry',
category: '涨跌停',
description: '收盘封住涨停,用于强势股、连板与市场情绪监控。',
},
{
id: 'signal_limit_down',
name: '跌停',
kind: 'exit',
category: '涨跌停',
description: '收盘触及跌停,用于风险控制与弱势监控。',
},
{
id: 'signal_limit_down_recovery',
name: '跌停翘板',
kind: 'entry',
category: '涨跌停',
description: '盘中触及跌停后回升,常用于短线情绪修复观察。',
},
{
id: 'signal_broken_limit_up',
name: '炸板',
kind: 'exit',
category: '涨跌停',
description: '盘中触及涨停但收盘未封住,用于强转弱或分歧监控。',
},
]
/** 内置原子信号 → 中文标签 */
export const SIGNAL_LABELS: Record<string, string> = BUILTIN_SIGNAL_DEFINITIONS.reduce<Record<string, string>>((acc, sig) => {
acc[sig.id] = sig.name
return acc
}, {})
/** 内置信号 ID 列表 */
export const SIGNAL_OPTIONS = BUILTIN_SIGNAL_DEFINITIONS.map(sig => sig.id)
/** 常用技术指标/字段 → 中文 (阈值条件展示用, 与后端 ENRICHED_COLUMNS 对齐) */
const FIELD_LABELS: Record<string, string> = {
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
change_pct: '涨跌幅', change_amount: '涨跌额', amplitude: '振幅',
turnover_rate: '换手率', volume: '成交量', amount: '成交额',
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
ema5: 'EMA5', ema10: 'EMA10', ema20: 'EMA20',
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
boll_upper: '布林上轨', boll_lower: '布林下轨',
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
rsi_6: 'RSI6', rsi_14: 'RSI14', rsi_24: 'RSI24',
vol_ratio_5d: '5日量比', vol_ratio_20d: '20日量比',
vol_ma5: '5日均量', vol_ma10: '10日均量',
high_60d: '60日最高', low_60d: '60日最低',
momentum_5d: '5日动量', momentum_20d: '20日动量', momentum_60d: '60日动量',
atr_14: 'ATR14', annual_vol_20d: '20日年化波动',
consecutive_limit_ups: '连板数', consecutive_limit_downs: '跌停连板',
}
/**
* 信号/字段 ID → 中文显示名。
* 内置信号查 SIGNAL_LABELS; csg_ 前缀查传入的自定义信号名称映射;
* 技术指标查 FIELD_LABELS; 都找不到则原样返回。
*/
export function cnSignal(name: string, customNames?: Record<string, string>): string {
if (customNames && name in customNames) return customNames[name]
return SIGNAL_LABELS[name] ?? FIELD_LABELS[name] ?? name
}
@@ -0,0 +1,82 @@
/**
* 个股日K信息条(StockInfoBar Row 2)的指标自定义配置。
*
* 与自选列表列配置同源:复用 list-columns 的通用列模型与合并/序列化底座,
* 仅做纯 localStorage 同步持久化(无后端双写)。个股预览弹窗与回测成交K线
* Modal 共用同一份配置。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam as buildExtColumnsParamBase,
mergeColumns as mergeColumnsBase,
serializeColumns as serializeColumnsBase,
type ColumnConfig,
type ColumnGroup,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup }
// ===== 内置指标注册表 =====
export const BUILTIN_INFO_FIELDS: ColumnConfig[] = [
// 规模
{ id: 'builtin:market_cap', source: { type: 'builtin', key: 'market_cap' }, label: '市值', visible: true, align: 'left' },
{ id: 'builtin:float_market_cap', source: { type: 'builtin', key: 'float_market_cap' }, label: '流通值', visible: true, align: 'left' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手', visible: true, align: 'left' },
{ id: 'builtin:volume', source: { type: 'builtin', key: 'volume' }, label: '成交量', visible: false, align: 'left' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'left' },
// 行情
{ id: 'builtin:open', source: { type: 'builtin', key: 'open' }, label: '开盘', visible: false, align: 'left' },
{ id: 'builtin:high', source: { type: 'builtin', key: 'high' }, label: '最高', visible: false, align: 'left' },
{ id: 'builtin:low', source: { type: 'builtin', key: 'low' }, label: '最低', visible: false, align: 'left' },
// 财务(数据来自 financials metrics 接口,默认隐藏;pe_ttm/pb 用 close 现算)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'left' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'left' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'left' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE', visible: false, align: 'left' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'left' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'left' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'left' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'left' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'left' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'left' },
]
export const INFO_GROUPS: ColumnGroup[] = [
{ id: 'scale', label: '规模', icon: '🏦', keys: ['market_cap', 'float_market_cap'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'volume', 'amplitude'] },
{ id: 'quote', label: '行情', icon: '📈', keys: ['open', 'high', 'low'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'] },
]
// ===== localStorage 持久化 =====
/** 加载信息条指标配置:localStorage → 默认值,自动补齐新增默认项。 */
export function loadInfoFields(): ColumnConfig[] {
const saved = storage.stockInfoBarFields.get([]) as ColumnConfig[]
if (saved.length === 0) return [...BUILTIN_INFO_FIELDS]
return mergeFields(saved, BUILTIN_INFO_FIELDS)
}
/** 保存信息条指标配置到 localStorage。 */
export function saveInfoFields(columns: ColumnConfig[]): void {
storage.stockInfoBarFields.set(serializeFields(columns))
}
/** 序列化(此处无 pinned/action 列,直接用底座默认实现)。 */
function serializeFields(columns: ColumnConfig[]): ColumnConfig[] {
return serializeColumnsBase(columns)
}
/** 从信息条字段配置中提取 ext 列参数(逗号分隔 config_id.field_name),用于 klineDaily 接口。 */
export function buildInfoExtColumnsParam(columns: ColumnConfig[]): string {
return buildExtColumnsParamBase(columns)
}
/** 合并用户保存的配置与默认配置。 */
function mergeFields(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
// 无固定列,传入空的 pinnedFirstIds 跳过「代码置顶」逻辑
return mergeColumnsBase(saved, defaults, { pinnedFirstIds: [] })
}
+108
View File
@@ -0,0 +1,108 @@
/**
* 股票列表共享逻辑(无 JSX):信号提取、排序取值、不可排序列集合。
*
* 自选页与策略页共用,避免两边各维护一份导致口径漂移(历史上策略页 14 个信号、
* 自选页 13 个,缺一个均线金叉/死叉变体)。
*/
import type { ColumnConfig } from '@/lib/list-columns'
// ===== 信号 =====
export type SignalType = 'bull' | 'bear' | 'neutral'
export interface Signal {
label: string
type: SignalType
}
/** 信号字段定义:[行字段名, 展示标签, 信号类型] */
export const SIGNAL_FIELDS: [string, string, SignalType][] = [
['signal_limit_up', '涨停', 'bull'],
['signal_broken_board_recovery', '反包', 'bull'],
['signal_volume_surge', '放量', 'neutral'],
['signal_ma_golden_5_20', 'MA金叉', 'bull'],
['signal_ma_dead_5_20', 'MA死叉', 'bear'],
['signal_ma_golden_20_60', '均线金叉', 'bull'],
['signal_macd_golden', 'MACD金叉', 'bull'],
['signal_macd_dead', 'MACD死叉', 'bear'],
['signal_ma20_breakout', '站上MA20', 'bull'],
['signal_ma20_breakdown', '跌破MA20', 'bear'],
['signal_n_day_high', '60日新高', 'bull'],
['signal_n_day_low', '60日新低', 'bear'],
['signal_boll_breakout_upper', '布林突破', 'bull'],
['signal_boll_breakdown_lower', '布林下破', 'bear'],
]
/** 从一行数据中提取已命中的信号列表 */
export function getSignals(r: Record<string, any>): Signal[] {
return SIGNAL_FIELDS.filter(([key]) => r[key]).map(([, label, type]) => ({ label, type }))
}
/** 信号类型 → tailwind 颜色类 */
export function signalCls(type: SignalType): string {
if (type === 'bull') return 'text-bull bg-bull/10'
if (type === 'bear') return 'text-bear bg-bear/10'
return 'text-accent bg-accent/10'
}
// ===== 排序 =====
/** 不可参与数值/文本排序的内置列 key(渲染为标签/图表,无单一标量值) */
export const UNSORTABLE_KEYS = new Set(['signals', 'candle', 'strategies'])
/**
* 取一列在某行上的排序标量值。builtin 列按 key 映射到行字段;ext 列走
* `${configId}__${fieldName}`;不可排序列返回 null。
*/
export function getSortValue(r: any, col: ColumnConfig): any {
if (col.source.type === 'ext') {
return r[`${col.source.configId}__${col.source.fieldName}`]
}
const key = col.source.key
switch (key) {
case 'symbol': return r.symbol
case 'price': return r.rt_price ?? r.close
case 'pct': return r.rt_pct ?? r.change_pct
case 'change_amount': return r.change_amount
case 'amplitude': return r.amplitude
case 'turnover': return r.turnover_rate
case 'amount': return r.rt_amount ?? r.amount
case 'float_val': return r.float_shares && (r.rt_price ?? r.close) ? r.float_shares * (r.rt_price ?? r.close) : null
case 'vol_ratio': return r.vol_ratio_5d
case 'annual_vol': return r.annual_vol_20d
case 'ma5': return r.ma5
case 'ma10': return r.ma10
case 'ma20': return r.ma20
case 'ma60': return r.ma60
case 'high_60d': return r.high_60d
case 'low_60d': return r.low_60d
case 'rsi6': return r.rsi_6
case 'rsi14': return r.rsi_14
case 'rsi24': return r.rsi_24
case 'macd_dif': return r.macd_dif
case 'macd_dea': return r.macd_dea
case 'macd_hist': return r.macd_hist
case 'kdj_k': return r.kdj_k
case 'kdj_d': return r.kdj_d
case 'kdj_j': return r.kdj_j
case 'boll_upper': return r.boll_upper
case 'boll_lower': return r.boll_lower
case 'atr14': return r.atr_14
case 'vol_ma5': return r.vol_ma5
case 'vol_ma10': return r.vol_ma10
case 'momentum_5d': return r.momentum_5d
case 'momentum_10d': return r.momentum_10d
case 'momentum_20d': return r.momentum_20d
case 'momentum_30d': return r.momentum_30d
case 'momentum_60d': return r.momentum_60d
case 'limit_ups': return r.consecutive_limit_ups ?? 0
case 'limit_downs': return r.consecutive_limit_downs ?? 0
case 'score': return r.score
default: return null
}
}
// ===== 共享样式 =====
/** 数值单元格统一样式(含 tabular-nums 等宽数字) */
export const NUM_CELL_CLASS = 'num tabular-nums'
@@ -0,0 +1,272 @@
import { useSyncExternalStore } from 'react'
import { api, type PriceLevel, type LevelType } from './api'
/**
* AI 个股分析 —— 全局任务/报告 store(与 aiReportStore 解耦、并行存在)。
*
* 与财务分析 store 的区别:
* - 独立的 activeTasks / history 状态池(不共享 3 并发上限)
* - meta 额外带 levels(关键价位),供图表回放
* - 状态文案在胶囊组件里用「蓝」色系区分(财务用紫)
*
* 设计同 aiReportStore:流式接收逻辑在此,与弹窗解耦 → 关闭弹窗后台照常累积。
*/
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
export interface ActiveTask {
id: string
symbol: string
name: string
focus: string
phase: Phase
content: string
error: string
meta: {
summary?: string
levels?: Record<LevelType, PriceLevel[]>
close?: number | null
} | null
createdAt: number
savedReportId?: string
doneAt?: number
dismissed?: boolean
}
export interface HistoryReport {
id: string
symbol: string
name: string
focus: string
content: string
summary?: string
close?: number | null
levels?: Record<LevelType, PriceLevel[]>
created_at: string
}
const MAX_ACTIVE = 3
let activeTasks: ActiveTask[] = []
let history: HistoryReport[] = []
let historyLoaded = false
const listeners = new Set<() => void>()
let activeDialogTaskId: string | null = null
let dialogMinimized = false
function emit() { listeners.forEach(fn => fn()) }
function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
function normalizeAiError(msg: string) {
return msg.includes('API Key') || msg.includes('api_key')
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
: msg
}
let _activeSnap: ActiveTask[] = []
let _historySnap: HistoryReport[] = []
interface DialogSnap { taskId: string | null; minimized: boolean }
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
function rebuildSnap() {
_activeSnap = activeTasks
_historySnap = history
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
}
function getActiveSnapshot() { return _activeSnap }
function getHistorySnapshot() { return _historySnap }
function getDialogSnapshot() { return _dialogSnap }
function patchTask(id: string, patch: Partial<ActiveTask>) {
activeTasks = activeTasks.map(t => {
if (t.id !== id) return t
const next = { ...t, ...patch }
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
next.doneAt = Date.now()
}
return next
})
rebuildSnap()
emit()
}
// ===== 查询 hooks =====
export function useBubbleTasks(): ActiveTask[] {
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
const ds = _dialogSnap
return all.filter(t => {
if (t.phase === 'loading' || t.phase === 'streaming') {
return !(ds.taskId === t.id && !ds.minimized)
}
if (t.dismissed) return false
if (!ds.minimized && ds.taskId === t.id) return false
return true
})
}
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
return { reports, loaded: historyLoaded }
}
export function useDialogState() {
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
}
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
const ds = useDialogState()
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
if (!ds.taskId) return { task: null, mode: null }
if (ds.taskId.startsWith('history:')) {
const rid = ds.taskId.slice('history:'.length)
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
}
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
}
// ===== 动作 =====
export async function loadHistory(): Promise<void> {
try {
const res = await api.stockAnalysisReportsList()
history = res.reports ?? []
historyLoaded = true
rebuildSnap()
emit()
} catch { /* 静默 */ }
}
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
return history.find(r => r.symbol === symbol) ?? null
}
/**
* 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
* 判断依据:created_at 的日期部分 == 本地今天。
* @returns 当天最近一条报告,或 null
*/
export async function findTodayReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
return history.find(r => r.symbol === symbol && (r.created_at ?? '').slice(0, 10) === today) ?? null
}
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
if (existing) {
activeDialogTaskId = existing.id
dialogMinimized = false
rebuildSnap()
emit()
return { id: existing.id }
}
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
if (ongoing.length >= MAX_ACTIVE) {
return { error: `同时进行的个股分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
}
const id = `stask_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const task: ActiveTask = {
id, symbol, name, focus,
phase: 'loading', content: '', error: '',
meta: null, createdAt: Date.now(),
}
activeTasks = [...activeTasks, task]
activeDialogTaskId = id
dialogMinimized = false
rebuildSnap()
emit()
runStream(id, symbol, name, focus)
return { id }
}
async function runStream(id: string, symbol: string, _name: string, focus: string) {
try {
let firstDelta = true
for await (const chunk of api.stockAnalyzeStream(symbol, focus)) {
const cur = activeTasks.find(t => t.id === id)
if (!cur) return
switch (chunk.type) {
case 'meta':
patchTask(id, { meta: { summary: chunk.summary, levels: chunk.levels, close: chunk.close } })
break
case 'delta':
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
patchTask(id, { content: cur.content + (chunk.content ?? '') })
break
case 'error':
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
return
case 'done':
patchTask(id, { phase: 'done' })
break
}
}
const final = activeTasks.find(t => t.id === id)
if (final && final.phase !== 'error') {
// 兜底:流正常结束但从未收到 delta(后端在生成内容前异常断流)→ 标记失败,避免卡死
if (!final.content) {
patchTask(id, { phase: 'error', error: '分析未返回内容(后端可能异常中断),请重试' })
return
}
try {
const res = await api.stockAnalysisReportSave({
symbol: final.symbol, name: final.name, focus: final.focus,
content: final.content, summary: final.meta?.summary ?? '',
close: final.meta?.close ?? null, levels: final.meta?.levels,
})
if (res.report) {
patchTask(id, { savedReportId: res.report.id })
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
historyLoaded = true
rebuildSnap()
emit()
}
} catch { /* 持久化失败不影响展示 */ }
}
} catch (e: any) {
const msg = String(e?.message ?? '分析失败')
patchTask(id, {
phase: 'error',
error: normalizeAiError(msg),
})
}
}
export function openDialog(taskId: string) {
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
}
export function minimizeDialog() {
dialogMinimized = true; rebuildSnap(); emit()
}
export function closeDialog() {
activeDialogTaskId = null; dialogMinimized = false; rebuildSnap(); emit()
}
export function restoreDialog(taskId: string) {
const t = activeTasks.find(x => x.id === taskId)
if (t && (t.phase === 'done' || t.phase === 'error')) {
patchTask(taskId, { dismissed: true })
}
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
}
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
return startAnalysis(task.symbol, task.name, task.focus)
}
export async function deleteReport(reportId: string): Promise<void> {
try {
await api.stockAnalysisReportDelete(reportId)
history = history.filter(r => r.id !== reportId)
rebuildSnap()
emit()
} catch { /* 静默 */ }
}
export function openHistoryReport(reportId: string) {
activeDialogTaskId = `history:${reportId}`; dialogMinimized = false; rebuildSnap(); emit()
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 集中管理所有 localStorage 持久化。
*
* - key 在此注册,各页面只通过 storage.xxx.get/set 调用。
* - 类型安全,不再散落 try/catch。
*/
function kv<T>(key: string) {
return {
get(fallback: T): T {
try {
const raw = localStorage.getItem(key)
if (raw !== null) return JSON.parse(raw) as T
} catch { /* ignore */ }
return fallback
},
set(val: T) {
try { localStorage.setItem(key, JSON.stringify(val)) } catch { /* ignore */ }
},
}
}
export const storage = {
/** 查询轮询 / SSE 配置 */
queryConfig: kv<unknown>('tf-stocks-query-config'),
/** 策略池 (screener) */
strategyPool: kv<string[]>('strategy-pool'),
/** 自选列表列配置 */
watchlistColumns: kv<unknown[]>('watchlist_columns'),
/** 个股日K信息条指标配置 */
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
/** 自选列表视图模式 table | card */
watchlistView: kv<string>('watchlist_view'),
/** 自选列表日K蜡烛图显示状态 */
watchlistCandle: kv<boolean>('watchlist_showCandle'),
/** 策略结果列表日K蜡烛图显示状态 */
screenerCandle: kv<boolean>('screener_showCandle'),
/** 自选列表板块筛选 */
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
/** Screener 卡片尺寸 */
screenerCardSize: kv<string>('screener-card-size'),
/** 连板梯队板块筛选 */
limitLadderBoard: kv<string[]>('limit-ladder-board-filter'),
/** 连板梯队 ext 字段配置 */
limitLadderExtFields: kv<Record<string, any>>('limit-ladder-ext-fields'),
/** 连板梯队 概念/行业 显示开关 */
limitLadderShowExt: kv<{ concept: boolean; industry: boolean }>('limit-ladder-show-ext'),
/** 连板梯队 涨停/跌停 切换方向 */
limitLadderDirection: kv<'up' | 'down'>('limit-ladder-direction'),
/** 连板梯队 封单显示模式: vol=按成交量(手), amount=按金额(元) */
limitLadderSealMode: kv<'vol' | 'amount'>('limit-ladder-seal-mode'),
/** 策略创建草稿(新建专用) */
strategyDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-draft'),
/** 策略修改草稿(AI修改专用,不影响创建按钮) */
strategyModify: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-modify'),
/** 策略构建器草稿(旧版兼容,逐渐废弃) */
strategyBuilderDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-builder-draft'),
/** 已保存策略的原始规则(策略ID → 规则文本) */
strategyRules: kv<Record<string, string>>('strategy-rules'),
/** 策略回测快捷区间按钮配置 */
strategyBacktestQuickRanges: kv<unknown>('strategy-backtest-quick-ranges'),
/** 策略回测最后一次成功结果和参数 */
strategyBacktestLast: kv<{
selectedStrategy: string | null
symbols: string
start: string
end: string
matching: 'close_t' | 'open_t+1'
entryFill: 'close_t' | 'open_t+1'
exitFill: 'close_t' | 'open_t+1'
fees: string
slippage: string
maxPositions: string
maxExposure: string
initialCapital: string
positionSizing: 'equal' | 'score_weight'
mode: 'position' | 'full'
holdingDays: string
params?: Record<string, any>
overrides?: Record<string, any>
result: any
} | null>('strategy-backtest-last'),
/** 概念分析页面字段配置 */
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
/** 行业分析页面字段配置 */
industryAnalysisConfig: kv<Record<string, any>>('industry-analysis-config'),
/** 数据页画像卡片显隐 (卡片key → 是否显示) */
dataCardVisible: kv<Record<string, boolean>>('data-card-visible'),
/** 数据页画像卡片顺序 (卡片key 数组, 长度=卡片总数) */
dataCardOrder: kv<string[]>('data-card-order'),
} as const
+72
View File
@@ -0,0 +1,72 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
export const FINANCIAL_QK = {
status: ['financials', 'status'],
metrics: (symbol?: string) => ['financials', 'metrics', symbol],
income: (symbol?: string) => ['financials', 'income', symbol],
balanceSheet: (symbol?: string) => ['financials', 'balance-sheet', symbol],
cashFlow: (symbol?: string) => ['financials', 'cash-flow', symbol],
}
export function useFinancialStatus() {
return useQuery({
queryKey: FINANCIAL_QK.status,
queryFn: () => api.financialStatus(),
staleTime: 60_000,
// 同步进行中时每 3s 轮询,及时反映表数变化与同步完成;空闲时不轮询。
refetchInterval: (query) => (query.state.data?.syncing ? 3_000 : false),
})
}
export function useFinancialMetrics(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.metrics(symbol),
queryFn: () => api.financialMetrics(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialIncome(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.income(symbol),
queryFn: () => api.financialIncome(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialBalanceSheet(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.balanceSheet(symbol),
queryFn: () => api.financialBalanceSheet(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialCashFlow(symbol?: string) {
return useQuery({
queryKey: FINANCIAL_QK.cashFlow(symbol),
queryFn: () => api.financialCashFlow(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
export function useFinancialSync() {
const qc = useQueryClient()
return useMutation({
mutationFn: (table: string) => api.financialSync(table),
// 点击瞬间立即刷新 status: 让后端 is_syncing=True 马上反映到 UI,
// 避免 mutation 阻塞(全量同步需数分钟)期间界面无变化。
onMutate: () => {
qc.invalidateQueries({ queryKey: FINANCIAL_QK.status })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: FINANCIAL_QK.status })
qc.invalidateQueries({ queryKey: ['financials'] })
},
})
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useState } from 'react'
/**
* 记忆"上次查看的个股"(按页面维度,localStorage 持久化)。
*
* 两个分析页(财务 / 个股)各自独立记忆,key 区分:
* - financials: 最后查看的财务分析个股
* - stock-analysis: 最后查看的个股分析个股
*
* 用法:
* const { last, remember } = useLastStock('stock-analysis')
* remember('000001.SZ', '平安银行') // 选中股票时调用
* <LastStockChip stock={last} ... /> // 渲染在 PageHeader 右侧
*/
export interface StockRef { symbol: string; name: string }
const PREFIX = 'last_stock:'
export function useLastStock(scope: string) {
const [last, setLast] = useState<StockRef | null>(() => load(scope))
const remember = useCallback((symbol: string, name: string) => {
const ref = { symbol, name }
setLast(ref)
save(scope, ref)
}, [scope])
const clear = useCallback(() => {
setLast(null)
save(scope, null)
}, [scope])
return { last, remember, clear }
}
function load(scope: string): StockRef | null {
try {
const v = localStorage.getItem(PREFIX + scope)
if (!v) return null
const p = JSON.parse(v)
if (p && typeof p.symbol === 'string' && typeof p.name === 'string') return p
} catch { /* ignore */ }
return null
}
function save(scope: string, ref: StockRef | null) {
try {
if (ref) localStorage.setItem(PREFIX + scope, JSON.stringify(ref))
else localStorage.removeItem(PREFIX + scope)
} catch { /* ignore */ }
}
+40
View File
@@ -0,0 +1,40 @@
/**
* SSE 配置 — 运行时参数。
*
* 目前只保留 SSE 重连延迟,其他数据刷新全部走 SSE invalidation。
* 存储在 localStorage。
*/
import { storage } from '@/lib/storage'
// ===== 配置结构 =====
export interface QueryConfig {
/** SSE 配置 */
sse: {
reconnectDelay: number
}
}
export const DEFAULT_QUERY_CONFIG: QueryConfig = {
sse: {
reconnectDelay: 5_000,
},
}
// ===== localStorage 持久化 =====
function loadConfig(): QueryConfig {
const raw = storage.queryConfig.get(null) as QueryConfig | null
if (!raw) return DEFAULT_QUERY_CONFIG
return {
sse: { ...DEFAULT_QUERY_CONFIG.sse, ...raw.sse },
}
}
/**
* 轻量版:只读取当前配置。
* 供 useQuoteStream 使用。
*/
export function getQueryConfig(): QueryConfig {
return loadConfig()
}
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToasts } from '@/components/AlertToast'
import { feedReviewEvent } from './reviewStore'
import type { StrategyAlertEvent } from './api'
/**
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
*
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
*
* 应在顶层 Layout 中调用一次。
*/
export function useQuoteStream(
enabled: boolean,
sseRefreshPages: Record<string, boolean> | undefined,
onAlert?: (alerts: StrategyAlertEvent[]) => void,
) {
const qc = useQueryClient()
const esRef = useRef<EventSource | null>(null)
const retryRef = useRef<ReturnType<typeof setTimeout>>()
const pagesRef = useRef(sseRefreshPages)
pagesRef.current = sseRefreshPages
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
// depth 系统接管通知: 单独处理, 不走 strategy 回调
const depthAlerts = alerts.filter(a => a.source === 'depth')
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
for (const a of depthAlerts.slice(0, 1)) {
toast(a.message, 'success')
}
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
if (strategyAlerts.length > 0) {
// 有 onAlert 回调时走回调, 否则弹 AlertToast
if (onAlert) {
onAlert(strategyAlerts)
}
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
pushAlertToasts(strategyAlerts as any)
}
}, [onAlert])
const enabledRef = useRef(enabled)
enabledRef.current = enabled
useEffect(() => {
// SSE 始终连接 — 监控告警不依赖实时行情开关
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
const connect = () => {
const es = new EventSource('/api/intraday/stream')
esRef.current = es
// sse-starlette ping 心跳走 SSE comment,不会到达这里
es.addEventListener('quotes_updated', () => {
// 实时行情未开启时不处理行情刷新
if (!enabledRef.current) return
// 根据用户配置过滤 invalidation
const pages = pagesRef.current
if (pages) {
// 只 invalidate 开启的页面对应的 prefix
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
// 'quote-status' 始终刷新 (全局状态)
if (p === 'quote-status') return true
return pages[p] !== false
})
qc.invalidateQueries({
predicate: (query) =>
activePrefixes.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
} else {
// 无配置时全部刷新 (向后兼容)
qc.invalidateQueries({
predicate: (query) =>
SSE_INVALIDATE_PREFIXES.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
}
})
es.addEventListener('depth_updated', () => {
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
qc.invalidateQueries({ queryKey: ['overview-market'] })
})
es.addEventListener('strategy_alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
const alerts: StrategyAlertEvent[] = data.alerts || []
if (alerts.length > 0) {
handleAlerts(alerts)
// 实时刷新触发记录列表 + 监控中心徽标
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
}
} catch {
// 忽略解析错误
}
})
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
es.addEventListener('review_progress', (e: MessageEvent) => {
try {
const evt = JSON.parse(e.data)
feedReviewEvent(evt)
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
if (evt.type === 'done') {
qc.invalidateQueries({ queryKey: QK.reviewReports })
}
} catch {
// 忽略解析错误
}
})
es.onerror = () => {
es.close()
esRef.current = null
const delay = getQueryConfig().sse.reconnectDelay
retryRef.current = setTimeout(connect, delay)
}
}
connect()
return () => {
clearTimeout(retryRef.current)
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
}
}, [qc, handleAlerts])
}
+14
View File
@@ -0,0 +1,14 @@
/**
* 订阅 reviewStore 的 React hook。
*
* mount 时订阅,store 变化触发 re-render;unmount 时退订(但 store 流继续跑)。
* 用 useSyncExternalStore 保证与 React 18 并发模式兼容。
*/
import { useSyncExternalStore } from 'react'
import {
getReviewState, subscribeReview, type ReviewState,
} from '@/lib/reviewStore'
export function useReviewState(): ReviewState {
return useSyncExternalStore(subscribeReview, getReviewState, getReviewState)
}
@@ -0,0 +1,42 @@
/**
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api'
import { QK } from './queryKeys'
/** 切换实时行情 — Layout / Data 共用 */
export function useToggleRealtimeQuotes() {
const qc = useQueryClient()
return useMutation({
mutationFn: (enabled: boolean) => api.updateRealtimeQuotes(enabled),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 更新行情轮询间隔 — Layout / Data 共用 */
export function useUpdateQuoteInterval() {
const qc = useQueryClient()
return useMutation({
mutationFn: (v: number) => api.updateQuoteInterval(v),
onSuccess: (data) => {
qc.setQueryData(QK.quoteInterval, data)
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 批量添加自选 — Screener / Intraday 共用 */
export function useWatchlistBatchAdd() {
const qc = useQueryClient()
return useMutation({
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.watchlist })
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
},
})
}
@@ -0,0 +1,81 @@
/**
* 共享 query hooks — 消除多页面重复的 useQuery 调用。
*
* 实时数据走 SSE invalidation,无需前端轮询。
* 只有管线进度等非 SSE 数据才用 refetchInterval。
*/
import { useQuery } from '@tanstack/react-query'
import { api } from './api'
import { QK } from './queryKeys'
// ===== 全局共享 =====
/** 能力检测 — Layout / Data / Keys 共用 */
export function useCapabilities() {
return useQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
}
/** 设置状态 — Layout / Data / Keys 共用 */
export function useSettings() {
return useQuery({
queryKey: QK.settings,
queryFn: api.settings,
})
}
/** 用户偏好 — Layout / Data / Intraday 共用 */
export function usePreferences() {
return useQuery({
queryKey: QK.preferences,
queryFn: api.preferences,
})
}
/** 行情状态 — SSE quotes_updated 自动刷新。
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s 轮询一次,
* 用于在交易时段边界 (11:30午休 / 12:55开盘 / 15:05收盘) 同步 is_trading_hours。
* 交易时段不轮询 (SSE 已驱动刷新), 非交易时段无 SSE 推送, 需要兜底。
* 只应在全局唯一挂载处 (Layout) 传 poll=true, 避免多页面重复轮询;
* 其他调用方共享同一 queryKey 缓存, 无需自行轮询。
*/
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
return useQuery({
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
enabled: opts?.enabled ?? true,
refetchInterval: opts?.poll
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
: false,
})
}
/** 行情间隔 — Layout / Data 共用 */
export function useQuoteInterval() {
return useQuery({
queryKey: QK.quoteInterval,
queryFn: api.quoteInterval,
})
}
/** 版本号 — Layout 专用 */
export function useVersion() {
return useQuery({
queryKey: QK.version,
queryFn: api.version,
staleTime: Infinity,
})
}
/** 数据状态 — Data / Screener 共用 */
export function useDataStatus(opts?: { staleTime?: number; refetchInterval?: number | false }) {
return useQuery({
queryKey: QK.dataStatus,
queryFn: api.dataStatus,
staleTime: opts?.staleTime,
refetchInterval: opts?.refetchInterval,
})
}
+36
View File
@@ -0,0 +1,36 @@
import { useState, useCallback, useEffect } from 'react'
import { storage } from '@/lib/storage'
export function useStrategyPool() {
const [pool, setPool] = useState<string[]>(() => storage.strategyPool.get([]))
// 同步写入 localStorage
useEffect(() => { storage.strategyPool.set(pool) }, [pool])
const addToPool = useCallback((id: string) => {
setPool(prev => prev.includes(id) ? prev : [...prev, id])
}, [])
const removeFromPool = useCallback((id: string) => {
setPool(prev => prev.filter(x => x !== id))
}, [])
const reorderPool = useCallback((newOrder: string[]) => {
setPool(newOrder)
}, [])
// 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。
// 仅当确实有失效项时才更新,避免无谓重渲染。
const prune = useCallback((validIds: Iterable<string>) => {
const validSet = validIds instanceof Set ? validIds : new Set(validIds)
setPool(prev => {
if (prev.length === 0) return prev
const next = prev.filter(id => validSet.has(id))
return next.length === prev.length ? prev : next
})
}, [])
const isInPool = useCallback((id: string) => pool.includes(id), [pool])
return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool }
}
+166
View File
@@ -0,0 +1,166 @@
/**
* 自选列表自定义列配置。
*
* 自选页只保留业务内置列、分组和偏好持久化;通用列模型/合并/扩展列参数
* 来自 list-columns,策略页等其它股票列表可复用同一底座。
*/
import { storage } from '@/lib/storage'
import {
buildExtColumnsParam as buildExtColumnsParamBase,
createExtColumn as createExtColumnBase,
mergeColumns as mergeColumnsBase,
serializeColumns as serializeColumnsBase,
type ColumnConfig,
type ColumnGroup,
type ColumnSource,
type ExtColumnDisplayConfig,
type CandleColumnConfig,
} from '@/lib/list-columns'
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
// ===== 内置列注册表(与当前硬编码一一对应) =====
export const BUILTIN_COLUMNS: ColumnConfig[] = [
// 固定列
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '代码/名称', visible: true, pinned: true, align: 'left' },
// 价格
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'center' },
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
// 成交
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'center' },
// 均线
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'center' },
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'center' },
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'center' },
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'center' },
// 区间
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'center' },
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'center' },
// 技术指标
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'center' },
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'center' },
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'center' },
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'center' },
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'center' },
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'center' },
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'center' },
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'center' },
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'center' },
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'center' },
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'center' },
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'center' },
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'center' },
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'center' },
// 动量
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'center' },
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum' }, label: '60D 动量', visible: true, align: 'center' },
// 连板
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
// 信号 & 图表
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'center' },
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'center' },
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'center' },
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'center' },
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
]
export const COLUMN_GROUPS: ColumnGroup[] = [
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
]
// 操作列(始终显示,不参与自定义)
export const ACTION_COLUMN_ID = 'builtin:action'
// ===== localStorage 持久化 =====
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action */
export function serializeColumns(columns: ColumnConfig[]): ColumnConfig[] {
return serializeColumnsBase(columns, ACTION_COLUMN_ID)
}
/** 序列化并保存到后端 + localStorage */
export async function saveColumnConfig(columns: ColumnConfig[]): Promise<void> {
const saveable = serializeColumns(columns)
// 同时写 localStorage(即时)和后端(持久化)
storage.watchlistColumns.set(saveable)
try {
const { api } = await import('@/lib/api')
await api.updateWatchlistColumns(saveable)
} catch {
// 后端不可用时 localStorage 仍有效
}
}
/** 加载列配置:优先后端,回退 localStorage,最终用默认值 */
export async function loadColumnConfig(): Promise<ColumnConfig[]> {
// 1. 尝试从后端加载
try {
const { api } = await import('@/lib/api')
const res = await api.watchlistColumns()
if (res.columns && res.columns.length > 0) {
const merged = mergeColumns(res.columns, BUILTIN_COLUMNS)
// 同步到 localStorage
storage.watchlistColumns.set(serializeColumns(merged))
return merged
}
} catch {
// 后端不可用,继续尝试 localStorage
}
// 2. 尝试从 localStorage 加载
const saved = storage.watchlistColumns.get([]) as ColumnConfig[]
if (saved.length > 0) {
return mergeColumns(saved, BUILTIN_COLUMNS)
}
// 3. 默认值
return [...BUILTIN_COLUMNS]
}
/** 合并用户保存的列与默认列 */
function mergeColumns(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
return mergeColumnsBase(saved, defaults, { actionColumnId: ACTION_COLUMN_ID })
}
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口 */
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
return buildExtColumnsParamBase(columns)
}
/** 根据 ext schema 数据创建 ext 列配置 */
export function createExtColumn(
configId: string,
configLabel: string,
fieldName: string,
fieldLabel?: string,
): ColumnConfig {
return createExtColumnBase(configId, configLabel, fieldName, fieldLabel)
}