diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx index bf95d8c..3b26340 100644 --- a/frontend/web/app/backtest/page.tsx +++ b/frontend/web/app/backtest/page.tsx @@ -1,266 +1,246 @@ "use client"; /** - * 选股回测页 —— 研究闭环里的「定规则 + 验证」环节。 + * 回测组合(/backtest)—— 选若干选股策略 + 填回测参数 → 保存为组合 / 直接运行。 * - * 本页承担三种进入方式(都可分享 URL): - * - 直接进入:从默认参数开始调; - * - `?strategy=STG-xxx`:从策略库载入一个已保存策略(可微调、可另存); - * - `?from_selection=SEL-xxx`:从选股结果直通过来,预填当时的条件/因子/TopN - * (解决「选出来的股票要手工抄到回测页」的断点); - * - `?from_experiment=EXP-xxx`:从实验详情「以此参数再跑一次」进来。 + * 2026-09 重构后这里是回测的**唯一产品入口**: + * - 选股策略(怎么选股)来自 /strategies; + * - 费率 / 复权口径来自 /settings(公共配置,本页只读展示); + * - 本页只定「回测时才定的参数」:起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、 + * 调仓时机(日/周/月)、回测区间。 * - * 交互改进:参数区与说明/公式同屏(说明随参数实时更新,由后端推导); - * 「保存为策略」把当前参数存进策略库;结果区所有股票代码都带名称且可点进个股页。 + * 进入方式(都可分享 URL): + * - 直接进入:空组合开始; + * - `?combo=CMB-xxx`:载入已保存组合; + * - `?strategy=STG-xxx`:预先把该选股策略勾进组合(从策略库「加入回测组合」过来)。 */ -import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { apiGet, apiPost } from "@/lib/api"; -import { STAGE_LABEL, submitJob, waitJob } from "@/lib/jobs"; +import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api"; +import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs"; import { recentRange } from "@/lib/dates"; -import type { - ActionRecord, - BacktestResult, - FactorMeta, - ResearchCondition, - ResearchSpec, - SelectionResult, - StrategyDefinition, - SymbolCurve, -} from "@/lib/types"; -import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading } from "@/components/ui"; -import { - StrategyParamsForm, - casePreset, - emptyParams, - paramsFromSpec, - paramsFromStrategy, - strategyFromParams, - validateParams, - type StrategyParams, -} from "@/components/StrategyParamsForm"; -import { StrategyDocCard } from "@/components/StrategyDocCard"; +import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types"; +import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui"; import { BacktestResultView } from "@/components/BacktestResultView"; import { adjustLabel } from "@/lib/labels"; -import { useStrategyDoc } from "@/lib/strategy"; -import { SymbolLink, useSymbolNames } from "@/lib/symbols"; -type LoadState = { kind: "none" | "strategy" | "selection" | "experiment"; label: string; note?: string }; - -/** - * Next 15 要求使用 `useSearchParams` 的组件处于 Suspense 边界内, - * 否则静态渲染阶段会报错(本页用 query 决定「从策略/选股/实验载入」)。 - */ export default function BacktestPage() { return ( - }> + }> ); } +interface Draft { + name: string; + description: string; + strategyIds: string[]; + initialCapital: number; + holdCount: number; + holdMinDays: number; + holdMaxDays: number | null; // null = 不强制了结 + rebalanceFreq: "daily" | "weekly" | "monthly"; + start: string; + end: string; +} + +function emptyDraft(range: { start: string; end: string }): Draft { + return { + name: "", + description: "", + strategyIds: [], + initialCapital: 1_000_000, + holdCount: 20, + holdMinDays: 0, + holdMaxDays: null, + rebalanceFreq: "monthly", + start: range.start, + end: range.end, + }; +} + +function draftToCombo(d: Draft): BacktestCombo { + return { + name: d.name.trim(), + description: d.description.trim(), + strategy_ids: d.strategyIds, + initial_capital: d.initialCapital, + hold_count: d.holdCount, + hold_min_days: d.holdMinDays, + hold_max_days: d.holdMaxDays, + rebalance_freq: d.rebalanceFreq, + period: [d.start, d.end], + }; +} + +function comboToDraft(c: BacktestCombo): Draft { + return { + name: c.name, + description: c.description ?? "", + strategyIds: [...c.strategy_ids], + initialCapital: c.initial_capital, + holdCount: c.hold_count, + holdMinDays: c.hold_min_days, + holdMaxDays: c.hold_max_days, + rebalanceFreq: c.rebalance_freq, + start: c.period[0], + end: c.period[1], + }; +} + +/** 调仓时机中文标签(列表与摘要共用,避免两处写法漂移)。 */ +const FREQ_LABEL: Record = { + daily: "每日", + weekly: "每周", + monthly: "每月", +}; + +function validateDraft(d: Draft, strategies: SelectionStrategy[]): Record { + const e: Record = {}; + if (!d.strategyIds.length) e.strategies = "至少选择一个选股策略"; + else { + const missing = d.strategyIds.filter((id) => !strategies.some((s) => s.id === id)); + if (missing.length) e.strategies = `以下选股策略已不存在,请移除:${missing.join(", ")}`; + } + if (d.holdCount < 1) e.holdCount = "持仓数 N 至少为 1"; + if (d.holdMinDays < 0) e.holdMinDays = "Tmin 不能为负"; + if (d.holdMaxDays !== null && d.holdMaxDays < 1) e.holdMaxDays = "Tmax 至少为 1 天"; + if (d.holdMaxDays !== null && d.holdMinDays > 0 && d.holdMaxDays < d.holdMinDays) + e.holdMaxDays = `Tmax=${d.holdMaxDays} 不能小于 Tmin=${d.holdMinDays}`; + if (d.initialCapital < 10000) e.capital = "起始资金建议 ≥ 1 万"; + if (!d.start || !d.end) e.period = "起止日期都必填"; + else if (d.start >= d.end) e.period = "开始日期必须早于结束日期"; + return e; +} + function BacktestInner() { const search = useSearchParams(); - const strategyId = search.get("strategy"); - const selectionId = search.get("from_selection"); - const experimentId = search.get("from_experiment"); + const comboId = search.get("combo"); + const presetStrategy = search.get("strategy"); const range = useMemo(() => recentRange(), []); - const [params, setParams] = useState(null); - const [factors, setFactors] = useState([]); - const [loaded, setLoaded] = useState({ kind: "none", label: "" }); + const [strategies, setStrategies] = useState([]); + const [config, setConfig] = useState(null); + const [combos, setCombos] = useState(null); + const [draft, setDraft] = useState(emptyDraft(range)); + const [savedComboId, setSavedComboId] = useState(null); const [result, setResult] = useState(null); const [running, setRunning] = useState(false); + const [runningComboId, setRunningComboId] = useState(null); const [jobId, setJobId] = useState(""); const [stage, setStage] = useState(""); const [elapsed, setElapsed] = useState(0); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [saving, setSaving] = useState(false); - const [lastExp, setLastExp] = useState(null); - const [archiveId, setArchiveId] = useState(""); - const [restoring, setRestoring] = useState(false); + const [deletingComboId, setDeletingComboId] = useState(null); + const [revealErrors, setRevealErrors] = useState(false); const bootstrapped = useRef(false); - /* ---------- 初始化:因子表 + 默认/来源参数 ---------- */ + /* ---------- 初始化:策略列表 + 公共配置 + 已保存组合 + 来源载入 ---------- */ useEffect(() => { let alive = true; - apiGet("/factors") - .then((list) => alive && setFactors(list)) + Promise.all([ + apiGet("/strategies"), + apiGet("/config"), + ]) + .then(([sts, cfg]) => { + if (!alive) return; + setStrategies(sts); + setConfig(cfg); + }) .catch((e: Error) => alive && setError(e.message)); + // 组合库单独拉取:即便它失败,也不该连累策略列表与公共配置(否则整页只剩一条错误)。 + apiGet("/combos") + .then((cbs) => { + if (alive) setCombos(cbs); + }) + .catch((e: Error) => { + if (!alive) return; + setError(e.message); + // 给空列表,否则「组合库」会永远停在加载中 + setCombos((prev) => prev ?? []); + }); return () => { alive = false; }; }, []); + const loadCombos = useCallback(async () => { + try { + setCombos(await apiGet("/combos")); + } catch (e) { + setError((e as Error).message); + setCombos([]); + } + }, []); + useEffect(() => { if (bootstrapped.current) return; bootstrapped.current = true; - const base = emptyParams(range); - try { - const prev = window.localStorage.getItem("qlib:last-backtest-experiment"); - if (prev) setLastExp(prev); - } catch { - /* localStorage 不可用(隐私模式):只是少一个便捷入口,不影响主流程 */ - } - (async () => { - // 1) 策略库载入 - if (strategyId) { + if (comboId) { try { - const st = await apiGet(`/strategies/${encodeURIComponent(strategyId)}`); - setParams({ ...paramsFromStrategy(st, base), start: base.start, end: base.end }); - setLoaded({ - kind: "strategy", - label: `来自策略库:${st.name}(${st.id})`, - note: "参数已载入;改完可「另存为新策略」或直接运行回测。", - }); + const c = await apiGet(`/combos/${encodeURIComponent(comboId)}`); + setDraft(comboToDraft(c)); + setSavedComboId(c.id ?? null); + setNotice(`已载入回测组合「${c.name}」(${c.id})`); return; } catch (e) { - setError(`策略 ${strategyId} 载入失败:${(e as Error).message}`); + setError(`回测组合 ${comboId} 载入失败:${(e as Error).message}`); } } - // 2) 选股结果直通:把当时的口径原样搬过来 - if (selectionId) { - try { - const sel = await apiGet(`/selections/${encodeURIComponent(selectionId)}`); - // 选股把当时的查询原样存进 config_snapshot(复现用),这里据此预填 - const snapQ = (sel.config_snapshot ?? {}) as { - universe?: { exclude_st?: boolean; min_listing_days?: number }; - price_adjustment?: "none" | "qfq" | "hfq"; - factors?: { name: string; weight: number }[]; - conditions?: ResearchCondition[]; - top_n?: number | null; - }; - const topNQ = snapQ.top_n ?? base.topN; - setParams({ - ...base, - factors: snapQ.factors?.length ? snapQ.factors.map((f) => ({ ...f })) : base.factors, - conditions: (snapQ.conditions ?? []).map((c) => ({ ...c })), - topN: topNQ, - holdX: Math.max(1, Math.min(topNQ, base.holdX)), - excludeSt: snapQ.universe?.exclude_st ?? base.excludeSt, - minListingDays: snapQ.universe?.min_listing_days ?? base.minListingDays, - priceAdjustment: snapQ.price_adjustment ?? base.priceAdjustment, - start: base.start, - end: base.end, - }); - setLoaded({ - kind: "selection", - label: `来自选股 ${sel.as_of_date}(${sel.method})`, - note: - "已把该次选股的**规则**(条件/因子/TopN/口径)预填为回测参数。" + - "注意:回测会在每个择股日按同一规则重新选股,而不是固定持有该次选出的这批股票。", - }); - return; - } catch (e) { - setError(`选股结果 ${selectionId} 载入失败:${(e as Error).message}`); - } + if (presetStrategy) { + setDraft((d) => ({ ...d, strategyIds: [presetStrategy] })); + setNotice(`已把选股策略 ${presetStrategy} 预勾选进组合 —— 可再加其它策略、填好参数后运行。`); } - // 3) 实验参数复用 - if (experimentId) { - try { - const exp = await apiGet<{ result?: BacktestResult & { config_snapshot?: Record } }>( - `/experiments/${encodeURIComponent(experimentId)}` - ); - const snap = exp.result?.config_snapshot as Partial | undefined; - if (snap) { - setParams(paramsFromSpec(snap, base)); - setLoaded({ - kind: "experiment", - label: `来自实验 ${experimentId}`, - note: exp.result?.equity_curve?.length - ? `已复刻该实验参数,并直接展示它的归档结果(未重跑)。归档快照请到 /experiments/${experimentId} 查看;改参数后可再跑一次并与它对比。` - : "已复刻该实验的参数,可改后重跑并与原实验对比。", - }); - // 实验已归档完整结果:直接展示,省一次几分钟的重跑 - if (exp.result?.equity_curve?.length) { - setResult(exp.result); - setLastExp(experimentId); - setArchiveId(experimentId); // 提示条里给「打开归档」入口 - } - return; - } - setError(`实验 ${experimentId} 没有可复用的参数快照`); - } catch (e) { - const msg = (e as Error).message; - setError(`实验 ${experimentId} 载入失败:${msg}`); - // 归档可能已被删除:清掉 localStorage 里的指针,避免「载入上次结果」每次都给坏链接。 - // 只清指针(不影响参数区),并且只有在确实是 404 时才清。 - if (msg.includes("404")) { - try { - if (window.localStorage.getItem("qlib:last-backtest-experiment") === experimentId) { - window.localStorage.removeItem("qlib:last-backtest-experiment"); - setLastExp(null); - } - } catch { - /* localStorage 不可用:无需清理 */ - } - } - } - } - setParams(base); })(); - // 仅首次进入时按 URL 载入 // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // 提交被拦下过 → 立即展开所有校验错误(否则用户只看到顶部一条,不知道还有哪些字段有问题) - const [revealErrors, setRevealErrors] = useState(false); - const errors = params ? validateParams(params, { requireMeta: params.name.trim() !== "" }) : {}; - const blocking = ["topN", "holdX", "factors", "mMonths", "yMonths", "costs", "capital", "period", "conditions"].filter( - (k) => errors[k] - ); - const doc = useStrategyDoc(params); + const errors = validateDraft(draft, strategies); + const blocking = Object.keys(errors); + + function toggleStrategy(id: string) { + setDraft((d) => ({ + ...d, + strategyIds: d.strategyIds.includes(id) + ? d.strategyIds.filter((x) => x !== id) + : [...d.strategyIds, id], + })); + } async function run() { - if (!params) return; if (blocking.length) { setError(errors[blocking[0]]); setRevealErrors(true); - // 焦点管理:直接把人带到出问题的控件上,而不是只丢一句提示让人自己找 requestAnimationFrame(() => { - const el = document.querySelector(".input--invalid, .field--invalid input"); - el?.focus(); + const el = document.querySelector(".input--invalid, .field--invalid"); el?.scrollIntoView({ block: "center", behavior: "smooth" }); }); return; } + await runVia(() => submitComboJob(draftToCombo(draft)), null); + } + + /** + * 共用的「提交 Job → 轮询 → 出结果」管道。 + * + * 两条入口共用:① 用页面上的草稿运行(临时组合,不落库);② 从组合库直接运行已保存组合 + * (走 POST /combos/{id}/run,跑库里那份参数)。抽出来是为了让两条路径的阶段显示、 + * 归档提示、错误处理完全一致,不会一条修了另一条忘了。 + */ + async function runVia(submit: () => Promise<{ job_id: string }>, comboId: string | null) { setRunning(true); + setRunningComboId(comboId); setError(""); setNotice(""); setJobId(""); - setArchiveId(""); setResult(null); try { - const spec = { - type: "backtest" as const, - universe: { exclude_st: params.excludeSt, min_listing_days: params.minListingDays }, - price_adjustment: params.priceAdjustment, - factors: params.factors.filter((f) => f.name.trim() !== ""), - conditions: params.conditions.filter((c) => c.field.trim() !== ""), - selection: { - top_n: params.topN, - hold_top_x: params.holdX, - allow_substitute: params.fillPolicy === "substitute", - defer_buy: params.fillPolicy === "defer", - }, - rebalance: params.rebalance, - selection_interval_months: - params.mMonths > 0 ? params.mMonths : params.yMonths > 0 ? params.yMonths : null, - rebalance_interval_months: - params.yMonths > 0 ? params.yMonths : params.mMonths > 0 ? params.mMonths : null, - costs: { - commission_rate: params.commission / 100, - stamp_tax_rate: params.stamp / 100, - slippage_rate: params.slippage / 100, - min_commission: params.minCommission, - }, - initial_capital: params.capital, - period: [params.start, params.end] as [string, string], - }; - const { job_id } = await submitJob(spec); + const { job_id } = await submit(); setJobId(job_id); setStage("queued"); const out = await waitJob(job_id, 900_000, (info) => { @@ -270,20 +250,7 @@ function BacktestInner() { if (out.status === "success" && out.result) { setResult(out.result); const exp = out.experimentId ?? null; - if (exp) { - setLastExp(exp); - setArchiveId(exp); - try { - window.localStorage.setItem("qlib:last-backtest-experiment", exp); - } catch { - /* 忽略:仅影响下次进入的便捷入口 */ - } - } - setNotice( - exp - ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` - : "回测完成,已自动归档到「实验对比」页。" - ); + setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。"); } else { setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); } @@ -291,44 +258,83 @@ function BacktestInner() { setError((e as Error).message); } finally { setRunning(false); + setRunningComboId(null); setJobId(""); } } - /** 载入上次回测结果:避免刷新页面后必须重跑一个几分钟的作业 */ - async function loadLast() { - if (!lastExp) return; - setRestoring(true); + /* ---------- 组合库操作 ---------- */ + + /** 把库里的一份组合回填到表单(不自动运行,先把参数摆出来给用户看/改)。 */ + function loadCombo(c: BacktestCombo) { + setDraft(comboToDraft(c)); + setSavedComboId(c.id ?? null); + setError(""); + setRevealErrors(false); + setNotice(`已载入回测组合「${c.name}」(${c.id})—— 可直接运行,或改完参数再「更新组合」。`); + } + + /** 用库里的参数直接运行(不掺入表单里未保存的改动)。 */ + async function runSaved(c: BacktestCombo) { + if (!c.id) return; + setDraft(comboToDraft(c)); + setSavedComboId(c.id); + await runVia(() => runSavedCombo(c.id as string), c.id); + } + + /** 清空表单,开始一个新组合(不影响库里已有的)。 */ + function startNewCombo() { + setDraft(emptyDraft(range)); + setSavedComboId(null); + setResult(null); + setNotice("已清空表单,开始一个新组合。"); + setError(""); + setRevealErrors(false); + } + + async function removeCombo(c: BacktestCombo) { + if (!c.id) return; + if (!window.confirm(`删除回测组合「${c.name}」(${c.id})?此操作不可撤销(归档结果不受影响)。`)) { + return; + } + setDeletingComboId(c.id); setError(""); try { - const exp = await apiGet<{ result?: BacktestResult }>(`/experiments/${encodeURIComponent(lastExp)}`); - if (exp.result?.equity_curve?.length) { - setResult(exp.result); - setArchiveId(lastExp); - setNotice(`已载入上次归档结果(${lastExp}),未重新执行作业。`); - } else { - setError(`实验 ${lastExp} 没有可载入的回测结果`); - } + await apiDelete(`/combos/${encodeURIComponent(c.id)}`); + if (savedComboId === c.id) setSavedComboId(null); + setNotice(`已删除回测组合「${c.name}」。`); + await loadCombos(); } catch (e) { setError((e as Error).message); } finally { - setRestoring(false); + setDeletingComboId(null); } } - async function saveAsStrategy() { - if (!params) return; - const need = validateParams(params, { requireMeta: true }); - if (Object.keys(need).length) { - setError(Object.values(need)[0]); + async function saveCombo() { + if (blocking.length) { + setError(errors[blocking[0]]); + setRevealErrors(true); + return; + } + if (!draft.name.trim()) { + setError("保存组合需要起个名字"); + setRevealErrors(true); return; } setSaving(true); setError(""); try { - const saved = await apiPost("/strategies", strategyFromParams(params)); - setNotice(`已保存到策略库:「${saved.name}」(${saved.id})—— 可在策略库一键回测/编辑。`); - setLoaded({ kind: "strategy", label: `已保存为策略 ${saved.name}(${saved.id})` }); + const combo = draftToCombo(draft); + if (savedComboId) { + await apiPut(`/combos/${encodeURIComponent(savedComboId)}`, combo); + setNotice(`已更新回测组合「${combo.name}」`); + } else { + const saved = await apiPost("/combos", combo); + setSavedComboId(saved.id ?? null); + setNotice(`已保存回测组合「${saved.name}」(${saved.id})`); + } + await loadCombos(); } catch (e) { setError((e as Error).message); } finally { @@ -336,126 +342,289 @@ function BacktestInner() { } } + const showErr = (k: string) => (revealErrors ? errors[k] : undefined); + return ( <> - - 策略库 - - - 实验对比 - + 选股策略库 + 公共配置 + 实验对比 } /> - {lastExp && !result && !running ? ( -
- - 上次结果 - - - 本机上次回测已归档为实验 {lastExp}; - 直接载入可省去重跑(全市场 8 年区间约 3~5 分钟)。 - - - 载入上次结果 - - { - setLastExp(null); - try { - window.localStorage.removeItem("qlib:last-backtest-experiment"); - } catch { - /* 忽略 */ - } - }} - > - 忽略 - -
- ) : null} - - {loaded.kind !== "none" ? ( - - {loaded.label} - {loaded.note ? ` · ${loaded.note}` : ""} - - ) : null} {error ? {error} : null} - {notice ? ( - - {notice} - {archiveId ? ( - <> - {" "} - - 打开归档(完整快照) - - - ) : null} - + {notice ? {notice} : null} + + {/* 公共配置(只读) */} + {config ? ( + 去修改 + }> +
+ 佣金 {(config.commission_rate * 100).toFixed(3)}% + 印花税 {(config.stamp_tax_rate * 100).toFixed(2)}% + 滑点 {(config.slippage_rate * 100).toFixed(2)}% + 最低佣金 {config.min_commission} 元 + 复权 {adjustLabel(config.price_adjustment)} + 基准 {config.benchmark} +
+
+ 运行时这些值会被**快照**进归档,事后改公共配置不影响这次结果的数字。 +
+
) : null} + {/* 组合库:保存过的组合在这里能被找回(否则「保存」等于存进黑洞) */} - params && setParams({ ...casePreset(range), name: params.name, description: params.description })}> - 载入高股息案例默认参数 + + 新建组合 - - 保存为策略 + void loadCombos()}> + 刷新 } > - {params ? ( - <> - -
- - {running ? "后台运行中…" : "运行回测"} - - - n={params.topN} → x={params.holdX} · m={params.mMonths} / y={params.yMonths} ·{" "} - {adjustLabel(params.priceAdjustment)} · {params.start} ~ {params.end} - - {blocking.length ? ( - - 有 {blocking.length} 处参数需要修正 - - ) : ( - - 参数校验通过 - - )} -
- + {combos === null ? ( + + ) : combos.length === 0 ? ( + ) : ( - +
+ {combos.map((c) => { + const isCurrent = savedComboId === c.id; + const isRunning = runningComboId === c.id; + return ( +
+
+
+ {c.name} + {isCurrent ? ( + 当前载入 + ) : null} + {isRunning ? 运行中… : null} + {c.id} +
+
+ 持仓数 {c.hold_count} + + 持仓区间 [{c.hold_min_days}, {c.hold_max_days ?? "∞"}] 天 + + 调仓 {FREQ_LABEL[c.rebalance_freq] ?? c.rebalance_freq} + 区间 {c.period[0]} ~ {c.period[1]} + 引用策略 {c.strategy_ids.length} 个 +
+ {c.description ?
{c.description}
: null} +
+
+ loadCombo(c)}> + 载入到表单 + + void runSaved(c)} + > + 直接运行 + + void removeCombo(c)} + > + 删除 + +
+
+ ); + })} +
)} +
+ 「直接运行」用库里保存的那份参数(不受表单里未保存改动影响);「载入到表单」把参数摆出来, + 改完点上方「更新组合」覆盖,或直接点「运行回测组合」按当前草稿跑。 +
+
+ + {/* 选股策略多选 */} + 多策略取并集后按 Borda 秩和统一打分排序 + }> + {strategies.length === 0 ? ( + 先去策略库建一个「怎么选」的策略 + } /> + ) : ( +
+ {strategies.map((s) => { + const on = draft.strategyIds.includes(s.id ?? ""); + return ( + + ); + })} +
+ )} + {showErr("strategies") &&
{showErr("strategies")}
} +
+ + {/* 回测参数 */} + + + {savedComboId ? "更新组合" : "保存为回测组合"} + + + } + > +
+
+ + setDraft({ ...draft, name: e.target.value })} + /> + + + setDraft({ ...draft, description: e.target.value })} + /> + + + setDraft({ ...draft, initialCapital: Number(e.target.value) })} + /> + + + setDraft({ ...draft, holdCount: Number(e.target.value) })} + /> + + + + + + setDraft({ ...draft, holdMinDays: Number(e.target.value) })} + /> + + +
+ setDraft({ ...draft, holdMaxDays: e.target.value === "" ? null : Number(e.target.value) })} + /> + +
+
+ + setDraft({ ...draft, start: e.target.value })} /> + + + setDraft({ ...draft, end: e.target.value })} /> + +
+ {showErr("period") &&
{showErr("period")}
} + +
+ + {running ? "后台运行中…" : "运行回测组合"} + + + N={draft.holdCount} · [{draft.holdMinDays}, {draft.holdMaxDays ?? "∞"}] 天 ·{" "} + {draft.rebalanceFreq === "daily" ? "日频" : draft.rebalanceFreq === "weekly" ? "周频" : "月频"} ·{" "} + {draft.start} ~ {draft.end} · {draft.strategyIds.length} 个策略 + + {blocking.length ? ( + 有 {blocking.length} 处需要修正 + ) : ( + 参数校验通过 + )} +
+
{running ? (
- {["data_loading", "selection", "backtesting", "analysis"].map((st) => { - const order = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"]; + {["data_loading", "backtesting", "analysis"].map((st) => { + const order = ["queued", "data_loading", "backtesting", "analysis", "done"]; const cur = order.indexOf(stage); const mine = order.indexOf(st); const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage"; @@ -466,31 +635,21 @@ function BacktestInner() { ); })} - 作业 {jobId || "排队中…"} · 已用{" "} - {Math.round(elapsed / 1000)}s(全市场 8 年区间约 3~5 分钟,可离开本页,结果会归档) + 作业 {jobId || "排队中…"} · 已用 {Math.round(elapsed / 1000)}s + (全市场多年区间约 3~5 分钟,可离开本页,结果会归档)
) : null}
- - {!result && !running && !error ? ( - + ) : null} - {result ? : null} + {result ? : null} ); } diff --git a/frontend/web/app/experiments/[id]/page.tsx b/frontend/web/app/experiments/[id]/page.tsx index 647b526..ff24fe6 100644 --- a/frontend/web/app/experiments/[id]/page.tsx +++ b/frontend/web/app/experiments/[id]/page.tsx @@ -27,7 +27,7 @@ import { archivedFieldSummary } from "@/lib/archive"; import { PageHeader, Card, Pill, Empty, Banner } from "@/components/ui"; import { adjustLabel, experimentKindLabel, rebalanceLabel } from "@/lib/labels"; import { RichText } from "@/components/RichText"; -import type { ExperimentDetail, FactorMeta, ResearchCondition, ResearchSpec, StrategyDoc } from "@/lib/types"; +import type { BacktestCombo, BacktestResult, ExperimentDetail, FactorMeta, ResearchCondition, ResearchSpec, StrategyDoc } from "@/lib/types"; export const dynamic = "force-dynamic"; // 归档可能被删除/新增,禁止静态化缓存 @@ -74,14 +74,26 @@ export default async function ExperimentArchivePage({ // 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实) if (!detail) notFound(); - const spec = (detail.spec ?? null) as Partial | null; + const rawSpec = (detail.spec ?? null) as Record | null; + // 组合回测的归档:spec_json 是 BacktestCombo(含 strategy_ids/hold_count),不是 ResearchSpec。 + // 用同一套「选股条件/交易执行依据」卡去解读它会满屏「未记录」—— 那是谎报。 + // 这里识别出来交给专门的 ComboArchiveCards;成本/复权从 result.config_snapshot 取快照。 + const isComboArchive = + detail.kind === "backtest" && + !!rawSpec && + (Array.isArray(rawSpec.strategy_ids) || "hold_count" in rawSpec); const result = detail.result; + const spec = isComboArchive ? null : (rawSpec as Partial | null); + const comboSpec = isComboArchive ? (rawSpec as unknown as BacktestCombo) : null; + const comboCosts = isComboArchive + ? ((result as BacktestResult | null)?.config_snapshot as Record | undefined) + : undefined; // 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的) const fieldSummary = archivedFieldSummary(detail.kind, result); // 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误) // 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径, // 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。 - const doc = spec && detail.kind === "backtest" + const doc = spec && detail.kind === "backtest" && !isComboArchive ? await serverPost("/strategies/describe", spec) : null; const docBody = doc ? ((doc as { doc?: StrategyDoc }).doc ?? doc) : null; @@ -95,9 +107,9 @@ export default async function ExperimentArchivePage({ - + - {detail.kind === "backtest" ? ( + {detail.kind === "backtest" && !isComboArchive ? ( ; +}) { + const costObj = (costs?.costs ?? {}) as Record; + const adj = String(costs?.price_adjustment ?? "未记录"); + const freqLabel = + combo.rebalance_freq === "daily" ? "每日" : combo.rebalance_freq === "weekly" ? "每周" : "每月"; + return ( + 来自归档 spec + 运行时配置快照} + > +
+ + {(combo.strategy_ids ?? []).length === 0 ? ( + + ) : ( + (combo.strategy_ids ?? []).map((id) => ( + + )) + )} + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ ); +} + function FactorTestSpecCards({ spec, factors, @@ -377,11 +449,21 @@ function ArchivedSpecCards({ detail, spec, factors, + combo, + comboCosts, }: { detail: ExperimentDetail; spec: Partial | null; factors: FactorMeta[]; + combo?: BacktestCombo | null; + comboCosts?: Record; }) { + // 组合回测归档:spec 是 BacktestCombo,用专属卡片如实展示(引用的策略 + 回测参数 + + // 从 config_snapshot 取的成本/复权快照)。绝不能套单策略回测的「选股条件/交易执行依据」。 + if (combo) { + return ; + } + if (!spec) { return ( diff --git a/frontend/web/app/factors/compose/page.tsx b/frontend/web/app/factors/compose/page.tsx index 4fcc382..245f994 100644 --- a/frontend/web/app/factors/compose/page.tsx +++ b/frontend/web/app/factors/compose/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { apiGet } from "@/lib/api"; import { submitJob, waitJob } from "@/lib/jobs"; +import { factorLabel, pickableFactors } from "@/lib/factors"; import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types"; import { recentRange } from "@/lib/dates"; import { @@ -45,7 +46,8 @@ export default function ComposePage() { apiGet("/factors") .then((list) => { if (!alive) return; - setFactors(list); + // 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中) + setFactors(pickableFactors(list)); setPicks([ { name: "momentum_60", weight: 1.5 }, { name: "volatility_60", weight: 1.0 }, @@ -159,15 +161,23 @@ export default function ComposePage() { return ( - togglePick(f.name)} - aria-label={`选择因子 ${f.name}`} - /> + {/* 勾选框要用 .check--cell 包一层:裸 16px 的 checkbox 点击目标太小 + (本项目 UI 自检要求 ≥28px,且与同排输入框等高) */} + - {f.name} + {factorLabel(f)} + {/* 名字即身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */} + + {f.name} + diff --git a/frontend/web/app/factors/page.tsx b/frontend/web/app/factors/page.tsx index 5d11e6b..74beeb2 100644 --- a/frontend/web/app/factors/page.tsx +++ b/frontend/web/app/factors/page.tsx @@ -1,9 +1,15 @@ "use client"; -import { useEffect, useState } from "react"; -import { apiGet } from "@/lib/api"; +import { useCallback, useEffect, useState } from "react"; +import { apiGet, apiPatch, apiPost } from "@/lib/api"; import { submitJob, waitJob } from "@/lib/jobs"; -import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types"; +import type { + FactorMeta, + FactorParam, + FactorTemplate, + FactorTestReport, + ResearchSpec, +} from "@/lib/types"; import { recentRange } from "@/lib/dates"; import { PageHeader, @@ -18,8 +24,156 @@ import { SkeletonLines, } from "@/components/ui"; +/** + * 因子研究页。 + * + * 参数化(2026-10 后端能力)之后,这个页面的诚实性要求变了:参数(窗口 / 方向)**可以改**, + * 但改的方式是「从模板新建一个参数化因子」—— 参数写进因子名,名字即身份。所以本页要 + * ① 说清「改参数 = 新身份,旧因子与既有策略不变义」;② 把每行的真实参数摆出来; + * ③ 给一个受控的新建表单(范围来自 param_specs,越界由后端 422 拒绝,界面不静默纠正)。 + */ + +/** 方向枚举的中文说法:界面上不暴露 higher_is_better 这种原始值。 */ +const DIRECTION_TEXT: Record = { + higher_is_better: "越高越好", + lower_is_better: "越低越好", +}; + +/** 参数原始值 → 展示值(方向枚举翻译成中文,其余原样;缺值退回模板默认值)。 */ +function paramValueText(spec: FactorParam, raw: number | string | undefined | null): string { + const value = raw === undefined || raw === null ? spec.default : raw; + if (typeof value === "string" && DIRECTION_TEXT[value]) return DIRECTION_TEXT[value]; + return String(value); +} + +/** + * 参数原始值 → **写进名字里的字面值**(整数写数字、枚举写 higher_is_better)。 + * + * 与 paramValueText 分开的原因:名字是引擎键,必须原样;中文只用于给人看的文案。 + */ +function paramRawText(spec: FactorParam, raw: number | string | undefined | null): string { + const value = raw === undefined || raw === null ? spec.default : raw; + return String(value); +} + +/** 参数允许范围 / 枚举文案:展开行与新建表单共用一份,避免两处说法分叉。 */ +function paramRangeText(spec: FactorParam): string { + if (spec.kind === "int") { + const { minimum: min, maximum: max } = spec; + if (min !== undefined && min !== null && max !== undefined && max !== null) { + return `${min} ~ ${max} 的整数`; + } + return "整数"; + } + const choices = spec.choices ?? []; + if (choices.length === 0) return "受控枚举"; + return choices.map((c) => DIRECTION_TEXT[c] ?? c).join(" / "); +} + +/** 单个参数的「标签 + 值」片段;方向这类不重复写标签(值本身已经是中文说法)。 */ +function paramPartText(spec: FactorParam, raw: number | string | undefined | null): string { + const value = paramValueText(spec, raw); + return spec.name === "direction" ? value : `${spec.label} ${value}`; +} + +/** + * 目录里的「参数」摘要:`窗口 60 · 越高越好`。 + * + * 没有 int 参数的因子是逐日时点值(如股息率),补一句「时点值(无窗口)」—— + * 否则「没有窗口」和「窗口是 0」在界面上分不出来。 + */ +function paramSummary(f: FactorMeta): string { + const specs = f.param_specs ?? []; + if (specs.length === 0) return f.resolvable === false ? "无参数声明" : "无参数"; + const params = f.params ?? {}; + const parts = specs.map((s) => paramPartText(s, params[s.name])); + if (!specs.some((s) => s.kind === "int")) parts.unshift("时点值(无窗口)"); + return parts.join(" · "); +} + +/** + * 来源文案。 + * + * 为什么先判 resolvable:库里算不出来的历史手登记行,`source` 只是 DB 默认值 builtin, + * 直接读它会把它说成「内置实例」——那是假话。 + */ +function sourceText(f: FactorMeta): string { + if (f.resolvable === false) return "历史手工登记行"; + return f.source === "custom" ? "目录里的参数化实例" : "内置实例"; +} + +/** 界面显示名:优先后端给的中文名(含参数),没有才退回引擎键。 */ +function factorLabel(f: FactorMeta): string { + return f.label || f.name; +} + +/** 新建表单初值:模板默认值,并以 param_specs 的 default 兜底(defaults 缺项也不空着)。 */ +function defaultsOf(t: FactorTemplate): Record { + const out: Record = {}; + for (const s of t.param_specs) out[s.name] = t.defaults[s.name] ?? s.default; + return out; +} + +/** + * 预览因子键:严格按 param_specs 的顺序拼 `模板名(参数=值,...)`(方向天然在最后)。 + * + * 与后端 canonical_key 同一套形状 —— 预览错了就等于骗人,所以这里不做任何本地化/重排。 + */ +function previewKey(t: FactorTemplate, values: Record): string { + const args = t.param_specs.map((s) => `${s.name}=${paramRawText(s, values[s.name])}`); + return `${t.name}(${args.join(",")})`; +} + +/** 预览中文名:镜像后端 _default_label(非方向参数用「、」,方向用「,」接在最后)。 */ +function previewLabel(t: FactorTemplate, values: Record): string { + const parts: string[] = []; + let direction = ""; + for (const s of t.param_specs) { + const value = paramValueText(s, values[s.name]); + if (s.name === "direction") direction = value; + else parts.push(`${s.label} ${value}`); + } + const inner = [parts.join("、"), direction].filter(Boolean).join(","); + return `${t.label}(${inner})`; +} + +/** + * 模板公式里的 `{参数}` 占位符按**当前表单值**渲染(镜像后端 _render)。 + * + * 为什么不能在预览里直接摆模板原文:模板公式写的是 `close / close.shift({window}) - 1`, + * 而真实因子的公式是 `…shift(90)…` —— 摆原文会让预览和创建后的口径看起来不一致。 + */ +function renderFormula(t: FactorTemplate, values: Record): string { + return t.formula.replace(/\{(\w+)\}/g, (raw, key: string) => { + const spec = t.param_specs.find((s) => s.name === key); + return spec ? paramRawText(spec, values[key]) : raw; + }); +} + +/** + * 从 lib/api 抛出的错误里取出后端的 `detail` 原文。 + * + * 为什么:apiPost 的报错是 `POST /factors → 422: {"detail":"…"}` 整串文本,而 422 的 detail + * 已经是给人看的中文(含允许范围、已有重名)—— 必须原样转述;界面自己再写一遍错误文案, + * 就会和后端的受控范围说法分叉。 + */ +function apiDetail(e: unknown): string { + const raw = e instanceof Error ? e.message : String(e); + const start = raw.indexOf("{"); + if (start >= 0) { + try { + const body = JSON.parse(raw.slice(start)) as { detail?: unknown }; + if (typeof body.detail === "string" && body.detail) return body.detail; + } catch { + /* 响应体不是 JSON(如 500 的纯文本):退回整串信息,至少不丢状态码 */ + } + } + return raw; +} + export default function FactorsPage() { const [factors, setFactors] = useState([]); + const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const [checked, setChecked] = useState>(new Set()); const [expanded, setExpanded] = useState>(new Set()); @@ -31,6 +185,26 @@ export default function FactorsPage() { const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null); const [jobId, setJobId] = useState(""); const [error, setError] = useState(""); + /** 目录级成功提示(停用 / 启用)。 */ + const [notice, setNotice] = useState(""); + + // 新建参数化因子表单 + const [tplName, setTplName] = useState(""); + const [paramValues, setParamValues] = useState>({}); + const [creating, setCreating] = useState(false); + const [created, setCreated] = useState<{ name: string; label?: string } | null>(null); + const [createErr, setCreateErr] = useState(""); + /** 模板清单是否还在加载:与目录分开,避免「还没加载完」被说成「没加载出来」。 */ + const [tplLoading, setTplLoading] = useState(true); + /** 正在切换开关的因子名(只禁用那一行,不冻结全表)。 */ + const [toggling, setToggling] = useState(""); + + /** 只刷新目录列表:不动勾选 / 展开状态(新建或停用后刷新不该清掉用户的选择)。 */ + const refresh = useCallback(async () => { + const list = await apiGet("/factors"); + setFactors(list); + return list; + }, []); useEffect(() => { let alive = true; @@ -43,8 +217,22 @@ export default function FactorsPage() { ); setChecked(new Set(picks)); }) - .catch((e: Error) => setError(e.message)) + .catch((e: Error) => alive && setError(e.message)) .finally(() => alive && setLoading(false)); + // 模板清单单独取:它挂了只影响「新建参数化因子」卡片,不该把整个目录一起变成错误页 + apiGet("/factors/templates") + .then((tpls) => { + if (!alive) return; + setTemplates(tpls); + // 默认落在动量模板(最常见的例子)上,没有就取第一个 + const t = tpls.find((x) => x.name === "momentum") ?? tpls[0]; + if (t) { + setTplName(t.name); + setParamValues(defaultsOf(t)); + } + }) + .catch((e: Error) => alive && setCreateErr(e.message)) + .finally(() => alive && setTplLoading(false)); const { start: s, end: e } = recentRange(); setStart(s); setEnd(e); @@ -71,6 +259,60 @@ export default function FactorsPage() { }); } + function pickTemplate(name: string) { + setTplName(name); + setCreateErr(""); + setCreated(null); + const t = templates.find((x) => x.name === name); + setParamValues(t ? defaultsOf(t) : {}); + } + + function setParam(name: string, value: number | string) { + setParamValues((prev) => ({ ...prev, [name]: value })); + } + + async function createFactor() { + const tpl = templates.find((x) => x.name === tplName); + if (!tpl) { + setCreateErr("请先选择一个模板"); + return; + } + setCreating(true); + setCreateErr(""); + setCreated(null); + try { + // 参数值原样提交(整数就是数字/数字串,枚举就是原始值):受控校验只由后端做, + // 越界时转述 422 的 detail,界面不自己截断也不自己改写错误 + const row = await apiPost("/factors", { template: tpl.name, params: paramValues }); + setCreated({ name: row.name, label: row.label }); + await refresh(); + } catch (e) { + setCreateErr(apiDetail(e)); + } finally { + setCreating(false); + } + } + + async function toggleEnabled(f: FactorMeta) { + setToggling(f.name); + setNotice(""); + setError(""); + try { + const row = await apiPatch("/factors", { name: f.name, enabled: !f.enabled }); + setNotice( + row.enabled + ? `因子 ${row.name} 已启用,重新出现在选择列表里。` + : `因子 ${row.name} 已停用:它仍留在目录里(既有策略 / 归档仍按它的名字解析),只是不再出现在选择列表里。`, + ); + // 停用后照样刷新而不是本地隐藏 —— 停用的行必须还在目录里看得见 + await refresh(); + } catch (e) { + setError(apiDetail(e)); + } finally { + setToggling(""); + } + } + async function run() { const names = factors.map((f) => f.name).filter((n) => checked.has(n)); if (names.length === 0) { @@ -119,6 +361,9 @@ export default function FactorsPage() { const reportNames = Object.keys(reports); const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100); + const tpl = templates.find((x) => x.name === tplName) ?? null; + const tplSpecs = tpl?.param_specs ?? []; + return ( <> {error} ) : null} + {notice && !running ? {notice} : null} 已选 {selectedCount} 个} flush > + {/* 目录的性质说明:放在 loading 分支之外,加载中也能看到(也是 SSR 可断言的静态文案)。 + 参数化之后这里必须说清新分工:口径文案仍以代码为准,参数则靠「新建参数化因子」来改。 + overflowWrap:示例键里没有空格,窄屏必须能断行,否则整页会横向溢出。 */} +
+ 目录是代码注册表(quant/factors.py)的投影: + 口径文案(描述 / 公式 / 方向 / 回看)由引擎决定并自动同步(手改会在下次读取时被纠正回代码文本)。 + 参数(窗口、方向)可以改,改的方式是「从模板新建一个参数化因子」—— + 参数写进因子的名字里(如 momentum(window=90,direction=higher_is_better)), + 所以新因子是一个新身份:旧因子、既有策略与归档都按各自名字里的参数计算, + 不会变义。参数只在模板给定的受控范围内可选 / 可填,越界会被后端拒绝(不会静默截断成边界值)。 + 依赖列(requires)仍不可改 —— 它是「引擎能不能算」的事实,不是配置。 +
{loading ? (
@@ -158,9 +416,11 @@ export default function FactorsPage() { 因子 + 参数 回看 方向 简介(用法 / 何时有效) + 操作 @@ -170,8 +430,10 @@ export default function FactorsPage() { factor={f} checked={checked.has(f.name)} expanded={expanded.has(f.name)} + toggling={toggling === f.name} onToggleCheck={() => toggleCheck(f.name)} onToggleExpand={() => toggleExpand(f.name)} + onToggleEnabled={() => void toggleEnabled(f)} /> ))} @@ -184,6 +446,139 @@ export default function FactorsPage() { )} + 创建中 : undefined} + > + {tplLoading ? ( + + ) : templates.length === 0 ? ( +
+
+ 模板清单没加载出来(GET /api/factors/templates 未返回模板), + 所以暂时无法新建参数化因子;目录本身不受影响。 +
+ {createErr ? {createErr} : null} +
+ ) : ( +
+
+ + + + + {/* 受控表单:参数从模板 param_specs 来,顺序也照它(方向恒在最后) */} + {tplSpecs.map((s) => + s.kind === "int" ? ( + + setParam(s.name, e.target.value)} + /> + + ) : ( + + + + ), + )} + + + 新建参数化因子 + +
+ + {tpl ? ( + /* overflowWrap:预览键是长且无空格的引擎键,窄屏要能断行 */ +
+
创建预览
+
+ 因子键:{previewKey(tpl, paramValues)} + {" "}(参数顺序与模板一致,方向恒在最后;这个键就是身份) +
+
+ 中文名:{previewLabel(tpl, paramValues)} + {" "}· 公式 {renderFormula(tpl, paramValues)} +
+
+ 中文名按引擎的通用规则预览(量比这类有定制命名的模板,创建后以引擎返回的名字为准)。 +
+
+ ) : null} + + {created ? ( + + 已创建因子 {created.name} + {created.label ? <>({created.label}) : null}:参数已经写进名字里, + 这是一个新身份 —— 旧因子与既有策略不变义。 + + ) : null} + {createErr ? {createErr} : null} + +
+ 参数相同不会重复创建:同一模板、同一组参数只对应一个因子键,重复提交会收到后端的重名提示。 + 要换参数就再建一个(新键),不要指望改旧键 —— 旧键被改了,引用它的策略与归档就会变义。 +
+
+ )} +
+ 执行中 : undefined}>
@@ -238,8 +633,16 @@ export default function FactorsPage() { + {name} · {meta.brief} + + ) : ( + meta?.brief + ) + } tools={ 已完成 @@ -258,13 +661,23 @@ function FactorRow(props: { factor: FactorMeta; checked: boolean; expanded: boolean; + toggling: boolean; onToggleCheck: () => void; onToggleExpand: () => void; + onToggleEnabled: () => void; }) { const { factor: f } = props; + const label = factorLabel(f); + // 开关只对「目录里创建的参数化实例」开放:内置实例的开关由代码决定(后端会 422), + // 算不出来的历史行也不给开关(后端拒绝,且开关本来就没有意义) + const canToggle = f.source === "custom" && f.resolvable !== false; + const high = f.direction === "higher_is_better"; return ( <> - + e.stopPropagation()}>
+ } + /> + + {err ? {err} : null} + {msg ? {msg} : null} + + {/* 因子 vs 条件的关系(用户问题第 3 条) */} + +
+
+ 一句话:条件决定「有没有资格」,因子决定「谁排前面」。 + 两者在选股流程里是先后两步,不是二选一。 +
+
+ 1 股票池 剔除 ST / 上市天数不足 / 非指数成分 + 2 过滤条件 全部 AND 通过才有资格(不排序) + 3 打分因子 横截面 z-score 加权 → 复合分排序 + 4 取 TopN N 在回测组合里定,不在策略里 +
+
+ 用你库里的策略举例(高股息 Top20): + 条件 dv_ratio ≤ 30 先剔掉「股息率 > 30% 的异常样本」 + (多为一次性特别分红或股价暴跌,是典型的「高股息陷阱」)—— 这是准入; + 因子 dividend_yield 给剩下的股票打分排序, + 让股息率更高的排前面 —— 这是优先级。 + 同一个字段(比如股息率)既可以是条件也可以是因子:当条件用就是「筛掉」,当因子用就是「排序」。 +
+
+ 另外两点容易混:股票池(剔 ST、上市天数、指数成分)也是过滤,但它属于策略的 + 「universe」,在条件之前执行;取多少只(N)不属于选股策略 —— + 同一个策略配不同 N 是不同风险收益,所以它在「回测组合」里填。 +
+
+ 这里只列内置因子名(如 momentum_60)。 + 自己新建的参数化因子(如 momentum(window=90,direction=higher_is_better)) + 在因子研究页管理,选股策略的条件字段下拉里会一并出现 —— + 它们算的是同一个引擎字段,只是参数不同。 +
+
+
+ + {/* 新增字段 */} + + {options.length === 0 ? ( +
没有可新增的字段了 —— 引擎支持且未进库的字段都已加入。
+ ) : ( +
+ + + + + setAddLabel(e.target.value)} /> + + + {(() => { + const opt = options.find((o) => o.name === addName); + const units = opt?.units ?? []; + const base = opt?.unit ?? ""; + if (units.length < 2) { + return {base || "—"}(该字段只有基准单位); + } + return ( + + ); + })()} + + + setAddDesc(e.target.value)} + placeholder="例:总市值 = 总股本 × 收盘价(万元)" + /> + + + 加入字段库 + +
+ )} +
+ + void load()} disabled={busy}>刷新 + }> + {rows === null ? ( + + ) : rows.length === 0 ? ( + + ) : ( +
+ {groups.map((g) => ( +
+
{g.name} · {g.items.length}
+
+ + + + + + + + + + + + + {g.items.map((f) => + editName === f.name && draft ? ( + + + + + + + + + ) : ( + + + + + + + + + ) + )} + +
中文名字段名类型 / 单位含义 / 口径来源操作
setDraft({ ...draft, label: e.target.value })} />{f.name} + setDraft({ ...draft, unit })} + /> + + setDraft({ ...draft, description: e.target.value })} /> + {f.source === "builtin" ? "内置" : "自定义"} +
+ 保存 + { setEditName(null); setDraft(null); }}>取消 +
+
+ {f.label} + {!f.enabled ? <> 已停用 : null} + {f.name} + {KIND_LABEL[f.kind]} + {f.unit ? ( + + {f.unit} + {f.base_unit && f.unit !== f.base_unit ? ( + + (基准 {f.base_unit},×{factorOf(f.units, f.unit)}) + + ) : null} + + ) : null} + {f.description}{f.source === "builtin" ? "内置" : "自定义"} +
+ startEdit(f)}>编辑 + toggleEnabled(f)}> + {f.enabled ? "停用" : "启用"} + + {f.source === "custom" ? ( + remove(f)}> + 删除 + + ) : null} +
+
+
+
+ ))} +
+ 分组顺序与可用比较符由引擎类型决定:{groupNames.length} 个分组。文本字段只能「等于 / 不等于 / 属于 / + 不属于」,数值字段才能比大小 —— 这样不会摆出「行业 > 5」这种永远为假的选项。 + 内置字段不能删除(删掉下次读取会自动补回),不想看到就「停用」。 +
+
+ )} +
+ + ); +} \ No newline at end of file diff --git a/frontend/web/app/globals.css b/frontend/web/app/globals.css index c854680..b321b1f 100644 --- a/frontend/web/app/globals.css +++ b/frontend/web/app/globals.css @@ -708,11 +708,13 @@ input[type="date"].input { .factor-row { display: grid; /* 因子名列封顶 460px:再宽就是「momentum_60」配 1500px 下拉的失调(截图实测)。 - 权重列固定,操作列按内容。整行也限宽,避免在宽屏上拉成一条长线。 */ + 权重列固定,操作列按内容。整行也限宽,避免在宽屏上拉成一条长线。 + start 对齐:因子列比别列高(下拉 + 口径/方向/简介两行说明), + 居中会让权重框浮在说明旁边显得没对齐。 */ grid-template-columns: minmax(150px, 460px) 118px auto; - align-items: center; + align-items: start; gap: var(--sp-2); - max-width: 720px; + max-width: 760px; } .factor-rows__head { @@ -731,17 +733,69 @@ input[type="date"].input { .cond-rows { display: grid; gap: var(--sp-2); - max-width: 760px; + max-width: 940px; } .cond-rows__head, .cond-row { display: grid; - grid-template-columns: minmax(150px, 360px) 84px minmax(96px, 160px) auto; - align-items: center; + grid-template-columns: minmax(150px, 260px) 84px 110px minmax(120px, 190px) auto; + align-items: start; gap: var(--sp-2); } +/* 单元格:下拉 + 该选项的口径说明(因子行与条件行共用 —— 选了就能看到含义, + 不用切到「字段库」/「因子研究」页去查) */ +.cell-stack { + display: grid; + gap: 2px; + align-content: center; +} + +.cell-hint { + font-size: var(--fs-xs); + line-height: 1.35; + display: block; +} + +/* 取值单元格:输入框 + 右侧的**界面单位**后缀。存储值一律是基准单位,这里显示的是 + 字段库选的界面单位(如「亿元」),提交时按系数换算 —— 所以后缀必须始终可见, + 否则用户会把「5 亿元」当成「5 万元」来读。 */ +.unit-input { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 4px; +} + +.unit-suffix { + font-size: var(--fs-xs); + color: var(--text-3); + white-space: nowrap; +} + +/* 因子 vs 条件 的关系说明卡(表单内固定展示) */ +.relation-note { + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--r-2); + background: var(--surface-2, transparent); + display: grid; + gap: 8px; +} + +.relation-note__title { + font-weight: 600; + font-size: var(--fs-sm); +} + +.relation-note__flow { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + .cond-rows__head { color: var(--text-3); font-size: var(--fs-xs); @@ -2026,3 +2080,108 @@ button.chip:hover { background: var(--bg-2); color: var(--text-1); } + +/* ---------- 回测组合:选股策略多选卡片 ---------- */ +.strategy-pick { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--sp-2); +} + +.strategy-pick__item { + display: grid; + grid-template-columns: auto 1fr; + grid-template-areas: + "check name" + "check meta" + "desc desc" + "factors factors"; + gap: 2px var(--sp-2); + align-items: center; + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--line-strong); + border-radius: var(--r-sm); + background: var(--surface-2); + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.strategy-pick__item:hover { + border-color: rgba(150, 165, 195, 0.42); +} + +.strategy-pick__item.is-on { + border-color: var(--accent); + background: var(--accent-soft); +} + +.strategy-pick__item input[type="checkbox"] { + grid-area: check; + width: 16px; + height: 16px; +} + +.strategy-pick__name { + grid-area: name; + font-weight: 600; + color: var(--text-1); + font-size: var(--fs-sm); +} + +.strategy-pick__meta { + grid-area: meta; + color: var(--text-3); + font-size: var(--fs-xs); +} + +.strategy-pick__desc { + grid-area: desc; + color: var(--text-2); + font-size: var(--fs-xs); + line-height: 1.5; + margin-top: 4px; +} + +.strategy-pick__factors { + grid-area: factors; + color: var(--violet); + font-size: var(--fs-xs); + margin-top: 2px; +} + +/* ---------- 回测组合:已保存组合库 ---------- */ +/* 单列纵向列表(每行「摘要 + 操作」),与选股策略卡的网格区分: + 组合条目信息更密(参数 chip + 说明 + 三个动作),横排比网格更好扫读。 */ +.combo-list { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.combo-item { + display: flex; + flex-wrap: wrap; + gap: var(--sp-3); + align-items: center; + justify-content: space-between; + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--line-strong); + border-radius: var(--r-sm); + background: var(--surface-2); + transition: border-color 0.15s ease, background 0.15s ease; +} + +.combo-item:hover { + border-color: rgba(150, 165, 195, 0.42); +} + +/* 当前载入的组合:与 .strategy-pick__item.is-on 同一视觉语言(强调色描边) */ +.combo-item.is-on { + border-color: var(--accent); + background: var(--accent-soft); +} + +.combo-item__main { + min-width: 0; + flex: 1 1 320px; +} diff --git a/frontend/web/app/selection/page.tsx b/frontend/web/app/selection/page.tsx index 72a209b..c98ed38 100644 --- a/frontend/web/app/selection/page.tsx +++ b/frontend/web/app/selection/page.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { apiGet, apiPost } from "@/lib/api"; import { SymbolLink } from "@/lib/symbols"; import { waitJob } from "@/lib/jobs"; +import { factorOptionLabel, pickableFactors } from "@/lib/factors"; import type { FactorMeta, SelectionCondition, @@ -87,7 +88,8 @@ export default function SelectionPage() { useEffect(() => { let alive = true; apiGet("/factors") - .then((list) => alive && list.length > 0 && setFactors(list)) + // 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中) + .then((list) => alive && list.length > 0 && setFactors(pickableFactors(list))) .catch((e: Error) => alive && setError(e.message)); apiGet("/selections?limit=8") .then((rows) => alive && setHistory(rows)) @@ -241,7 +243,7 @@ export default function SelectionPage() { }} > {factors.map((f) => ( - + ))} (null); + const [draft, setDraft] = useState(DEFAULTS); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + const [msg, setMsg] = useState(""); + + useEffect(() => { + let alive = true; + apiGet("/config") + .then((c) => { + if (!alive) return; + setCfg(c); + setDraft(c); + }) + .catch((e: Error) => alive && setErr(e.message)); + return () => { + alive = false; + }; + }, []); + + async function save() { + setBusy(true); + setErr(""); + setMsg(""); + try { + const saved = await apiPut("/config", draft); + setCfg(saved); + setMsg("公共配置已保存(之后的回测组合将采用新值;已归档的结果不受影响)"); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + } + + // 只比较用户可编辑的字段:忽略 id / updated_at(后端回填,draft 里没有), + // 否则刚载入就会因 updated_at:null vs undefined 被判为「有改动」而误亮保存按钮。 + const editable = (c: GlobalConfig) => + JSON.stringify([c.commission_rate, c.stamp_tax_rate, c.slippage_rate, c.min_commission, c.price_adjustment, c.benchmark]); + const dirty = cfg ? editable(cfg) !== editable(draft) : false; + + return ( + <> + + + {err ? {err} : null} + {msg ? {msg} : null} + + {!cfg ? ( + + ) : ( + + {dirty ? 有未保存的修改 : null} + + 保存配置 + +
+ } + > +
+ + setDraft({ ...draft, commission_rate: Number(e.target.value) })} + /> + + + setDraft({ ...draft, stamp_tax_rate: Number(e.target.value) })} + /> + + + setDraft({ ...draft, slippage_rate: Number(e.target.value) })} + /> + + + setDraft({ ...draft, min_commission: Number(e.target.value) })} + /> + + + + + + setDraft({ ...draft, benchmark: e.target.value })} + /> + +
+ +
+ 费率以**小数**填写(0.0003 = 万三,不是 0.03%)。这里的值是全局默认;每个回测组合运行时 + 会把当时的成本/复权**快照**进归档,所以事后改这里不会改变历史结果的数字。 +
+
+ )} + + ); +} diff --git a/frontend/web/app/signals/page.tsx b/frontend/web/app/signals/page.tsx index 630439d..df5eec7 100644 --- a/frontend/web/app/signals/page.tsx +++ b/frontend/web/app/signals/page.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { apiGet, apiPost } from "@/lib/api"; import { SymbolLink } from "@/lib/symbols"; +import { factorOptionLabel, pickableFactors } from "@/lib/factors"; import type { FactorMeta, SelectionQuery, @@ -55,7 +56,8 @@ export default function SignalsPage() { useEffect(() => { let alive = true; apiGet("/factors") - .then((list) => alive && list.length > 0 && setFactors(list)) + // 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中) + .then((list) => alive && list.length > 0 && setFactors(pickableFactors(list))) .catch((e: Error) => alive && setError(e.message)); apiGet("/signals?limit=8").then(setHistory).catch(() => alive && setHistory([])); return () => { @@ -116,7 +118,7 @@ export default function SignalsPage() { diff --git a/frontend/web/app/strategies/page.tsx b/frontend/web/app/strategies/page.tsx index a034373..ab5de61 100644 --- a/frontend/web/app/strategies/page.tsx +++ b/frontend/web/app/strategies/page.tsx @@ -1,23 +1,17 @@ "use client"; /** - * 策略库(/strategies)—— 策略的命名资产中心。 + * 选股策略库(/strategies)—— 只管理「选股条件组合」。 * - * 职责: - * - 列表 + 检索:每个策略显示**一句话说明**与**计算公式**(后端 describe_strategy 推导)。 - * - 新建 / 编辑:复用 StrategyParamsForm(与回测页同一份参数模型与校验)。 - * - 一键回测:`POST /strategies/{id}/expand`(补 period + 资金)→ `POST /jobs` → 轮询 → - * 就地展示核心指标,并提供「查看实验详情 / 回到回测页看曲线」。 - * - 去回测页:`/backtest?strategy={id}`(回测页载入后可保存为策略、可微调)。 - * - * 设计取舍:编辑不弹窗而是就地展开表单(页面滚动位置不丢,参数多,弹窗太挤)。 + * 2026-09 重构后这里**不再有回测参数**(资金/持仓/调仓/费率/区间都移到回测组合与公共配置)。 + * 每个策略只回答「怎么选」:股票池 + 因子 + 过滤条件。要验证它,去 /backtest 把它 + * (可与其他策略一起)放进一个回测组合再跑。 */ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api"; -import { STAGE_LABEL, submitJob, waitJob, type JobOutcome } from "@/lib/jobs"; -import { recentRange } from "@/lib/dates"; -import type { BacktestResult, FactorMeta, StrategyDefinition } from "@/lib/types"; +import type { ConditionField, FactorMeta, ResearchCondition, SelectionStrategy } from "@/lib/types"; +import { displayUnitOf, fromBase, unitScale } from "@/lib/units"; import { Btn, Banner, @@ -26,40 +20,67 @@ import { Loading, PageHeader, Pill, - Progress, - BacktestMetrics, - Field, } from "@/components/ui"; import { - StrategyParamsForm, - casePreset, - emptyParams, - paramsFromStrategy, - strategyFromParams, - validateParams, - type StrategyParams, -} from "@/components/StrategyParamsForm"; + SelectionStrategyForm, + emptySelectionParams, + paramsFromSelectionStrategy, + selectionStrategyFromParams, + validateSelectionParams, + type SelectionStrategyParams, +} from "@/components/SelectionStrategyForm"; import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard"; -import { useStrategyDoc, useStrategyDocById } from "@/lib/strategy"; +import { useStrategyDocById } from "@/lib/strategy"; export default function StrategiesPage() { - const [list, setList] = useState(null); + const [list, setList] = useState(null); const [factors, setFactors] = useState([]); + const [conditionFields, setConditionFields] = useState([]); + /** + * 条件下拉用的字段表 = 字段库 + 因子目录里「字段库还没收录」的因子。 + * + * 为什么:参数化因子(`momentum(window=90,direction=…)`)是引擎真认的过滤字段 + * (`momentum_60 > 0` 一直合法),但它们不在字段库的注册表投影里。这里按名字去重地 + * 并进来 —— 内置因子本来就在字段库的「因子」分组里,不会重复出现。 + * 依赖列之类仍由字段库提供;因子条目的单位恒为空(因子是没有单位的无量纲量)。 + */ + const filterFields = useMemo(() => { + const known = new Set(conditionFields.map((f) => f.name)); + const factorFields: ConditionField[] = factors + .filter((f) => !known.has(f.name) && f.enabled !== false && f.resolvable !== false) + .map((f) => ({ + name: f.name, + label: f.label || f.name, + description: `${f.description}${f.brief ? ` 用法:${f.brief}` : ""}`, + kind: "num", + group_name: "因子", + unit: "", + source: f.source === "custom" ? "custom" : "builtin", + enabled: true, + sort_order: 900, + ops: ["gt", "gte", "lt", "lte", "eq", "ne"], + })); + return [...conditionFields, ...factorFields]; + }, [conditionFields, factors]); + /** 字段名 → 字段库条目:卡片回显条件时据此取中文名与界面单位。 */ + const fieldByName = useMemo( + () => new Map(filterFields.map((f) => [f.name, f])), + [filterFields], + ); + /** 因子名 → 目录条目:卡片/Pill 显示中文名(含参数),而不是又长又生的引擎键。 */ + const factorByKey = useMemo(() => new Map(factors.map((f) => [f.name, f])), [factors]); const [query, setQuery] = useState(""); const [err, setErr] = useState(""); const [msg, setMsg] = useState(""); - const [editing, setEditing] = useState(null); + const [editing, setEditing] = useState(null); const [editingId, setEditingId] = useState(null); - // 保存被拦下过 → 立即展开全部校验错误(见 StrategyParamsFormProps.revealErrors) const [revealErrors, setRevealErrors] = useState(false); const [busy, setBusy] = useState(false); const [docId, setDocId] = useState(null); - const range = useMemo(() => recentRange(), []); - const load = useCallback(async () => { try { - const rows = await apiGet("/strategies"); + const rows = await apiGet("/strategies"); setList(rows); setErr(""); } catch (e) { @@ -74,6 +95,10 @@ export default function StrategiesPage() { apiGet("/factors") .then((f) => alive && setFactors(f)) .catch(() => alive && setFactors([])); + // 字段库只取启用项:停用的字段不该出现在条件下拉里(管理在 /fields) + apiGet("/condition-fields?include_disabled=false") + .then((f) => alive && setConditionFields(f)) + .catch(() => alive && setConditionFields([])); return () => { alive = false; }; @@ -96,19 +121,18 @@ export default function StrategiesPage() { }); }, [list, query]); - /* ---------- 新建 / 编辑 ---------- */ function startCreate() { setEditingId(null); - setEditing(emptyParams(range)); + setEditing(emptySelectionParams()); setDocId(null); setMsg(""); setErr(""); setRevealErrors(false); } - function startEdit(s: StrategyDefinition) { + function startEdit(s: SelectionStrategy) { setEditingId(s.id ?? null); - setEditing(paramsFromStrategy(s, { ...emptyParams(range) })); + setEditing(paramsFromSelectionStrategy(s)); setDocId(s.id ?? null); setMsg(""); setErr(""); @@ -117,22 +141,22 @@ export default function StrategiesPage() { async function save() { if (!editing) return; - const errors = validateParams(editing, { requireMeta: true }); + const errors = validateSelectionParams(editing); if (Object.keys(errors).length) { setErr(Object.values(errors)[0]); - setRevealErrors(true); // 一次把问题全列出来,而不是让用户逐个试 + setRevealErrors(true); return; } setBusy(true); setErr(""); try { - const body = strategyFromParams(editing, editingId ?? undefined); + const body = selectionStrategyFromParams(editing, editingId ?? undefined); if (editingId) { - await apiPut(`/strategies/${encodeURIComponent(editingId)}`, body); - setMsg(`已更新策略「${body.name}」`); + await apiPut(`/strategies/${encodeURIComponent(editingId)}`, body); + setMsg(`已更新选股策略「${body.name}」`); } else { - const saved = await apiPost("/strategies", body); - setMsg(`已保存策略「${saved.name}」(${saved.id})`); + const saved = await apiPost("/strategies", body); + setMsg(`已保存选股策略「${saved.name}」(${saved.id})`); } setEditing(null); setEditingId(null); @@ -145,13 +169,13 @@ export default function StrategiesPage() { } } - async function remove(s: StrategyDefinition) { + async function remove(s: SelectionStrategy) { if (!s.id) return; - if (!window.confirm(`删除策略「${s.name}」?此操作不可撤销(已跑过的实验不受影响)。`)) return; + if (!window.confirm(`删除选股策略「${s.name}」?引用它的回测组合将无法运行。`)) return; setBusy(true); try { await apiDelete(`/strategies/${encodeURIComponent(s.id)}`); - setMsg(`已删除策略「${s.name}」`); + setMsg(`已删除选股策略「${s.name}」`); await load(); } catch (e) { setErr((e as Error).message); @@ -163,15 +187,15 @@ export default function StrategiesPage() { return ( <> - - 查看实验 + + 去建回测组合 - 新建策略 + 新建选股策略 } @@ -180,11 +204,10 @@ export default function StrategiesPage() { {err ? {err} : null} {msg ? {msg} : null} - {/* ---------- 编辑器 ---------- */} {editing ? ( { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}> @@ -196,68 +219,48 @@ export default function StrategiesPage() { } > - -
- 策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** —— - 这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。 -
-
- - setEditing({ - ...casePreset(range), - name: editing.name, - description: editing.description, - }) - } - > - 载入高股息案例默认参数 - +
+ 这里不填资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 回测区间 —— + 那些是回测时才定的,在「回测组合」里填;费率与复权在「公共配置」里设。
) : null} - {editing ? ( - - ) : null} - + {editing ? : null} {docId && !editing ? setDocId(null)} /> : null} - {/* ---------- 列表 ---------- */} setQuery(e.target.value)} - aria-label="搜索策略" + aria-label="搜索选股策略" /> } > {filtered === null ? ( - + ) : filtered.length === 0 ? ( ) : ( @@ -266,8 +269,9 @@ export default function StrategiesPage() { startEdit(s)} onDelete={() => remove(s)} onDoc={() => setDocId(s.id ?? null)} @@ -282,17 +286,22 @@ export default function StrategiesPage() { /* ------------------------------------------------------------------ */ -/** 未保存参数的实时说明预览 */ -function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) { - const live = useStrategyDoc(params, !savedId); - const saved = useStrategyDocById(savedId); - const state = savedId ? saved : live; +function EditingDoc({ id }: { id: string | null }) { + // 编辑未保存时无法预览后端说明(需要 id);保存后才能看 + const state = useStrategyDocById(id); + if (!id) { + return ( + +
保存后即可在此预览后端按选股条件推导的说明与公式。
+
+ ); + } return ( ); } @@ -318,120 +327,64 @@ function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) { ); } -/** 单个策略卡片:说明 + 参数摘要 + 一键回测 */ function StrategyCard({ s, - range, busy, + fieldByName, + factorByKey, onEdit, onDelete, onDoc, }: { - s: StrategyDefinition; - range: { start: string; end: string }; + s: SelectionStrategy; busy: boolean; + fieldByName: Map; + factorByKey: Map; onEdit: () => void; onDelete: () => void; onDoc: () => void; }) { - const [start, setStart] = useState("2020-01-01"); - const [end, setEnd] = useState(range.end); - const [capital, setCapital] = useState(1_000_000); - const [running, setRunning] = useState(false); - const [jobId, setJobId] = useState(""); - const [result, setResult] = useState(null); - const [stage, setStage] = useState(""); - const [expId, setExpId] = useState(""); - const [error, setError] = useState(""); - const [open, setOpen] = useState(false); - - const sel = s.selection ?? {}; - const costs = s.costs ?? {}; - - async function run() { - if (!s.id) return; - if (start >= end) { - setError("开始日期必须早于结束日期"); - return; - } - setRunning(true); - setError(""); - setResult(null); - setExpId(""); - try { - const spec = await apiPost>( - `/strategies/${encodeURIComponent(s.id)}/expand`, - { period: [start, end], initial_capital: capital } - ); - const { job_id } = await submitJob(spec as never); - setJobId(job_id); - setStage("queued"); - const out: JobOutcome = await waitJob(job_id, 900_000, (info) => - setStage(info.stage) - ); - if (out.status === "success" && out.result) { - setResult(out.result); - setExpId(out.experimentId ?? ""); - } else { - setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); - } - } catch (e) { - setError((e as Error).message); - } finally { - setRunning(false); - setJobId(""); - setStage(""); - } - } - + const u = s.universe ?? {}; return (
-
- {s.name} -
+
{s.name}
{s.id} {s.created_at ? 创建 {String(s.created_at).slice(0, 10)} : null}
- {s.factors?.map((f) => f.name).join(" + ") || "—"} + {s.factors?.map((f) => factorLabel(factorByKey.get(f.name), f.name)).join(" + ") || "—"}
-
{s.description || "(无说明:建议补一句话说明,便于日后识别)"}
+
{s.description || "(无说明:建议补一句话,便于日后识别)"}
- 候选池 {sel.top_n ?? "—"} → 持仓 {sel.hold_top_x ?? sel.top_n ?? "—"} - - - 择股 {s.selection_interval_months ?? "跟随 y"} 月 / 调仓{" "} - {s.rebalance_interval_months ?? "跟随 m"} 月 - - - 复权 {adjLabel(s.price_adjustment)} - - - {s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "} - {((costs.commission_rate ?? 0) * 100).toFixed(3)}% + 印花{" "} - {((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}% + 股票池 {u.index_code ? `${u.index_code} 成分` : "全市场"} + {u.exclude_st ? " · 剔 ST" : ""} + {u.min_listing_days ? ` · ≥${u.min_listing_days}天` : ""} {(s.conditions ?? []).length ? ( - 条件 {(s.conditions ?? []).map((c) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")} + 条件{" "} + + {(s.conditions ?? []) + .map((c) => `${fieldByName.get(c.field)?.label ?? c.field} ${opLabel(c.op)} ${condValueText(c, fieldByName.get(c.field))}`) + .join(" 且 ")} + - ) : null} + ) : ( + 无过滤条件 + )}
- - {running ? "后台运行中…" : "一键回测"} - - 载入回测页(可微调) + 加入回测组合 看说明/公式 @@ -443,77 +396,19 @@ function StrategyCard({ 删除
- - {/* 运行区间(默认 2020-01-01 ~ 最近交易日) */} -
setOpen((e.target as HTMLDetailsElement).open)}> - - 回测区间与资金(一键回测使用) - -
- - setStart(e.target.value)} disabled={running} /> - - - setEnd(e.target.value)} disabled={running} /> - - - setCapital(Number(e.target.value))} - disabled={running} - /> - -
-
- - {running ? ( - - 任务 {jobId || "排队中…"} 后台执行中(全市场回测通常 1~5 分钟) - - } - /> - ) : null} - {error ? {error} : null} - - {result ? ( -
-
- - 回测完成 - - - 在实验中对比 - - {expId ? ( - <> - - 打开归档(完整快照) - - - 以此参数再跑 - - - ) : null} -
- -
- ) : null}
); } -function adjLabel(m?: string): string { - return m === "hfq" ? "后复权" : m === "qfq" ? "前复权" : "不复权"; +/** + * 卡片上的因子名:优先中文名(含参数,如「动量(窗口 90,越高越好)」)。 + * + * 参数化因子的引擎键很长(`momentum(window=90,direction=higher_is_better)`), + * 卡片上直接显示会把布局撑坏;找不到目录条目时退回键名 —— 预览不到就照实显示, + * 不猜、不截断成看起来像另一个因子。 + */ +function factorLabel(meta: FactorMeta | undefined, name: string): string { + return meta?.label || name; } function opLabel(op: string): string { @@ -521,3 +416,21 @@ function opLabel(op: string): string { { gt: ">", gte: "≥", lt: "<", lte: "≤", eq: "=", ne: "≠", in: "属于", not_in: "不属于" }[op] ?? op ); } + +/** 条件取值的人类可读文本:存的是**基准单位**,这里按字段库的界面单位回显。 + * + * 卡片上必须带单位(如「≥ 5 亿元」):引擎按基准单位比较,用户看到的若是裸数字, + * 就会把「5 亿元」读成「5 万元」—— 这正是「单位可选」要避免的误读。 + */ +function condValueText(c: ResearchCondition, meta?: ConditionField): string { + if (c.ref) return `字段 ${c.ref}`; + const scale = unitScale(meta); + const unit = displayUnitOf(meta) || (meta?.base_unit ?? meta?.unit ?? ""); + const show = (x: unknown) => { + const n = Number(x); + return Number.isNaN(n) ? String(x ?? "") : String(fromBase(n, scale)); + }; + const body = Array.isArray(c.value) ? c.value.map(show).join("、") : show(c.value); + return unit ? `${body} ${unit}` : body; +} + diff --git a/frontend/web/components/SelectionStrategyForm.tsx b/frontend/web/components/SelectionStrategyForm.tsx new file mode 100644 index 0000000..37bab62 --- /dev/null +++ b/frontend/web/components/SelectionStrategyForm.tsx @@ -0,0 +1,628 @@ +"use client"; + +/** + * 选股策略表单(2026-09 重构):只编辑「选股条件组合」。 + * + * 与旧的 StrategyParamsForm 的区别:这里**没有**回测执行参数 —— + * 资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间都不属于选股策略, + * 它们在「回测组合」(/backtest)里才填。策略库只回答一个问题:**怎么选**。 + * + * 字段:名字 + 一句话说明 + 股票池(剔ST/上市天数/指数成分/白名单)+ 因子[名,权重] + 过滤条件。 + */ +import Link from "next/link"; +import { useId, useState } from "react"; +import { factorOptionLabel } from "@/lib/factors"; +import type { ConditionField, FactorMeta, ResearchCondition, SelectionStrategy } from "@/lib/types"; +import { baseUnitOf, displayUnitOf, fromBase, toBase, unitScale } from "@/lib/units"; +import { Btn, Field } from "@/components/ui"; + +export type Op = ResearchCondition["op"]; + +export const OPS: { value: Op; label: string }[] = [ + { value: "gt", label: ">" }, + { value: "gte", label: "≥" }, + { value: "lt", label: "<" }, + { value: "lte", label: "≤" }, + { value: "eq", label: "=" }, + { value: "ne", label: "≠" }, + { value: "in", label: "属于" }, + { value: "not_in", label: "不属于" }, +]; + +const OP_LABEL: Record = Object.fromEntries(OPS.map((o) => [o.value, o.label])); + +/** 把「属于 / 不属于」的取值写成给人看的逗号分隔串(数组 value ↔ 文本)。 */ +export function valueToText( + v: ResearchCondition["value"], + scale = 1, + asNumber = true, +): string { + if (v === null || v === undefined) return ""; + // 存储值一律是**基准单位**;这里换算成界面单位显示(scale=1 时原样) + const show = (x: unknown) => { + if (!asNumber) return String(x); + const n = Number(x); + if (Number.isNaN(n)) return String(x); + return String(fromBase(n, scale)); + }; + if (Array.isArray(v)) return v.map(show).join(", "); + return show(v); +} + +/** 文本 → 条件取值:属于/不属于 必为数组(引擎实体强制要求 list),其余按能否解析成数字。 + * 数字按**界面单位**输入,写回时换算成基准单位(存储与引擎只用基准单位)。 */ +export function textToValue( + raw: string, + op: Op, + scale = 1, + kind: "num" | "str" = "num", +): ResearchCondition["value"] { + const conv = (s: string): string | number => { + if (kind === "str") return s; + const n = Number(s); + return Number.isNaN(n) ? s : toBase(n, scale); + }; + if (op === "in" || op === "not_in") { + return raw + .split(/[,,]/) + .map((s) => s.trim()) + .filter(Boolean) + .map(conv); + } + if (raw === "") return ""; + return conv(raw); +} + +export interface SelectionStrategyParams { + name: string; + description: string; + factors: { name: string; weight: number }[]; + conditions: ResearchCondition[]; + excludeSt: boolean; + minListingDays: number; + indexCode: string; +} + +export function emptySelectionParams(): SelectionStrategyParams { + return { + name: "", + description: "", + factors: [{ name: "dividend_yield", weight: 1 }], + conditions: [], + excludeSt: true, + minListingDays: 250, + indexCode: "", + }; +} + +/** 后端 SelectionStrategy → 表单参数 */ +export function paramsFromSelectionStrategy(s: SelectionStrategy): SelectionStrategyParams { + const u = s.universe ?? {}; + return { + name: s.name, + description: s.description, + factors: (s.factors ?? []).map((f) => ({ name: f.name, weight: f.weight })), + conditions: (s.conditions ?? []).map((c) => ({ ...c })), + excludeSt: u.exclude_st ?? true, + minListingDays: u.min_listing_days ?? 250, + indexCode: u.index_code ?? "", + }; +} + +/** 表单参数 → 后端 SelectionStrategy(id 可选,用于新建/更新) */ +export function selectionStrategyFromParams( + p: SelectionStrategyParams, + id?: string +): SelectionStrategy { + return { + ...(id ? { id } : {}), + name: p.name.trim(), + description: p.description.trim(), + universe: { + exclude_st: p.excludeSt, + min_listing_days: p.minListingDays, + index_code: p.indexCode.trim() || null, + }, + factors: p.factors.filter((f) => f.name.trim()), + conditions: p.conditions.filter((c) => c.field.trim()), + }; +} + +export function validateSelectionParams(p: SelectionStrategyParams): Record { + const e: Record = {}; + if (!p.name.trim()) e.name = "策略名必填(便于在策略库中识别)"; + else if (p.name.trim().length > 64) e.name = "策略名最多 64 字"; + if (!p.description.trim()) e.description = "一句话说明必填:说清这个策略怎么选"; + else if (p.description.trim().length > 300) + e.description = `一句话说明最长 300 字(当前 ${p.description.trim().length} 字)`; + if (!p.factors.some((f) => f.name.trim())) e.factors = "至少选择一个因子"; + else { + const used = p.factors.map((f) => f.name.trim()).filter(Boolean); + const dup = used.find((n, i) => used.indexOf(n) !== i); + if (dup) e.factors = `因子「${dup}」重复了:同一因子只应出现一次(想加权重请调权重值)`; + } + for (const c of p.conditions) { + if (!c.field.trim()) { + e.conditions = "存在空的条件字段:请选择字段或删除该条件"; + break; + } + if (c.ref !== null && c.ref !== undefined && !String(c.ref).trim()) { + e.conditions = `条件「${c.field}」选择了「与另一字段比较」,但右侧字段是空的`; + break; + } + if (c.ref) continue; // 字段 vs 字段:右侧字段已校验 + if (c.op === "in" || c.op === "not_in") { + if (!Array.isArray(c.value) || c.value.length === 0) { + e.conditions = `条件「${c.field} ${OP_LABEL[c.op]}」需要至少一个取值(多个值用逗号分隔)`; + break; + } + continue; + } + if (c.value === null || c.value === undefined || String(c.value).trim() === "") { + e.conditions = `条件「${c.field}」缺少取值`; + break; + } + } + return e; +} + +export interface SelectionStrategyFormProps { + value: SelectionStrategyParams; + onChange: (next: SelectionStrategyParams) => void; + factorOptions: FactorMeta[]; + /** 字段库(/api/condition-fields,只传启用项):条件的字段从这里选,附带中文名与含义。 */ + conditionFields?: ConditionField[]; + disabled?: boolean; + errors?: Record; + revealErrors?: boolean; +} + +/** 该字段允许的比较符(字段库里没登记过的字段 → 按数值处理,交给后端报错)。 */ +function opsFor(meta: ConditionField | undefined): Op[] { + return (meta?.ops as Op[] | undefined) ?? ["gt", "gte", "lt", "lte", "eq", "ne"]; +} + +/** 换字段/换比较符时把比较符收敛到合法集合,避免出现「行业 > 5」这种永远为假的组合。 */ +function pickOp(current: Op, meta: ConditionField | undefined): Op { + const allowed = opsFor(meta); + if (allowed.includes(current)) return current; + return meta?.kind === "str" ? "eq" : "gte"; +} + +/** 取值随比较符变形:属于/不属于 必须是数组(引擎实体强制),其余退回标量。 */ +function coerceValue(v: ResearchCondition["value"], op: Op): ResearchCondition["value"] { + if (op === "in" || op === "not_in") { + if (Array.isArray(v)) return v; + return v === null || v === undefined || v === "" ? [] : [v as string | number]; + } + return Array.isArray(v) ? (v[0] ?? "") : v; +} + +/** 因子方向的人话(与「因子研究」页同一套说法)。 */ +function directionText(d: FactorMeta["direction"]): string { + return d === "lower_is_better" ? "越低越好" : "越高越好"; +} + +/** 因子频率的人话(后端存 daily/weekly/monthly,界面不该露出英文枚举)。 */ +function frequencyText(f: string): string { + return { daily: "日频", weekly: "周频", monthly: "月频" }[f] ?? f; +} + +/** 选中因子后的说明:真实参数/方向/频率/回看/依赖列 + 简介(与字段库的口径提示同一位置)。 */ +function factorHint(f: FactorMeta): string { + const parts: string[] = []; + // 真实参数(窗口等)放最前:这是「这个因子到底怎么算」的第一信息 + const specs = f.param_specs ?? []; + const params = specs + .filter((s) => s.kind === "int") + .map((s) => `${s.label} ${f.params?.[s.name] ?? s.default}`) + .join("、"); + if (params) parts.push(params); + parts.push(directionText(f.direction), frequencyText(f.frequency)); + parts.push(f.lookback ? `回看 ${f.lookback} 日` : "时点值(无回看窗口)"); + if (f.requires?.length) parts.push(`需要 ${f.requires.join(" / ")}`); + if (f.source === "custom") parts.push("目录里的参数化实例(参数写在名字里)"); + return `${parts.join(" · ")} — ${f.brief || f.description}`; +} + +export function SelectionStrategyForm({ + value: p, + onChange, + factorOptions, + conditionFields = [], + disabled = false, + errors = {}, + revealErrors = false, +}: SelectionStrategyFormProps) { + const fid = useId(); + const [touched, setTouched] = useState>({}); + const blur = (key: string) => () => setTouched((t) => ({ ...t, [key]: true })); + const showErr = (key: string) => (revealErrors || touched[key] ? errors[key] : undefined); + const set = (patch: Partial) => onChange({ ...p, ...patch }); + const setFactor = (i: number, patch: Partial<{ name: string; weight: number }>) => + set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) }); + + // 字段库 → 分组下拉 + 按名查含义(后端已按 sort_order 排序,这里保持首次出现顺序) + const fieldByName = new Map(conditionFields.map((f) => [f.name, f])); + const fieldGroups: { name: string; items: ConditionField[] }[] = []; + for (const f of conditionFields) { + const g = fieldGroups.find((x) => x.name === f.group_name); + if (g) g.items.push(f); + else fieldGroups.push({ name: f.group_name, items: [f] }); + } + + return ( +
+ {/* 名字 + 一句话说明 */} +
+ + set({ name: e.target.value })} + onBlur={blur("name")} + /> + {showErr("name") &&
{showErr("name")}
} +
+ +