diff --git a/backend/app/api/financials.py b/backend/app/api/financials.py index f642b6c..2cdf433 100644 --- a/backend/app/api/financials.py +++ b/backend/app/api/financials.py @@ -41,7 +41,14 @@ def financial_status(request: Request): fs = getattr(request.app.state, "financial_scheduler", None) last_sync = fs.last_sync if fs else {} - return {"available": True, "tables": tables, "last_sync": last_sync} + return { + "available": True, + "tables": tables, + "last_sync": last_sync, + # 服务端是否正在同步(手动触发)——前端据此显示"同步中"并防重复点击, + # 且刷新页面后仍能正确反映服务端状态。 + "syncing": bool(fs and fs.is_syncing), + } @router.get("/metrics") diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 14d9d36..748c0b9 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -550,7 +550,9 @@ async def extend_minute_history(request: Request): """向前扩展分钟K历史数据 — 仅拉数据,不做任何后续处理。 body: { "value": int, "unit": "day"|"month" } - 最大 15 天。返回 job_id,可轮询 /api/pipeline/jobs 查看进度。 + - day 单位:1~15 天(所有有分钟K权限的套餐可用) + - month 单位:1~6 月(每月按 30 天计,即最多 180 天)—— 仅 Expert+ 可用 + 返回 job_id,可轮询 /api/pipeline/jobs 查看进度。 """ import asyncio import traceback as _tb @@ -570,10 +572,20 @@ async def extend_minute_history(request: Request): if not capset.has(Cap.KLINE_MINUTE_BATCH): raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch minute K-line)") - # 计算天数,上限 15 + # month 单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用 day + if unit == "month": + from app.tickflow.policy import tier_label + base_tier = tier_label().split()[0].split("+")[0].strip().lower() + if base_tier != "expert": + raise HTTPException( + status_code=403, + detail="按月扩展分钟K历史需要 Expert 及以上套餐", + ) + + # 计算天数上限:day 最多 15 天;month 最多 6 月(180 天) from datetime import timedelta if unit == "month": - total_days = min(value * 30, 15) + total_days = min(value * 30, 180) else: total_days = min(value, 15) diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py index da19ed1..25e1cc2 100644 --- a/backend/app/services/financial_sync.py +++ b/backend/app/services/financial_sync.py @@ -197,6 +197,8 @@ class FinancialScheduler: self._capset: CapabilitySet | None = None self._lock = threading.Lock() self._last_sync: dict[str, str] = {} # {table: iso_timestamp} + # 手动同步(run_now)是否正在进行。前端据此显示"同步中"并防重复点击。 + self._is_syncing = False def start(self, data_dir: Path, capset: CapabilitySet) -> None: if not capset.has(Cap.FINANCIAL): @@ -242,27 +244,46 @@ class FinancialScheduler: pass def run_now(self, table: str | None = None) -> dict[str, int]: - """手动触发同步。table=None 同步全部。""" + """手动触发同步。table=None 同步全部。 + + 用 _is_syncing 标志防并发:若已有同步在进行,本次直接跳过, + 避免重复请求拖慢服务端 / 触发上游限流。 + """ if not self._capset or not self._capset.has(Cap.FINANCIAL): return {} - if table: - fn = { - "metrics": sync_metrics, - "income": sync_income, - "balance_sheet": sync_balance_sheet, - "cash_flow": sync_cash_flow, - }.get(table) - if not fn: - return {} - rows = fn(self._data_dir, self._capset) - self._last_sync[table] = datetime.now(timezone.utc).isoformat() - return {table: rows} - else: - result = sync_all(self._data_dir, self._capset) - now = datetime.now(timezone.utc).isoformat() - for t in result: - self._last_sync[t] = now - return result + with self._lock: + if self._is_syncing: + logger.info("financial sync skipped: already running") + return {"_skipped": 1} + self._is_syncing = True + try: + if table: + fn = { + "metrics": sync_metrics, + "income": sync_income, + "balance_sheet": sync_balance_sheet, + "cash_flow": sync_cash_flow, + }.get(table) + if not fn: + return {} + rows = fn(self._data_dir, self._capset) + self._last_sync[table] = datetime.now(timezone.utc).isoformat() + return {table: rows} + else: + result = sync_all(self._data_dir, self._capset) + now = datetime.now(timezone.utc).isoformat() + for t in result: + self._last_sync[t] = now + return result + finally: + with self._lock: + self._is_syncing = False + + @property + def is_syncing(self) -> bool: + """手动同步是否正在进行(供 /status 返回,前端据此显示"同步中")。""" + with self._lock: + return self._is_syncing @property def last_sync(self) -> dict[str, str]: diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 3307408..942f90f 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import logging +import time from pathlib import Path from typing import Any @@ -52,6 +53,50 @@ def _tier_to_capset(tier_def: dict[str, dict[str, Any]]) -> CapabilitySet: return CapabilitySet(caps) +def _is_transient(e: Exception) -> bool: + """是否为"可重试的瞬时错误"——网络抖动 / 限流 / 服务端 5xx。 + + 与权限/参数错误(403/401/400/404)区分:后者重试也无用,不重试。 + 用类名匹配而非 import SDK 异常,避免探测期对 SDK 内部耦合。 + """ + cls = e.__class__.__name__ + if cls in { + "RateLimitError", "InternalServerError", "APIError", + "ConnectionError", "TimeoutError", "ConnectError", + "ConnectTimeout", "ReadTimeout", "RemoteProtocolError", + "httpx.ConnectError", "httpx.TimeoutException", + }: + return True + # APIError 体系下,status_code 5xx/429 视为瞬时 + status = getattr(e, "status_code", None) + if isinstance(status, int) and (status == 429 or status >= 500): + return True + return False + + +def _call_with_retry(fn, attempts: int = 3, backoff: float = 0.6) -> None: + """调用 fn();对瞬时错误退避重试,权限/参数错误立即抛出。 + + attempts=总尝试次数(含首次)。返回 None,异常由调用方分类。 + """ + last_exc: Exception | None = None + for i in range(attempts): + try: + fn() + return + except Exception as e: # noqa: BLE001 + last_exc = e + # 权限/参数类错误:重试无意义,立即抛出交给 try_call 归类 + if not _is_transient(e): + raise + # 瞬时错误:最后一轮不再 sleep + if i < attempts - 1: + time.sleep(backoff * (i + 1)) + # 重试耗尽,抛出最后一次异常 + assert last_exc is not None + raise last_exc + + def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: """逐 capability 试探。需要 API key。 @@ -65,7 +110,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: def try_call(cap: Cap, fn, default_limits: dict[str, Any]) -> None: try: - fn() + _call_with_retry(fn) available[cap] = CapabilityLimits( rpm=default_limits.get("rpm"), batch=default_limits.get("batch"), @@ -85,6 +130,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: if is_perm_denied: log.append(f"✗ {cap}(无权限)") else: + # 重试耗尽仍失败的瞬时错误 — 标记为疑似,而非直接判定"无此能力" log.append(f"? {cap} ({cls}: {e})") # 用各档默认上限作为占位(无 X-RateLimit-* 头时) diff --git a/frontend/src/components/EndpointTestDialog.tsx b/frontend/src/components/EndpointTestDialog.tsx index 8df5444..86bca85 100644 --- a/frontend/src/components/EndpointTestDialog.tsx +++ b/frontend/src/components/EndpointTestDialog.tsx @@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { Wifi, Play, Loader2, X, Check, Crown } from 'lucide-react' import { api, type EndpointItem } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { EXPERT_RANK, tierRank } from '@/lib/capability-labels' interface EpResult { ok: boolean @@ -15,7 +16,7 @@ interface EpResult { error?: string } -export function EndpointTestDialog({ hasKey, currentEndpoint, onClose }: { hasKey: boolean; currentEndpoint: string; onClose: () => void }) { +export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose }: { hasKey: boolean; tierLabel: string; currentEndpoint: string; onClose: () => void }) { const qc = useQueryClient() const [results, setResults] = useState>({}) const [testing, setTesting] = useState>({}) @@ -52,6 +53,8 @@ export function EndpointTestDialog({ hasKey, currentEndpoint, onClose }: { hasKe const anyTesting = Object.values(testing).some(Boolean) const isFree = !hasKey + // 专线端点需 Expert 及以上套餐;Free 模式必然不可用 + const canUsePremium = !isFree && tierRank(tierLabel) >= EXPERT_RANK const currentLabel = endpoints.find(ep => ep.url === currentEndpoint)?.label ?? currentEndpoint async function applyEndpoint(url: string) { @@ -137,7 +140,7 @@ export function EndpointTestDialog({ hasKey, currentEndpoint, onClose }: { hasKe
未能加载端点列表
) : ( endpoints.map(ep => ( - + )) )} @@ -151,12 +154,13 @@ export function EndpointTestDialog({ hasKey, currentEndpoint, onClose }: { hasKe ) } -function EpRow({ ep, result, testing, isCurrent, isFree, switching, onApply }: { +function EpRow({ ep, result, testing, isCurrent, isFree, canUsePremium, switching, onApply }: { ep: EndpointItem result: EpResult | null testing?: boolean isCurrent?: boolean isFree?: boolean + canUsePremium?: boolean switching: string | null onApply: (url: string) => void }) { @@ -234,9 +238,9 @@ function EpRow({ ep, result, testing, isCurrent, isFree, switching, onApply }: { - {/* 应用按钮区域 —— Free 模式不可用任何付费端点 */} - {isFree ? null : isPremium ? ( - // 专线端点:需 Expert 及以上套餐权限,不可应用 + {/* 应用按钮区域 —— Free 模式不可用任何付费端点;专线端点需 Expert+ */} + {isFree ? null : (isPremium && !canUsePremium) ? ( + // 专线端点:需 Expert 及以上套餐权限,当前套餐不足,不可应用 } | undefined; isRunning: boolean; onStart: () => void }) { const qc = useQueryClient() @@ -88,7 +89,7 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label:
向前扩展历史数据
- +
@@ -98,8 +99,11 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label: ) } -function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteCap: boolean; isRunning: boolean; onStart: () => void }) { +function MinuteExtendControls({ hasMinuteCap, tierLabel, isRunning, onStart }: { hasMinuteCap: boolean; tierLabel: string; isRunning: boolean; onStart: () => void }) { const qc = useQueryClient() + // 月单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用"天"(1~15 天) + const canUseMonth = isExpertOrAbove(tierLabel) + const [unit, setUnit] = useState<'day' | 'month'>('day') const [value, setValue] = useState(5) const [confirmOpen, setConfirmOpen] = useState(false) @@ -107,10 +111,12 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC queryKey: QK.dataStatus, queryFn: api.dataStatus, }) - const hasMinuteData = !!(dataStatus.data?.minute?.rows) + // 判断本地是否已有分钟K数据:后端 _safe_aggregate_minute 为避免全表扫描, + // rows 恒为 0,改用 trading_days(分区目录数,真实统计)判断。 + const hasMinuteData = !!(dataStatus.data?.minute?.trading_days) const extend = useMutation({ - mutationFn: () => api.extendMinuteHistory(value, 'day'), + mutationFn: () => api.extendMinuteHistory(value, unit), onSuccess: () => { onStart() qc.invalidateQueries({ queryKey: QK.pipelineJobs }) @@ -118,7 +124,8 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC }, }) - const totalDays = Math.min(value, 15) + // 各单位上限:day 15 天,month 6 月(180 天) + const maxValue = unit === 'month' ? 6 : 15 const handleFetch = () => { if (!hasMinuteData) { @@ -128,6 +135,14 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC } } + // 切换单位时把 value clamp 到新单位的上限 + const switchUnit = (u: 'day' | 'month') => { + if (u === unit) return + setUnit(u) + const max = u === 'month' ? 6 : 15 + setValue(v => Math.min(v, max)) + } + return ( <>
@@ -138,15 +153,30 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs" >−
- {totalDays} + {value}
- + + {canUseMonth ? ( +
+ {(['day', 'month'] as const).map(u => ( + + ))} +
+ ) : ( + + )}
+ ) + })} + + + {/* 表格内容 */} +
+ {current.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+ ) : rows.length === 0 ? ( +
+ 暂无{TABS.find(t => t.key === tab)?.label}数据 — 可点击顶部「全部同步」拉取 +
+ ) : ( +
+ {/* 多期时为每期渲染一组;单期时只有一组 */} + {rows.map((row, ri) => ( +
+ {rows.length > 1 && ( +
+ + 报告期 {row.period_end} +
+ )} +
+ {fieldDefs.map((def: any) => { + const val = row[def.key] + return ( +
+ {def.label} + + {formatValue(val, def.fmt)} + +
+ ) + })} +
+
+ ))} +
+ )} +
+ + ) +} diff --git a/frontend/src/components/financials/StockFinancialSearch.tsx b/frontend/src/components/financials/StockFinancialSearch.tsx new file mode 100644 index 0000000..b5f408a --- /dev/null +++ b/frontend/src/components/financials/StockFinancialSearch.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect, useRef } from 'react' +import { useQuery } from '@tanstack/react-query' +import { motion, AnimatePresence } from 'framer-motion' +import { Search, Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' + +interface Props { + onSelect: (symbol: string, name: string) => void +} + +/** + * 个股模糊搜索框 —— 财务页主入口。 + * 复用 instrumentSearch 后端(代码 / 名称模糊匹配),单选即跳转该股财务详情。 + * 模式对齐 Watchlist.StockSearchBox:useQuery + 外部点击关闭 + 键盘导航。 + */ +export function StockFinancialSearch({ onSelect }: Props) { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(-1) + const containerRef = useRef(null) + const inputRef = useRef(null) + + const search = useQuery({ + queryKey: QK.instrumentSearch(query), + queryFn: () => api.instrumentSearch(query), + enabled: query.trim().length > 0, + staleTime: 30_000, + }) + + const results = search.data?.results ?? [] + + // 外部点击关闭下拉 + useEffect(() => { + function handleClick(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + function handleSelect(r: { symbol: string; name: string }) { + onSelect(r.symbol, r.name) + setQuery('') + setOpen(false) + setActiveIdx(-1) + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Escape') { setOpen(false); return } + if (!open || results.length === 0) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIdx(i => Math.min(i + 1, results.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx(i => Math.max(i - 1, -1)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (activeIdx >= 0) handleSelect(results[activeIdx]) + else if (results.length > 0) handleSelect(results[0]) + } + } + + const trimmed = query.trim() + + return ( +
+
+ + { setQuery(e.target.value); setOpen(true); setActiveIdx(-1) }} + onFocus={() => { if (trimmed) setOpen(true) }} + onKeyDown={handleKeyDown} + // 较宽、更醒目 —— 作为财务页主入口 + className="w-full h-11 pl-11 pr-10 rounded-card bg-surface border border-border text-sm text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:bg-base transition-colors" + /> + {search.isFetching && ( + + )} +
+ + + {open && trimmed && ( + + {search.isLoading ? ( +
+ + 搜索中… +
+ ) : results.length === 0 ? ( +
+ 未找到匹配的股票 +
+ ) : ( + results.map((r, i) => ( + + )) + )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f181fb8..c4e3895 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -39,6 +39,8 @@ export interface FinancialStatus { available: boolean tables: Record last_sync: Record + /** 服务端是否正在同步(手动触发)——驱动"同步中"UI 并防重复点击 */ + syncing?: boolean } export interface FinancialMetricRecord { diff --git a/frontend/src/lib/capability-labels.ts b/frontend/src/lib/capability-labels.ts index 7883315..560ec63 100644 --- a/frontend/src/lib/capability-labels.ts +++ b/frontend/src/lib/capability-labels.ts @@ -13,3 +13,18 @@ export const CAP_LABELS: Record = { 'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' }, 'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' }, } + +// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。 +// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。 +export const TIER_RANK: Record = { 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 +} + diff --git a/frontend/src/lib/useFinancials.ts b/frontend/src/lib/useFinancials.ts index 1243fa5..955694c 100644 --- a/frontend/src/lib/useFinancials.ts +++ b/frontend/src/lib/useFinancials.ts @@ -14,6 +14,8 @@ export function useFinancialStatus() { queryKey: FINANCIAL_QK.status, queryFn: () => api.financialStatus(), staleTime: 60_000, + // 同步进行中时每 3s 轮询,及时反映表数变化与同步完成;空闲时不轮询。 + refetchInterval: (query) => (query.state.data?.syncing ? 3_000 : false), }) } @@ -26,6 +28,33 @@ export function useFinancialMetrics(symbol?: string) { }) } +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({ diff --git a/frontend/src/lib/useStrategyPool.ts b/frontend/src/lib/useStrategyPool.ts index 52007d1..7fcc86a 100644 --- a/frontend/src/lib/useStrategyPool.ts +++ b/frontend/src/lib/useStrategyPool.ts @@ -19,7 +19,18 @@ export function useStrategyPool() { setPool(newOrder) }, []) + // 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。 + // 仅当确实有失效项时才更新,避免无谓重渲染。 + const prune = useCallback((validIds: Iterable) => { + 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, isInPool } + return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool } } diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx index ef6fb14..8c841b4 100644 --- a/frontend/src/pages/Data.tsx +++ b/frontend/src/pages/Data.tsx @@ -733,6 +733,7 @@ export function Data() { {showEndpointTest && ( setShowEndpointTest(false)} /> diff --git a/frontend/src/pages/Financials.tsx b/frontend/src/pages/Financials.tsx index 9e31911..5bba4e1 100644 --- a/frontend/src/pages/Financials.tsx +++ b/frontend/src/pages/Financials.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' -import { RefreshCw, Lock } from 'lucide-react' +import { RefreshCw, Lock, Loader2, X, Search } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { useCapabilities } from '@/lib/useSharedQueries' import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials' +import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch' +import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail' const TABLE_LABELS: Record = { metrics: '核心指标', @@ -12,29 +14,15 @@ const TABLE_LABELS: Record = { cash_flow: '现金流量表', } -const METRIC_LABELS: Record = { - eps_basic: '基本EPS', - eps_diluted: '稀释EPS', - bps: '每股净资产', - ocfps: '每股经营现金流', - roe: 'ROE', - roe_diluted: '稀释ROE', - roa: 'ROA', - gross_margin: '毛利率', - net_margin: '净利率', - debt_to_asset_ratio: '负债率', - revenue_yoy: '营收增速', - net_income_yoy: '净利增速', - operating_cash_to_revenue: '经营现金/营收', - inventory_turnover: '存货周转率', -} - export function Financials() { const { data: caps } = useCapabilities() const hasFinancial = caps?.capabilities?.['financial'] != null const { data: status, isLoading } = useFinancialStatus() const syncMut = useFinancialSync() - const [syncing, setSyncing] = useState(null) + // 用服务端 syncing 真值驱动 UI(而非本地状态),刷新后仍正确,且天然防重复点击 + const syncing = status?.syncing ?? false + // 选中的个股(模糊搜索结果);null 时显示搜索引导 + const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null) if (!hasFinancial) { return ( @@ -49,17 +37,16 @@ export function Financials() { ) } - const handleSync = async (table: string) => { - setSyncing(table) - try { - await syncMut.mutateAsync(table) - } finally { - setSyncing(null) - } + const handleSync = (table: string) => { + // 防重复点击:syncing 中不再触发(后端 run_now 也有 is_syncing 兜底) + if (syncing) return + syncMut.mutate(table) } const tables = status?.tables ?? {} const available = status?.available ?? false + // 进度:已同步(rows>0)的表数 / 总表数,供同步中提示 + const syncedCount = Object.values(tables).filter(t => (t?.rows ?? 0) > 0).length return ( <> @@ -69,24 +56,30 @@ export function Financials() { right={
} /> - {!available || isLoading ? ( -
- {isLoading ? '加载中…' : '暂无数据,点击"全部同步"从 TickFlow 拉取财务数据'} + {syncing && ( +
+ + 正在从 TickFlow 拉取财务数据,已同步 {syncedCount}/4 张表…
- ) : ( -
- {/* 各表状态卡片 */} + )} + + {/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */} + {!isLoading && available && ( +
{Object.entries(TABLE_LABELS).map(([key, label]) => { const info = tables[key] @@ -95,12 +88,14 @@ export function Financials() {
{label}
@@ -114,23 +109,8 @@ export function Financials() { ) })}
- - {/* 指标说明 */} -
-

核心指标字段说明

-
- {Object.entries(METRIC_LABELS).map(([key, label]) => ( -
- {key} - {label} -
- ))} -
-
- - {/* 最后同步时间 */} {status?.last_sync && Object.keys(status.last_sync).length > 0 && ( -
+
最后同步: {Object.entries(status.last_sync).map(([k, v]) => `${TABLE_LABELS[k] || k}: ${new Date(v).toLocaleString()}` ).join(' / ')} @@ -138,6 +118,57 @@ export function Financials() { )}
)} + + {isLoading ? ( +
加载中…
+ ) : !available ? ( +
暂无数据,点击"全部同步"从 TickFlow 拉取财务数据
+ ) : ( + <> + {/* 个股搜索区 */} +
+ {selected ? ( + // 已选股:紧凑搜索条 + 清除按钮(便于换股) +
+
+ setSelected({ symbol, name })} /> +
+ +
+ ) : ( + // 未选股:醒目居中引导 +
+
+ + 搜索个股查看详细财务数据 +
+ setSelected({ symbol, name })} /> +
支持股票代码或名称模糊匹配,如 600000 / 浦发
+
+ )} +
+ + {/* 个股详情 / 空引导 */} +
+ {selected ? ( + + ) : ( + + )} +
+ + )} ) } diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index 979c902..24c9538 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -44,7 +44,7 @@ export function Screener() { const [showBuilder, setShowBuilder] = useState(false) const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create') const [showStore, setShowStore] = useState(false) - const { pool, addToPool, removeFromPool, reorderPool } = useStrategyPool() + const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool() const [cardSize, setCardSize] = useState(loadCardSize) // 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化) const [dailyKChartVisible, setDailyKChartVisible] = useState(() => storage.screenerCandle.get(true)) @@ -144,6 +144,14 @@ export function Screener() { const availableStrategyIds = useMemo(() => new Set((strategies.data?.presets ?? []).map(s => s.id)), [strategies.data]) const visiblePool = useMemo(() => pool.filter(id => availableStrategyIds.has(id)), [pool, availableStrategyIds]) + // 策略列表加载后,自动清除池中失效的自定义策略(如本地开发残留的、 + // 当前后端已不存在的策略 ID),避免"策略池"对话框持续显示失效项。 + // availableStrategyIds 初始为空集合时跳过,防止首次渲染误清整个池。 + useEffect(() => { + if (availableStrategyIds.size === 0) return + prune(availableStrategyIds) + }, [availableStrategyIds, prune]) + // 进入页面自动跑策略池中的策略,获取命中数 const runAll = useMutation({ mutationFn: ({ date, strategyIds }: { date?: string; strategyIds?: string[] } = {}) => diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 2975c8a..435c3ed 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -638,7 +638,10 @@ export function StrategyBacktest() { const [scoringDraft, setScoringDraft] = useState>({}) const [strategyParams, setStrategyParams] = useState>(saved?.params ?? {}) const [overrides, setOverrides] = useState>(saved?.overrides ?? {}) - const [result, setResult] = useState(saved?.result ?? null) + // result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时, + // 跨会话/拉新代码后自动渲染一个可能对应已失效策略的旧结果会造成困惑 + // (切页不卸载组件,内存中的 result 仍保留,无需靠 localStorage 恢复)。 + const [result, setResult] = useState(null) const [resultTab, setResultTab] = useState<'daily' | 'trades' | 'picks'>('daily') const [dailyPage, setDailyPage] = useState(0) const [tradePage, setTradePage] = useState(0) @@ -656,6 +659,20 @@ export function StrategyBacktest() { strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup) ), [strategyGroup, strategyList]) + // 校验 localStorage 里保存的上次选中策略是否仍存在(本地开发残留的自定义策略 + // 拉新代码后会失效,导致 strategyGet 一直 404/加载中)。列表就绪后若失效, + // 连带清除其专属的 params/overrides/result(这些是该策略的运行配置/产物, + // 策略失效后留着会造成"孤儿"状态:界面显示旧回测结果却无对应策略)。 + useEffect(() => { + if (strategies.isLoading || strategyList.length === 0) return + if (selectedStrategy && !strategyList.some(st => st.id === selectedStrategy)) { + setSelectedStrategy(null) + setStrategyParams({}) + setOverrides({}) + setResult(null) + } + }, [strategies.isLoading, strategyList, selectedStrategy]) + const strategyDetail = useQuery({ queryKey: ['strategy-detail', selectedStrategy], queryFn: () => api.strategyGet(selectedStrategy!),