feat(definitions): 统一定义选择器前端 + 冒烟 API 按 id 复检 + 备份导入冒烟展示

- POST /api/definitions/smoke 支持 id 直查已存定义 (smoke_stored),
  统一选择器逐条冒烟无需回传原始定义; 不存在 fail-closed 404
- DefinitionPicker 可复用组件: 跨 kind 目录 (21 内置信号实测) +
  kind 筛选/搜索/依赖 chips/预热/坏定义隔离展示 + 逐条冒烟
- 设置页新增「定义目录」页签; Backup 导入结果新增「导入冒烟」小节
  (apply 响应 smoke 逐条渲染, 失败条目带 issue 编码)
- 验收: pnpm build 过; 浏览器 E2E 导出→覆盖导入 275 项→
  冒烟 21/21 通过零 issue; 后端 36 契约测试过

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-26 15:54:03 +08:00
parent ecec9f5e54
commit 5d06af60b2
9 changed files with 374 additions and 4 deletions
+10 -1
View File
@@ -15,7 +15,10 @@ router = APIRouter(prefix="/api/definitions", tags=["definitions"])
class SmokeRequest(BaseModel):
kind: str = Field(pattern="^(signal|factor)$")
definition: dict
definition: dict | None = None
# 二选一: 传 id 时按存储重查该定义复检 (统一选择器的"逐条冒烟"),
# 不必先把原始定义回传服务端。
id: str | None = None
@router.get("")
@@ -26,6 +29,12 @@ def list_definitions(request: Request) -> dict:
@router.post("/smoke")
def smoke_definition(req: SmokeRequest) -> dict:
"""导入冒烟: 返回 ok/issues/dependencies/warmup_bars, 不落盘。"""
if req.id is not None:
return defs.smoke_stored(req.kind, req.id, _data_dir(None))
if req.definition is None:
from fastapi import HTTPException
raise HTTPException(status_code=422, detail="definition 与 id 必须提供其一")
return defs.smoke_definition(req.kind, req.definition)
+19
View File
@@ -269,6 +269,25 @@ def smoke_definition(kind: str, definition: dict) -> dict:
}
def smoke_stored(kind: str, definition_id: str, data_dir: Path) -> dict:
"""按 id 复检已存定义 (统一选择器逐条冒烟); 不存在时 fail-closed 报错。"""
from fastapi import HTTPException
from app.factors import store as factor_store
from app.strategy import custom_signals
if kind == "signal":
definitions = custom_signals.load_all(data_dir)
elif kind == "factor":
definitions = factor_store.load_all(data_dir)
else:
raise HTTPException(status_code=422, detail=f"未知 kind: {kind}")
for definition in definitions:
if definition.get("id") == definition_id:
return smoke_definition(kind, definition)
raise HTTPException(status_code=404, detail=f"定义不存在: {kind}/{definition_id}")
def catalog(data_dir: Path) -> list[dict]:
"""全部已存定义的统一目录视图 (signal + factor), 供统一选择器消费。"""
from app.factors import store as factor_store
+15 -1
View File
@@ -15,7 +15,7 @@ import polars as pl
import pytest
from app.config import settings
from app.definitions import catalog, smoke_definition
from app.definitions import catalog, smoke_definition, smoke_stored
from app.services import config_backup
@@ -60,6 +60,20 @@ def test_smoke_ok_for_valid_signal_and_factor() -> None:
assert factor["warmup_bars"] == 6
def test_smoke_stored_by_id_roundtrip(tmp_path: Path):
"""统一选择器逐条冒烟: 按 id 从存储复检, 不存在时 fail-closed 404。"""
from app.strategy import custom_signals
custom_signals.save_one(tmp_path, {**_GOOD_SIGNAL})
result = smoke_stored("signal", "smoke_ok", tmp_path)
assert result["ok"] is True
import fastapi
with pytest.raises(fastapi.HTTPException) as ei:
smoke_stored("signal", "missing_id", tmp_path)
assert ei.value.status_code == 404
def test_smoke_flags_typos_and_invalid_definitions() -> None:
typo_signal = smoke_definition("signal", _TYPO_SIGNAL)
assert not typo_signal["ok"]
@@ -0,0 +1,222 @@
/**
* 统一定义选择器 — 跨 kind (信号/因子) 的目录浏览、选取与逐条冒烟 (P4)。
*
* 数据: GET /api/definitions (统一目录视图; 坏定义不阻断目录, 单条携带 error)。
* 冒烟: POST /api/definitions/smoke 按 id 复检 (校验 → 编译 → 依赖可解析),
* 导入/编辑后的定义当场验证, 不必跑一次回测才发现拼错列名。
*
* onSelect 提供时行可选中 (供表单嵌入); 否则为纯浏览器。
*/
import { useMemo, useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { FlaskConical, Loader2, Search, SearchX } from 'lucide-react'
import { api } from '@/lib/api'
import type { DefinitionsCatalogItem, DefinitionSmokeResult } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
const KIND_LABEL: Record<string, string> = { signal: '信号', factor: '因子' }
const LANG_LABEL: Record<string, string> = { conditions: '条件', dsl: 'DSL', python: 'Python' }
type Kind = 'signal' | 'factor'
/** 依赖列 chips: 超出 3 个折叠为 +N, 悬停看全量。 */
function DepChips({ deps }: { deps: string[] }) {
if (deps.length === 0) return <span className="text-[11px] text-muted">无依赖</span>
const visible = deps.slice(0, 3)
const rest = deps.length - visible.length
return (
<span className="flex flex-wrap items-center gap-1" title={deps.join(', ')}>
{visible.map((d) => (
<span key={d} className="rounded-full bg-elevated px-1.5 py-px text-[10px] text-muted">{d}</span>
))}
{rest > 0 && <span className="text-[10px] text-muted">+{rest}</span>}
</span>
)
}
/** 单条冒烟结果: ok 绿勾 / issues 逐条红字。 */
function SmokeResultView({ r }: { r: DefinitionSmokeResult }) {
if (r.ok) {
return (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] text-accent">
<span>冒烟通过</span>
{r.warmup_bars != null && <span className="text-muted">预热 {r.warmup_bars} bars</span>}
{r.dependencies.length > 0 && (
<span className="text-muted">依赖 {r.dependencies.join(', ')}</span>
)}
</div>
)
}
return (
<div className="mt-1.5 space-y-0.5">
{r.issues.map((i, idx) => (
<div key={idx} className="text-[11px] text-danger">
<span className="rounded bg-danger/10 px-1 py-px font-mono text-[10px]">{i.code}</span> {i.message}
</div>
))}
</div>
)
}
export function DefinitionPicker({ kinds = ['signal', 'factor'], onSelect, selectedId }: {
kinds?: ReadonlyArray<Kind>
onSelect?: (item: DefinitionsCatalogItem) => void
selectedId?: string
}) {
const [search, setSearch] = useState('')
const [kindFilter, setKindFilter] = useState<Kind | 'all'>(kinds.length === 1 ? kinds[0] : 'all')
const [smokeOf, setSmokeOf] = useState<string | null>(null) // 当前展开冒烟结果的 id
const listQ = useQuery({
queryKey: QK.definitions,
queryFn: () => api.definitionsList(),
staleTime: 30_000,
})
const smokeM = useMutation({
mutationFn: (item: DefinitionsCatalogItem) => api.definitionsSmoke(item.kind, item.id),
onSuccess: (_r, item) => setSmokeOf(item.id),
})
const items = useMemo(() => {
const all = listQ.data?.items ?? []
const kw = search.trim().toLowerCase()
return all.filter((it) =>
(kindFilter === 'all' || it.kind === kindFilter)
&& (!kw || it.id.toLowerCase().includes(kw) || it.label.toLowerCase().includes(kw)),
)
}, [listQ.data, kindFilter, search])
const counts = useMemo(() => {
const all = listQ.data?.items ?? []
return {
signal: all.filter((it) => it.kind === 'signal').length,
factor: all.filter((it) => it.kind === 'factor').length,
}
}, [listQ.data])
return (
<div className="space-y-2.5">
{/* 过滤行: kind chips + 搜索框 */}
<div className="flex flex-wrap items-center gap-2">
{kinds.length > 1 && (
<div className="flex items-center gap-1">
{(['all', ...kinds] as const).map((k) => (
<button
key={k}
onClick={() => setKindFilter(k)}
className={`rounded-full px-2.5 py-1 text-[11px] transition-colors ${
kindFilter === k
? 'bg-accent/15 text-accent'
: 'text-muted hover:bg-elevated hover:text-foreground'
}`}
>
{k === 'all' ? `全部 ${counts.signal + counts.factor}` : `${KIND_LABEL[k]} ${counts[k]}`}
</button>
))}
</div>
)}
<div className="relative ml-auto min-w-0 flex-1 sm:max-w-56">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索 id 或名称…"
className="w-full rounded-btn border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs text-foreground placeholder:text-muted focus:border-accent/40 focus:outline-none"
/>
</div>
</div>
{/* 目录列表 */}
{listQ.isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-muted">
<Loader2 className="h-4 w-4 animate-spin" /> 加载定义目录…
</div>
) : listQ.isError ? (
<div className="py-8 text-center text-xs text-danger">定义目录加载失败, 请刷新重试</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-1.5 py-8 text-xs text-muted">
<SearchX className="h-4 w-4" />
{search || kindFilter !== 'all' ? '没有匹配的定义' : '暂无自定义信号 / 因子 — 可在信号库或因子库中创建, 或从配置备份导入'}
</div>
) : (
<div className="space-y-1.5">
{items.map((it) => {
const selectable = !!onSelect
const selected = selectedId === it.id
const smoking = smokeM.isPending && smokeM.variables?.id === it.id
return (
<div
key={`${it.kind}:${it.id}`}
onClick={selectable ? () => onSelect!(it) : undefined}
className={`rounded-btn border px-3 py-2 transition-colors ${
selected
? 'border-accent/40 bg-accent/[0.06]'
: selectable
? 'cursor-pointer border-border/70 hover:border-accent/25 hover:bg-elevated/40'
: 'border-border/70'
}`}
>
<div className="flex items-center gap-2">
<span className={`shrink-0 rounded-full px-1.5 py-px text-[10px] ${
it.kind === 'signal' ? 'bg-accent/10 text-accent' : 'bg-secondary/10 text-secondary'
}`}>
{KIND_LABEL[it.kind]}
</span>
<span className="min-w-0 truncate text-xs font-medium text-foreground" title={it.id}>
{it.label}
</span>
<span className="shrink-0 truncate font-mono text-[10px] text-muted" title={it.id}>{it.id}</span>
<span className="ml-auto flex shrink-0 items-center gap-1.5">
{it.lang && LANG_LABEL[it.lang] && (
<span className="rounded-full bg-elevated px-1.5 py-px text-[10px] text-muted">{LANG_LABEL[it.lang]}</span>
)}
{it.timeframe && it.timeframe !== 'daily' && (
<span className="rounded-full bg-elevated px-1.5 py-px text-[10px] text-muted">{it.timeframe}</span>
)}
{it.kind === 'signal' && it.enabled === false && (
<span className="rounded-full bg-warning/10 px-1.5 py-px text-[10px] text-warning">已停用</span>
)}
{it.kind === 'factor' && it.status && it.status !== 'active' && (
<span className="rounded-full bg-elevated px-1.5 py-px text-[10px] text-muted">{it.status}</span>
)}
<button
onClick={(e) => { e.stopPropagation(); smokeM.mutate(it) }}
disabled={smoking}
title="冒烟: 校验 → 编译 → 依赖可解析 (不跑回测)"
className="inline-flex items-center gap-1 rounded-btn border border-border px-2 py-1 text-[10px] text-muted transition-colors hover:border-accent/30 hover:text-accent disabled:opacity-50"
>
{smoking ? <Loader2 className="h-3 w-3 animate-spin" /> : <FlaskConical className="h-3 w-3" />}
冒烟
</button>
</span>
</div>
{/* 元信息行: 依赖 / 预热 / 坏定义错误 */}
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{it.error ? (
<span className="text-[11px] text-danger" title={it.error}>定义异常: {it.error}</span>
) : (
<>
<DepChips deps={it.dependencies ?? []} />
{it.warmup_bars != null && (
<span className="text-[10px] text-muted">预热 {it.warmup_bars} bars</span>
)}
</>
)}
</div>
{/* 冒烟结果 (仅最近一条展开, 避免列表抖动) */}
{smokeOf === it.id && smokeM.variables?.id === it.id && smokeM.data && (
<div className="mt-1.5 border-t border-border/60 pt-1.5">
<SmokeResultView r={smokeM.data} />
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
+38
View File
@@ -2173,6 +2173,32 @@ export interface ConfigBackupApplyCategory {
imported: number
skipped_conflict: number
failed: { file: string; error: string }[]
/** 导入即冒烟 (仅 custom_signals/custom_factors): 逐条校验+编译+依赖检查结果 */
smoke?: (DefinitionSmokeResult & { file: string })[]
}
/** 统一定义目录项 (GET /api/definitions) — 信号+因子统一视图 */
export interface DefinitionsCatalogItem {
kind: 'signal' | 'factor'
id: string
label: string
enabled?: boolean
timeframe?: string
lang: string
status?: string
group?: string
dependencies?: string[]
warmup_bars?: number | null
/** 目录不因单个坏定义失败 — 坏条目携带错误原因 (隔离要求) */
error?: string
}
/** 导入冒烟结果 (POST /api/definitions/smoke) */
export interface DefinitionSmokeResult {
ok: boolean
issues: { code: string; message: string }[]
dependencies: string[]
warmup_bars: number | null
}
export interface ConfigBackupApplyResult {
@@ -3818,6 +3844,18 @@ export const api = {
})
},
// ── 统一定义目录 / 导入冒烟 (P4) ──
definitionsList: () =>
request<{ items: DefinitionsCatalogItem[] }>('/api/definitions'),
/** 逐条冒烟: 按 id 复检已存定义 (definition 与 id 二选一) */
definitionsSmoke: (kind: 'signal' | 'factor', id: string) =>
request<DefinitionSmokeResult & { file?: string }>('/api/definitions/smoke', {
method: 'POST',
body: JSON.stringify({ kind, id }),
}),
analysisMenu: (id: string) =>
request<AnalysisMenu>(`/api/analysis-menus/${encodeURIComponent(id)}`),
+2
View File
@@ -62,6 +62,8 @@ export const QK = {
backtestCandidates: ['backtest-candidates'] as const,
factorColumns: ['backtest-factor-columns'] as const,
factorLibrary: (assetType: string) => ['factors-library', assetType] as const,
/** 统一定义目录 (信号+因子, P4) — 定义选择器数据源 */
definitions: ['definitions'] as const,
miningRuns: ['backtest-mining-runs'] as const,
miningAvailability: (assetType: string, profile: string, start: string, end: string) =>
['backtest-mining-availability', assetType, profile, start, end] as const,
+3 -1
View File
@@ -6,11 +6,12 @@
import { Suspense, lazy, useEffect, useState, type ComponentType } from 'react'
import { useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Database, KeyRound, LayoutGrid, Radio, Sparkles, Settings2, PanelLeftClose, PanelLeftOpen, Clock3, Info, HardDriveDownload } from 'lucide-react'
import { Database, KeyRound, LayoutGrid, Library, Radio, Sparkles, Settings2, PanelLeftClose, PanelLeftOpen, Clock3, Info, HardDriveDownload } from 'lucide-react'
// 面板按 tab 按需加载: 8 个面板源码 270KB+, 同步打包会让设置页 chunk 膨胀
const SettingsAIPanel = lazy(() => import('./settings/AI').then(m => ({ default: m.SettingsAIPanel })))
const SettingsApiTokensPanel = lazy(() => import('./settings/ApiTokens').then(m => ({ default: m.SettingsApiTokensPanel })))
const SettingsBackupPanel = lazy(() => import('./settings/Backup').then(m => ({ default: m.SettingsBackupPanel })))
const SettingsDefinitionsPanel = lazy(() => import('./settings/Definitions').then(m => ({ default: m.SettingsDefinitionsPanel })))
const SettingsMonitoringPanel = lazy(() => import('./settings/Monitoring').then(m => ({ default: m.SettingsMonitoringPanel })))
const SettingsPageAdminPanel = lazy(() => import('./settings/PageAdmin').then(m => ({ default: m.SettingsPageAdminPanel })))
const SettingsTimeoutPanel = lazy(() => import('./settings/Timeout').then(m => ({ default: m.SettingsTimeoutPanel })))
@@ -38,6 +39,7 @@ const TABS: readonly TabDef[] = [
{ key: 'pages', label: '页面管理', icon: LayoutGrid, panel: SettingsPageAdminPanel },
{ key: 'timeout', label: '网络设置', icon: Clock3, panel: SettingsTimeoutPanel },
{ key: 'api-tokens', label: '开放接口', icon: KeyRound, panel: SettingsApiTokensPanel },
{ key: 'definitions', label: '定义目录', icon: Library, panel: SettingsDefinitionsPanel },
{ key: 'backup', label: '配置备份', icon: HardDriveDownload, panel: SettingsBackupPanel },
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
{ key: 'about', label: '关于', icon: Info, panel: SettingsAboutPanel },
+31 -1
View File
@@ -7,7 +7,7 @@
*/
import { useCallback, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { CheckCircle2, Download, FileArchive, RotateCcw, ShieldAlert, TriangleAlert, Upload } from 'lucide-react'
import { CheckCircle2, Download, FileArchive, FlaskConical, RotateCcw, ShieldAlert, TriangleAlert, Upload } from 'lucide-react'
import { api } from '@/lib/api'
import type { ConfigBackupApplyResult, ConfigBackupInspect } from '@/lib/api'
import { toast } from '@/components/Toast'
@@ -478,6 +478,36 @@ export function SettingsBackupPanel() {
)}
</div>
)}
{/* 导入即冒烟: 信号/因子逐条 校验→编译→依赖可解析 结果 */}
{applyResult.categories.some((c) => (c.smoke?.length ?? 0) > 0) && (
<div className="space-y-1.5 border-t border-border/60 pt-2">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-secondary">
<FlaskConical className="h-3.5 w-3.5 text-accent" />
导入冒烟
<span className="font-normal text-muted">校验 → 编译 → 依赖可解析 (不跑回测)</span>
</div>
{applyResult.categories.flatMap((c) =>
(c.smoke ?? []).map((sm) => (
<div key={`${c.key}:${sm.file}`} className="rounded-btn bg-elevated/50 px-2.5 py-1.5">
<div className="flex items-center gap-1.5 text-[11px]">
{sm.ok
? <CheckCircle2 className="h-3 w-3 shrink-0 text-accent" />
: <TriangleAlert className="h-3 w-3 shrink-0 text-danger" />}
<span className="truncate font-mono text-muted" title={sm.file}>{sm.file}</span>
{sm.ok && sm.warmup_bars != null && (
<span className="ml-auto shrink-0 text-muted">预热 {sm.warmup_bars} bars</span>
)}
</div>
{!sm.ok && sm.issues.map((i, idx) => (
<div key={idx} className="mt-0.5 pl-4.5 text-[11px] text-danger">
<span className="rounded bg-danger/10 px-1 py-px font-mono text-[10px]">{i.code}</span> {i.message}
</div>
))}
</div>
)),
)}
</div>
)}
{(applyResult.skipped_unselected > 0 || applyResult.out_of_scope_files > 0) && (
<div className="space-y-0.5 text-[11px] text-muted">
{applyResult.skipped_unselected > 0 && (
@@ -0,0 +1,34 @@
/**
* 统一定义目录面板 — "一切皆扩展"的消费面 (P4)。
*
* 跨 kind (信号/因子) 的统一目录 + 逐条导入冒烟。定义 (信号/因子/策略/
* 数据集) 都走同一条 契约校验 → 编译 → 依赖可解析 链路; 这里是它们的
* 总目录与体检入口, 也是配置备份导入后核对冒烟结果的落点。
*/
import { Library } from 'lucide-react'
import { DefinitionPicker } from '@/components/DefinitionPicker'
export function SettingsDefinitionsPanel() {
return (
<div className="mx-auto max-w-3xl space-y-4">
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-start gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-btn bg-accent/10 text-accent">
<Library className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">统一定义目录</div>
<div className="mt-0.5 text-[11px] leading-relaxed text-muted">
自定义信号与因子的统一视图 (语言 / 依赖 / 预热 / 状态)。
「冒烟」当场执行 校验 → 编译 → 依赖可解析, 拼错列名 (W001)
无需跑回测即可发现; 导入配置备份时同一检查自动执行。
</div>
</div>
</div>
<div className="mt-3">
<DefinitionPicker kinds={['signal', 'factor']} />
</div>
</div>
</div>
)
}