feat: 完善历史换手率重算入口

- 财务数据统计纳入历史股本,并在数据页提供分批重算入口

- 缺少股本历史时阻止重算,并接入现有任务进度

- 财务分析卡片使用更新图标,明确数据拉取语义
This commit is contained in:
shy3130
2026-07-19 16:42:03 +08:00
parent 8e4258cf20
commit b17c882e27
6 changed files with 87 additions and 13 deletions
+1 -1
View File
@@ -411,7 +411,7 @@ def _safe_aggregate_financials(repo) -> dict | None:
tables_info: dict[str, dict] = {}
total_rows = 0
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
for table in ("metrics", "income", "balance_sheet", "cash_flow", "shares"):
path = data_dir / "financials" / table / "part.parquet"
if path.exists():
try:
+18
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
from datetime import date
from types import SimpleNamespace
import polars as pl
import pytest
from app.api import data as data_api
from app.indicators import pipeline
from app.services import financial_sync
from app.tickflow.capabilities import CapabilitySet
@@ -159,3 +161,19 @@ def test_turnover_without_share_history_keeps_existing_behavior(monkeypatch):
)
assert result["turnover_rate"][0] == pytest.approx(0.5)
def test_data_status_includes_share_history(tmp_path):
path = tmp_path / "financials" / "shares" / "part.parquet"
path.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame({
"symbol": ["600000.SH", "600000.SH", "000001.SZ"],
"period_end": ["2023-12-31", "2024-06-30", "2024-06-30"],
}).write_parquet(path)
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
result = data_api._safe_aggregate_financials(repo)
assert result is not None
assert result["rows"] == 3
assert result["tables"]["shares"] == {"rows": 3, "symbols": 2}
@@ -5,10 +5,24 @@ import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
export function EnrichedRebuildPanel({ isRunning, onStart }: { isRunning: boolean; onStart: () => void }) {
type Props = {
isRunning: boolean
onStart: (jobId: string) => void
purpose?: 'enriched' | 'turnover'
historicalShareRows?: number
}
export function EnrichedRebuildPanel({
isRunning,
onStart,
purpose = 'enriched',
historicalShareRows = 0,
}: Props) {
const qc = useQueryClient()
const prefs = usePreferences()
const batchSize = prefs.data?.enriched_batch_size ?? 1000
const isTurnoverRebuild = purpose === 'turnover'
const canRebuild = !isTurnoverRebuild || historicalShareRows > 0
const [editing, setEditing] = useState(false)
const [draftSize, setDraftSize] = useState(String(batchSize))
const [hint, setHint] = useState<string | null>(null)
@@ -28,8 +42,8 @@ export function EnrichedRebuildPanel({ isRunning, onStart }: { isRunning: boolea
})
const rebuild = useMutation({
mutationFn: api.rebuildEnriched,
onSuccess: () => {
onStart()
onSuccess: ({ job_id }) => {
onStart(job_id)
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
},
})
@@ -93,18 +107,38 @@ export function EnrichedRebuildPanel({ isRunning, onStart }: { isRunning: boolea
</div>
<div>
<div className="text-[10px] text-muted mb-2"> kline_daily + adj_factor + + </div>
{isTurnoverRebuild && (
<div className={`mb-3 rounded-btn border px-3 py-2 text-[10px] leading-relaxed ${
canRebuild
? 'border-accent/20 bg-accent/5 text-secondary'
: 'border-warning/20 bg-warning/10 text-warning'
}`}>
{canRebuild
? `已检测到 ${historicalShareRows.toLocaleString()} 条历史股本记录。重算会按公告可用日匹配历史流通股本,并覆盖全部 Enriched 分区。`
: '未检测到历史股本数据,请先在财务分析页面同步“股本表”,再执行重算。'}
</div>
)}
<div className="text-[10px] text-muted mb-2">
{isTurnoverRebuild
? '为保证数据一致性,将基于现有日 K、除权因子和历史股本重新生成 Enriched;其他指标也会按当前逻辑同步更新。'
: '基于已有 kline_daily + adj_factor 全量计算前复权 + 技术指标 + 信号'}
</div>
<button
onClick={() => rebuild.mutate()}
disabled={isRunning || rebuild.isPending}
disabled={!canRebuild || isRunning || rebuild.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
>
{rebuild.isPending ? (
<><Loader2 className="h-3 w-3 animate-spin" /></>
) : (
<></>
<>{isTurnoverRebuild ? '重新计算并覆盖' : '全量计算'}</>
)}
</button>
{rebuild.isError && (
<div className="mt-2 text-[10px] text-danger">
{String((rebuild.error as Error)?.message ?? rebuild.error)}
</div>
)}
</div>
</div>
)
@@ -230,6 +230,7 @@ export function StatCard({
className={`p-0.5 rounded hover:bg-elevated transition-colors ${
settingsOpen ? 'text-accent' : 'text-secondary'
}`}
title="设置"
>
<Settings className="h-3.5 w-3.5" />
</button>
+24 -3
View File
@@ -512,19 +512,24 @@ export function Data() {
settingsOpen={openSettings === 'minute'}
/>
)
case 'financials':
case 'financials': {
const historicalShareRows = s?.financials?.tables?.shares?.rows ?? 0
return (
<StatCard
title="财务数据"
hint="利润表 / 资负表 / 现金流 / 指标"
hint="财报 / 指标 / 历史股本"
stats={s?.financials ? { rows: s.financials.rows } : null}
loading={isLoading}
tierKey="financials"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
customProvider={getCustomProviderName('financials')}
subLabel={`历史股本 · ${historicalShareRows.toLocaleString()}`}
onSettings={hasData ? () => setOpenSettings(v => v === 'financials' ? null : 'financials') : undefined}
settingsOpen={openSettings === 'financials'}
/>
)
}
default:
return null
}
@@ -958,7 +963,23 @@ export function Data() {
<AnimatePresence>
{openSettings === 'enriched' && (
<SettingsModal title="Enriched · 计算设置" onClose={() => setOpenSettings(null)}>
<EnrichedRebuildPanel isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
<EnrichedRebuildPanel
isRunning={!!activeJobId}
onStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }}
/>
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'financials' && (
<SettingsModal title="财务数据 · 换手率重算" onClose={() => setOpenSettings(null)}>
<EnrichedRebuildPanel
isRunning={!!activeJobId}
purpose="turnover"
historicalShareRows={s?.financials?.tables?.shares?.rows ?? 0}
onStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }}
/>
</SettingsModal>
)}
</AnimatePresence>
+3 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink, ChartPie } from 'lucide-react'
import { RefreshCw, Download, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink, ChartPie } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities } from '@/lib/useSharedQueries'
@@ -239,11 +239,11 @@ export function Financials() {
className="text-muted hover:text-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => handleSync(key)}
disabled={syncing}
title={syncing ? '正在同步…' : `同步${label}`}
title={syncing ? '正在同步…' : `更新${label}`}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
: <Download className="h-3.5 w-3.5" />}
</button>
</div>
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">