fix: 端点套餐门控/探测重试/分钟K月单位 + 财务页新功能 + localStorage 残留自愈

## Bug 修复
- 端点测速:Expert+ 用户现可选用专线端点(premium),非 Expert 仍锁定(EndpointTestDialog)
- 能力探测:try_call 对瞬时错误(429/5xx/超时)重试,避免抖动误丢 capability(导致"需 Free"误导徽章)
- 数据画像 CapBadge:tierReq='Free' 时不显示无意义的"需 Free"徽章
- 财务同步:用服务端 is_syncing 标志驱动 UI,刷新后仍正确显示"同步中",防重复点击 + 进度提示

## 新功能
- 财务页重设计:中间模糊搜索框(代码/名称),选股后展示 4 标签页详情
  (核心指标/利润表/资负表/现金流),字段格式化(百分点/金额转亿/每股)
- 分钟K向前扩展:Expert+ 新增「月」单位(1~6 月,180 天),Pro 仍只「天」(15 天)

## localStorage 残留自愈(拉新代码后不再显示本地开发残留)
- 策略池:加载后自动清除失效的自定义策略 ID
- 回测页:校验 selectedStrategy 是否存在,失效则重置;result 不跨会话恢复

## 重构
- 提取共享 tier 工具到 capability-labels.ts(TIER_RANK/isExpertOrAbove)
This commit is contained in:
shy3130
2026-06-19 00:10:01 +08:00
parent 7369358d08
commit a5a04b4dd8
17 changed files with 692 additions and 99 deletions
+8 -1
View File
@@ -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")
+15 -3
View File
@@ -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)
+40 -19
View File
@@ -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]:
+47 -1
View File
@@ -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-* 头时)
+10 -6
View File
@@ -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<Record<string, EpResult | null>>({})
const [testing, setTesting] = useState<Record<string, boolean>>({})
@@ -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
<div className="py-10 text-center text-xs text-muted"></div>
) : (
endpoints.map(ep => (
<EpRow key={ep.url} ep={ep} result={results[ep.url]} testing={testing[ep.url]} isCurrent={ep.url === currentEndpoint} isFree={isFree} switching={switching} onApply={applyEndpoint} />
<EpRow key={ep.url} ep={ep} result={results[ep.url]} testing={testing[ep.url]} isCurrent={ep.url === currentEndpoint} isFree={isFree} canUsePremium={canUsePremium} switching={switching} onApply={applyEndpoint} />
))
)}
</div>
@@ -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 }: {
</div>
</div>
{/* 应用按钮区域 —— Free 模式不可用任何付费端点 */}
{isFree ? null : isPremium ? (
// 专线端点:需 Expert 及以上套餐权限,不可应用
{/* 应用按钮区域 —— Free 模式不可用任何付费端点;专线端点需 Expert+ */}
{isFree ? null : (isPremium && !canUsePremium) ? (
// 专线端点:需 Expert 及以上套餐权限,当前套餐不足,不可应用
<span
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-btn text-[11px] font-medium bg-warning/10 text-warning/70 cursor-not-allowed select-none mt-0.5"
title="需要 Expert 及以上套餐的专线加速权限"
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { isExpertOrAbove } from '@/lib/capability-labels'
export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; isRunning: boolean; onStart: () => void }) {
const qc = useQueryClient()
@@ -88,7 +89,7 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label:
<div className="pt-2 border-t border-border space-y-2.5">
<div className="text-[10px] text-secondary"></div>
<MinuteExtendControls hasMinuteCap={hasMinuteCap} isRunning={isRunning} onStart={onStart} />
<MinuteExtendControls hasMinuteCap={hasMinuteCap} tierLabel={caps?.label ?? ''} isRunning={isRunning} onStart={onStart} />
</div>
<div className="text-[10px] text-muted">
@@ -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 (
<>
<div className="flex items-center gap-2">
@@ -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"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums text-foreground bg-base">
{totalDays}
{value}
</div>
<button
onClick={() => setValue(Math.min(15, value + 1))}
disabled={!hasMinuteCap || isRunning || extend.isPending || value >= 15}
onClick={() => setValue(Math.min(maxValue, value + 1))}
disabled={!hasMinuteCap || isRunning || extend.isPending || value >= maxValue}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
<span className="text-[10px] text-muted"></span>
{canUseMonth ? (
<div className="flex rounded-btn border border-border overflow-hidden">
{(['day', 'month'] as const).map(u => (
<button
key={u}
onClick={() => switchUnit(u)}
className={`px-2 py-0.5 text-[10px] font-medium transition-colors ${
unit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
}`}
>{u === 'day' ? '天' : '月'}</button>
))}
</div>
) : (
<span className="text-[10px] text-muted"></span>
)}
</div>
<button
@@ -165,7 +195,7 @@ function MinuteExtendControls({ hasMinuteCap, isRunning, onStart }: { hasMinuteC
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmOpen(false)} />
<div className="relative rounded-card border border-border bg-surface shadow-2xl mx-4 px-6 py-5 max-w-sm w-full space-y-4">
<div className="text-sm text-foreground text-center">K数据 {totalDays} K</div>
<div className="text-sm text-foreground text-center">K数据 {value} {unit === 'month' ? '月' : '天'}K</div>
<div className="flex items-center justify-center gap-3">
<button
onClick={() => { setConfirmOpen(false); extend.mutate() }}
+3 -1
View File
@@ -52,7 +52,9 @@ function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix }:
)
}
if (!hasCap && tierReq) {
if (!hasCap && tierReq && tierReq !== 'Free') {
// 缺权限且非 Free 档(付费档位才提示升级);Free 档人人可用,
// 若显示"需 Free"会造成 Expert 等用户困惑(通常是探测瞬时失败丢能力)
return (
<span className="text-[10px] text-warning/90 bg-warning/8 rounded px-1.5 py-px font-medium">
{tierReq}
@@ -0,0 +1,229 @@
import { useState } from 'react'
import { CalendarDays, TrendingUp, FileText, Wallet, Activity } from 'lucide-react'
import {
useFinancialMetrics,
useFinancialIncome,
useFinancialBalanceSheet,
useFinancialCashFlow,
} from '@/lib/useFinancials'
import { fmtPrice, fmtBigNum, fmtDate } from '@/lib/format'
import { Skeleton } from '@/components/data/Skeleton'
interface Props {
symbol: string
name: string
}
type TabKey = 'metrics' | 'income' | 'balance_sheet' | 'cash_flow'
const TABS: { key: TabKey; label: string; icon: typeof TrendingUp }[] = [
{ key: 'metrics', label: '核心指标', icon: TrendingUp },
{ key: 'income', label: '利润表', icon: FileText },
{ key: 'balance_sheet', label: '资产负债表', icon: Wallet },
{ key: 'cash_flow', label: '现金流量表', icon: Activity },
]
// 字段定义:键 → (中文名, 格式化类型)
// pct=百分点(存的是 12.3 表示 12.3%); amount=金额(元,转亿/万亿); perShare=每股; num=普通数值(保留2位)
type FmtType = 'pct' | 'amount' | 'perShare' | 'num'
type FieldDef = { label: string; fmt: FmtType; group?: string }
const FIELD_DEFS: Record<TabKey, FieldDef[]> = {
metrics: [
{ label: '基本每股收益 EPS', fmt: 'perShare', key: 'eps_basic' } as any,
{ label: '稀释每股收益 EPS', fmt: 'perShare', key: 'eps_diluted' } as any,
{ label: '每股净资产 BPS', fmt: 'perShare', key: 'bps' } as any,
{ label: '每股经营现金流', fmt: 'perShare', key: 'ocfps' } as any,
{ label: '净资产收益率 ROE', fmt: 'pct', key: 'roe' } as any,
{ label: '稀释 ROE', fmt: 'pct', key: 'roe_diluted' } as any,
{ label: '总资产收益率 ROA', fmt: 'pct', key: 'roa' } as any,
{ label: '销售毛利率', fmt: 'pct', key: 'gross_margin' } as any,
{ label: '销售净利率', fmt: 'pct', key: 'net_margin' } as any,
{ label: '资产负债率', fmt: 'pct', key: 'debt_to_asset_ratio' } as any,
{ label: '营业收入同比增长', fmt: 'pct', key: 'revenue_yoy' } as any,
{ label: '净利润同比增长', fmt: 'pct', key: 'net_income_yoy' } as any,
{ label: '经营现金/营收', fmt: 'pct', key: 'operating_cash_to_revenue' } as any,
{ label: '存货周转率', fmt: 'num', key: 'inventory_turnover' } as any,
],
income: [
{ label: '营业收入', fmt: 'amount', key: 'revenue' } as any,
{ label: '营业成本', fmt: 'amount', key: 'operating_cost' } as any,
{ label: '营业利润', fmt: 'amount', key: 'operating_profit' } as any,
{ label: '销售费用', fmt: 'amount', key: 'selling_expense' } as any,
{ label: '管理费用', fmt: 'amount', key: 'admin_expense' } as any,
{ label: '研发费用', fmt: 'amount', key: 'rd_expense' } as any,
{ label: '财务费用', fmt: 'amount', key: 'financial_expense' } as any,
{ label: '营业外收入', fmt: 'amount', key: 'non_operating_income' } as any,
{ label: '营业外支出', fmt: 'amount', key: 'non_operating_expense' } as any,
{ label: '利润总额', fmt: 'amount', key: 'total_profit' } as any,
{ label: '所得税', fmt: 'amount', key: 'income_tax' } as any,
{ label: '净利润', fmt: 'amount', key: 'net_income' } as any,
{ label: '归母净利润', fmt: 'amount', key: 'net_income_attributable' } as any,
{ label: '扣非净利润', fmt: 'amount', key: 'net_income_deducted' } as any,
{ label: '基本每股收益', fmt: 'perShare', key: 'basic_eps' } as any,
{ label: '稀释每股收益', fmt: 'perShare', key: 'diluted_eps' } as any,
],
balance_sheet: [
{ label: '资产总计', fmt: 'amount', key: 'total_assets' } as any,
{ label: '流动资产合计', fmt: 'amount', key: 'total_current_assets' } as any,
{ label: '非流动资产合计', fmt: 'amount', key: 'total_non_current_assets' } as any,
{ label: '货币资金', fmt: 'amount', key: 'cash_and_equivalents' } as any,
{ label: '应收账款', fmt: 'amount', key: 'accounts_receivable' } as any,
{ label: '存货', fmt: 'amount', key: 'inventory' } as any,
{ label: '固定资产', fmt: 'amount', key: 'fixed_assets' } as any,
{ label: '无形资产', fmt: 'amount', key: 'intangible_assets' } as any,
{ label: '商誉', fmt: 'amount', key: 'goodwill' } as any,
{ label: '负债合计', fmt: 'amount', key: 'total_liabilities' } as any,
{ label: '流动负债合计', fmt: 'amount', key: 'total_current_liabilities' } as any,
{ label: '非流动负债合计', fmt: 'amount', key: 'total_non_current_liabilities' } as any,
{ label: '短期借款', fmt: 'amount', key: 'short_term_borrowing' } as any,
{ label: '长期借款', fmt: 'amount', key: 'long_term_borrowing' } as any,
{ label: '应付账款', fmt: 'amount', key: 'accounts_payable' } as any,
{ label: '所有者权益合计', fmt: 'amount', key: 'total_equity' } as any,
{ label: '归母所有者权益', fmt: 'amount', key: 'equity_attributable' } as any,
{ label: '未分配利润', fmt: 'amount', key: 'retained_earnings' } as any,
{ label: '少数股东权益', fmt: 'amount', key: 'minority_interest' } as any,
],
cash_flow: [
{ label: '经营活动现金流净额', fmt: 'amount', key: 'net_operating_cash_flow' } as any,
{ label: '投资活动现金流净额', fmt: 'amount', key: 'net_investing_cash_flow' } as any,
{ label: '筹资活动现金流净额', fmt: 'amount', key: 'net_financing_cash_flow' } as any,
{ label: '固定资产/无形资产投资', fmt: 'amount', key: 'capex' } as any,
{ label: '现金及等价物净增加额', fmt: 'amount', key: 'net_cash_change' } as any,
],
}
function formatValue(v: number | null | undefined, fmt: FmtType): string {
if (v == null || Number.isNaN(v)) return '—'
switch (fmt) {
case 'pct':
// 存储的是百分点(12.3 表示 12.3%),直接保留2位 + %
return `${v.toFixed(2)}%`
case 'amount':
// 金额(元)→ 亿/万亿;保留负号
return fmtBigNum(v)
case 'perShare':
return fmtPrice(v, 2)
case 'num':
default:
return v.toFixed(2)
}
}
export function StockFinancialDetail({ symbol, name }: Props) {
const [tab, setTab] = useState<TabKey>('metrics')
const metrics = useFinancialMetrics(symbol)
const income = useFinancialIncome(symbol)
const balance = useFinancialBalanceSheet(symbol)
const cashFlow = useFinancialCashFlow(symbol)
const queryMap = {
metrics: metrics,
income: income,
balance_sheet: balance,
cash_flow: cashFlow,
} as const
const current = queryMap[tab]
// 按 period_end 降序(最新在前);同步默认 latest_only,通常只有1期
const rows = (current.data?.data ?? []).slice().sort((a, b) =>
(b.period_end ?? '').localeCompare(a.period_end ?? '')
)
const fieldDefs = FIELD_DEFS[tab]
// 头部报告期信息取最新一期(优先用当前 tab,兜底用 metrics)
const latestPeriod = rows[0]?.period_end ?? metrics.data?.data?.[0]?.period_end ?? null
const latestAnnounce = rows[0]?.announce_date ?? metrics.data?.data?.[0]?.announce_date ?? null
return (
<div className="rounded-card border border-border bg-surface overflow-hidden">
{/* 头部:标的 + 报告期 */}
<div className="px-5 py-4 border-b border-border flex items-center gap-3 flex-wrap">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-lg font-semibold text-foreground">{name}</span>
<span className="text-xs font-mono text-muted">{symbol}</span>
</div>
{latestPeriod && (
<div className="flex items-center gap-1.5 text-xs text-secondary ml-auto">
<CalendarDays className="h-3.5 w-3.5" />
<span> <span className="font-mono">{latestPeriod}</span></span>
{latestAnnounce && (
<span className="text-muted">· {fmtDate(latestAnnounce)}</span>
)}
</div>
)}
</div>
{/* 标签页 */}
<div className="flex items-center gap-1 px-3 pt-2 border-b border-border/60">
{TABS.map(t => {
const Icon = t.icon
const isActive = tab === t.key
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors ${
isActive
? 'border-accent text-accent'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
<Icon className="h-3.5 w-3.5" />
{t.label}
</button>
)
})}
</div>
{/* 表格内容 */}
<div className="p-4">
{current.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton w="w-32" h="h-4" />
<Skeleton w="w-20" h="h-4" />
</div>
))}
</div>
) : rows.length === 0 ? (
<div className="py-10 text-center text-xs text-muted">
{TABS.find(t => t.key === tab)?.label}
</div>
) : (
<div className="space-y-5">
{/* 多期时为每期渲染一组;单期时只有一组 */}
{rows.map((row, ri) => (
<div key={row.period_end ?? ri}>
{rows.length > 1 && (
<div className="text-[11px] text-muted mb-2 flex items-center gap-1.5">
<CalendarDays className="h-3 w-3" />
<span className="font-mono text-secondary">{row.period_end}</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-0">
{fieldDefs.map((def: any) => {
const val = row[def.key]
return (
<div
key={def.key}
className="flex items-baseline justify-between gap-3 py-2 border-b border-border/40"
>
<span className="text-xs text-secondary shrink-0">{def.label}</span>
<span className="text-sm font-mono tabular-nums text-foreground text-right">
{formatValue(val, def.fmt)}
</span>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
@@ -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<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className="relative w-full max-w-xl mx-auto">
<div className="relative flex items-center">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder="输入股票代码或名称,如 600000 / 浦发"
value={query}
onChange={(e) => { 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 && (
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted animate-spin" />
)}
</div>
<AnimatePresence>
{open && trimmed && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
className="absolute left-0 right-0 top-full mt-1.5 z-50 max-h-[360px] overflow-y-auto rounded-card border border-border bg-base shadow-xl"
>
{search.isLoading ? (
<div className="px-4 py-6 flex items-center justify-center gap-2 text-xs text-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</div>
) : results.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-muted">
</div>
) : (
results.map((r, i) => (
<button
key={r.symbol}
type="button"
onClick={() => handleSelect(r)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors duration-100 ${
i === activeIdx ? 'bg-accent/10 text-accent' : 'hover:bg-elevated text-foreground'
}`}
>
<span className="font-mono shrink-0 text-xs w-[88px]">{r.symbol}</span>
<span className="truncate text-sm flex-1">{r.name}</span>
{r.code && <span className="text-[10px] text-muted font-mono shrink-0">{r.code}</span>}
</button>
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+2
View File
@@ -39,6 +39,8 @@ export interface FinancialStatus {
available: boolean
tables: Record<string, { rows: number; symbols: number }>
last_sync: Record<string, string>
/** 服务端是否正在同步(手动触发)——驱动"同步中"UI 并防重复点击 */
syncing?: boolean
}
export interface FinancialMetricRecord {
+15
View File
@@ -13,3 +13,18 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
}
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
export const TIER_RANK: Record<string, number> = { 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
}
+29
View File
@@ -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({
+12 -1
View File
@@ -19,7 +19,18 @@ export function useStrategyPool() {
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, isInPool }
return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool }
}
+1
View File
@@ -733,6 +733,7 @@ export function Data() {
{showEndpointTest && (
<EndpointTestDialog
hasKey={settings.data?.has_tickflow_key ?? false}
tierLabel={settings.data?.tier_label ?? ''}
currentEndpoint={settings.data?.current_endpoint ?? ''}
onClose={() => setShowEndpointTest(false)}
/>
+86 -55
View File
@@ -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<string, string> = {
metrics: '核心指标',
@@ -12,29 +14,15 @@ const TABLE_LABELS: Record<string, string> = {
cash_flow: '现金流量表',
}
const METRIC_LABELS: Record<string, string> = {
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<string | null>(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={
<div className="flex gap-2">
<button
className="px-3 py-1.5 text-xs bg-card border border-border rounded-md hover:bg-accent transition-colors disabled:opacity-50"
className="px-3 py-1.5 text-xs bg-card border border-border rounded-md hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleSync('all')}
disabled={!!syncing}
disabled={syncing}
title={syncing ? '正在同步,请稍候…' : '同步全部财务表'}
>
<RefreshCw className={`inline w-3 h-3 mr-1 ${syncing ? 'animate-spin' : ''}`} />
{syncing
? <Loader2 className="inline w-3 h-3 mr-1 animate-spin" />
: <RefreshCw className="inline w-3 h-3 mr-1" />}
{syncing ? '同步中…' : '全部同步'}
</button>
</div>
}
/>
{!available || isLoading ? (
<div className="p-5 text-sm text-muted">
{isLoading ? '加载中…' : '暂无数据,点击"全部同步"从 TickFlow 拉取财务数据'}
{syncing && (
<div className="px-5 -mt-2 pb-1 text-xs text-accent/80 flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" />
TickFlow {syncedCount}/4
</div>
) : (
<div className="p-5 space-y-6">
{/* 各表状态卡片 */}
)}
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
{!isLoading && available && (
<div className="px-5 pt-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(TABLE_LABELS).map(([key, label]) => {
const info = tables[key]
@@ -95,12 +88,14 @@ export function Financials() {
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{label}</span>
<button
className="text-muted hover:text-foreground transition-colors disabled:opacity-50"
className="text-muted hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleSync(key)}
disabled={!!syncing}
title={`同步${label}`}
disabled={syncing}
title={syncing ? '正在同步…' : `同步${label}`}
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing === key ? 'animate-spin' : ''}`} />
{syncing
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <RefreshCw className="w-3.5 h-3.5" />}
</button>
</div>
<div className="mt-2 text-2xl font-semibold tabular-nums">
@@ -114,23 +109,8 @@ export function Financials() {
)
})}
</div>
{/* 指标说明 */}
<div className="bg-card border border-border rounded-lg p-4">
<h3 className="text-sm font-medium mb-3"></h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-1.5 text-xs">
{Object.entries(METRIC_LABELS).map(([key, label]) => (
<div key={key} className="flex gap-2">
<code className="text-primary/80 font-mono">{key}</code>
<span className="text-muted">{label}</span>
</div>
))}
</div>
</div>
{/* 最后同步时间 */}
{status?.last_sync && Object.keys(status.last_sync).length > 0 && (
<div className="text-xs text-muted">
<div className="text-xs text-muted mt-3">
: {Object.entries(status.last_sync).map(([k, v]) =>
`${TABLE_LABELS[k] || k}: ${new Date(v).toLocaleString()}`
).join(' / ')}
@@ -138,6 +118,57 @@ export function Financials() {
)}
</div>
)}
{isLoading ? (
<div className="p-5 text-sm text-muted"></div>
) : !available ? (
<div className="p-5 text-sm text-muted">"全部同步" TickFlow </div>
) : (
<>
{/* 个股搜索区 */}
<div className="px-5 pt-6 pb-2">
{selected ? (
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
</div>
<button
onClick={() => setSelected(null)}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-secondary hover:text-foreground rounded-btn border border-border hover:bg-elevated transition-colors shrink-0"
title="清除选择"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
// 未选股:醒目居中引导
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex items-center gap-2 text-sm text-secondary">
<Search className="h-4 w-4" />
<span></span>
</div>
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
<div className="text-[11px] text-muted"> 600000 / </div>
</div>
)}
</div>
{/* 个股详情 / 空引导 */}
<div className="px-5 pb-8">
{selected ? (
<StockFinancialDetail symbol={selected.symbol} name={selected.name} />
) : (
<EmptyState
icon={Search}
title="未选择股票"
hint="在上方搜索框输入股票代码或名称,选择后即可查看该股的核心指标、利润表、资产负债表与现金流量表。"
/>
)}
</div>
</>
)}
</>
)
}
+9 -1
View File
@@ -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<CardSize>(loadCardSize)
// 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化)
const [dailyKChartVisible, setDailyKChartVisible] = useState<boolean>(() => 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[] } = {}) =>
@@ -638,7 +638,10 @@ export function StrategyBacktest() {
const [scoringDraft, setScoringDraft] = useState<Record<string, number>>({})
const [strategyParams, setStrategyParams] = useState<Record<string, any>>(saved?.params ?? {})
const [overrides, setOverrides] = useState<Record<string, any>>(saved?.overrides ?? {})
const [result, setResult] = useState<StrategyBacktestResult | null>(saved?.result ?? null)
// result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时,
// 跨会话/拉新代码后自动渲染一个可能对应已失效策略的旧结果会造成困惑
// (切页不卸载组件,内存中的 result 仍保留,无需靠 localStorage 恢复)。
const [result, setResult] = useState<StrategyBacktestResult | null>(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!),