feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单
字段库与单位: - `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改); 单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。 - `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、 提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。 - `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」 (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。 因子参数化(名字即身份,界面不许藏): - `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列 (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为 「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。 - 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉, 实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。 - 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**; 停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose), 既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。 - `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理, 选股策略条件下拉里会一并出现。 tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
This commit is contained in:
+496
-337
@@ -1,266 +1,246 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选股回测页 —— 研究闭环里的「定规则 + 验证」环节。
|
* 回测组合(/backtest)—— 选若干选股策略 + 填回测参数 → 保存为组合 / 直接运行。
|
||||||
*
|
*
|
||||||
* 本页承担三种进入方式(都可分享 URL):
|
* 2026-09 重构后这里是回测的**唯一产品入口**:
|
||||||
* - 直接进入:从默认参数开始调;
|
* - 选股策略(怎么选股)来自 /strategies;
|
||||||
* - `?strategy=STG-xxx`:从策略库载入一个已保存策略(可微调、可另存);
|
* - 费率 / 复权口径来自 /settings(公共配置,本页只读展示);
|
||||||
* - `?from_selection=SEL-xxx`:从选股结果直通过来,预填当时的条件/因子/TopN
|
* - 本页只定「回测时才定的参数」:起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、
|
||||||
* (解决「选出来的股票要手工抄到回测页」的断点);
|
* 调仓时机(日/周/月)、回测区间。
|
||||||
* - `?from_experiment=EXP-xxx`:从实验详情「以此参数再跑一次」进来。
|
|
||||||
*
|
*
|
||||||
* 交互改进:参数区与说明/公式同屏(说明随参数实时更新,由后端推导);
|
* 进入方式(都可分享 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 Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { apiGet, apiPost } from "@/lib/api";
|
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
||||||
import { STAGE_LABEL, submitJob, waitJob } from "@/lib/jobs";
|
import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs";
|
||||||
import { recentRange } from "@/lib/dates";
|
import { recentRange } from "@/lib/dates";
|
||||||
import type {
|
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
|
||||||
ActionRecord,
|
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
|
||||||
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 { BacktestResultView } from "@/components/BacktestResultView";
|
import { BacktestResultView } from "@/components/BacktestResultView";
|
||||||
import { adjustLabel } from "@/lib/labels";
|
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() {
|
export default function BacktestPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loading label="加载回测页…" />}>
|
<Suspense fallback={<Loading label="加载回测组合页…" />}>
|
||||||
<BacktestInner />
|
<BacktestInner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<Draft["rebalanceFreq"], string> = {
|
||||||
|
daily: "每日",
|
||||||
|
weekly: "每周",
|
||||||
|
monthly: "每月",
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateDraft(d: Draft, strategies: SelectionStrategy[]): Record<string, string> {
|
||||||
|
const e: Record<string, string> = {};
|
||||||
|
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() {
|
function BacktestInner() {
|
||||||
const search = useSearchParams();
|
const search = useSearchParams();
|
||||||
const strategyId = search.get("strategy");
|
const comboId = search.get("combo");
|
||||||
const selectionId = search.get("from_selection");
|
const presetStrategy = search.get("strategy");
|
||||||
const experimentId = search.get("from_experiment");
|
|
||||||
|
|
||||||
const range = useMemo(() => recentRange(), []);
|
const range = useMemo(() => recentRange(), []);
|
||||||
const [params, setParams] = useState<StrategyParams | null>(null);
|
const [strategies, setStrategies] = useState<SelectionStrategy[]>([]);
|
||||||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
const [config, setConfig] = useState<GlobalConfig | null>(null);
|
||||||
const [loaded, setLoaded] = useState<LoadState>({ kind: "none", label: "" });
|
const [combos, setCombos] = useState<BacktestCombo[] | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Draft>(emptyDraft(range));
|
||||||
|
const [savedComboId, setSavedComboId] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
const [runningComboId, setRunningComboId] = useState<string | null>(null);
|
||||||
const [jobId, setJobId] = useState("");
|
const [jobId, setJobId] = useState("");
|
||||||
const [stage, setStage] = useState("");
|
const [stage, setStage] = useState("");
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [lastExp, setLastExp] = useState<string | null>(null);
|
const [deletingComboId, setDeletingComboId] = useState<string | null>(null);
|
||||||
const [archiveId, setArchiveId] = useState("");
|
const [revealErrors, setRevealErrors] = useState(false);
|
||||||
const [restoring, setRestoring] = useState(false);
|
|
||||||
const bootstrapped = useRef(false);
|
const bootstrapped = useRef(false);
|
||||||
|
|
||||||
/* ---------- 初始化:因子表 + 默认/来源参数 ---------- */
|
/* ---------- 初始化:策略列表 + 公共配置 + 已保存组合 + 来源载入 ---------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
apiGet<FactorMeta[]>("/factors")
|
Promise.all([
|
||||||
.then((list) => alive && setFactors(list))
|
apiGet<SelectionStrategy[]>("/strategies"),
|
||||||
|
apiGet<GlobalConfig>("/config"),
|
||||||
|
])
|
||||||
|
.then(([sts, cfg]) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setStrategies(sts);
|
||||||
|
setConfig(cfg);
|
||||||
|
})
|
||||||
.catch((e: Error) => alive && setError(e.message));
|
.catch((e: Error) => alive && setError(e.message));
|
||||||
|
// 组合库单独拉取:即便它失败,也不该连累策略列表与公共配置(否则整页只剩一条错误)。
|
||||||
|
apiGet<BacktestCombo[]>("/combos")
|
||||||
|
.then((cbs) => {
|
||||||
|
if (alive) setCombos(cbs);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setError(e.message);
|
||||||
|
// 给空列表,否则「组合库」会永远停在加载中
|
||||||
|
setCombos((prev) => prev ?? []);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadCombos = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setCombos(await apiGet<BacktestCombo[]>("/combos"));
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
setCombos([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bootstrapped.current) return;
|
if (bootstrapped.current) return;
|
||||||
bootstrapped.current = true;
|
bootstrapped.current = true;
|
||||||
const base = emptyParams(range);
|
|
||||||
try {
|
|
||||||
const prev = window.localStorage.getItem("qlib:last-backtest-experiment");
|
|
||||||
if (prev) setLastExp(prev);
|
|
||||||
} catch {
|
|
||||||
/* localStorage 不可用(隐私模式):只是少一个便捷入口,不影响主流程 */
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
// 1) 策略库载入
|
if (comboId) {
|
||||||
if (strategyId) {
|
|
||||||
try {
|
try {
|
||||||
const st = await apiGet<StrategyDefinition>(`/strategies/${encodeURIComponent(strategyId)}`);
|
const c = await apiGet<BacktestCombo>(`/combos/${encodeURIComponent(comboId)}`);
|
||||||
setParams({ ...paramsFromStrategy(st, base), start: base.start, end: base.end });
|
setDraft(comboToDraft(c));
|
||||||
setLoaded({
|
setSavedComboId(c.id ?? null);
|
||||||
kind: "strategy",
|
setNotice(`已载入回测组合「${c.name}」(${c.id})`);
|
||||||
label: `来自策略库:${st.name}(${st.id})`,
|
|
||||||
note: "参数已载入;改完可「另存为新策略」或直接运行回测。",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(`策略 ${strategyId} 载入失败:${(e as Error).message}`);
|
setError(`回测组合 ${comboId} 载入失败:${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2) 选股结果直通:把当时的口径原样搬过来
|
if (presetStrategy) {
|
||||||
if (selectionId) {
|
setDraft((d) => ({ ...d, strategyIds: [presetStrategy] }));
|
||||||
try {
|
setNotice(`已把选股策略 ${presetStrategy} 预勾选进组合 —— 可再加其它策略、填好参数后运行。`);
|
||||||
const sel = await apiGet<SelectionResult>(`/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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 3) 实验参数复用
|
|
||||||
if (experimentId) {
|
|
||||||
try {
|
|
||||||
const exp = await apiGet<{ result?: BacktestResult & { config_snapshot?: Record<string, unknown> } }>(
|
|
||||||
`/experiments/${encodeURIComponent(experimentId)}`
|
|
||||||
);
|
|
||||||
const snap = exp.result?.config_snapshot as Partial<ResearchSpec> | 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 提交被拦下过 → 立即展开所有校验错误(否则用户只看到顶部一条,不知道还有哪些字段有问题)
|
const errors = validateDraft(draft, strategies);
|
||||||
const [revealErrors, setRevealErrors] = useState(false);
|
const blocking = Object.keys(errors);
|
||||||
const errors = params ? validateParams(params, { requireMeta: params.name.trim() !== "" }) : {};
|
|
||||||
const blocking = ["topN", "holdX", "factors", "mMonths", "yMonths", "costs", "capital", "period", "conditions"].filter(
|
function toggleStrategy(id: string) {
|
||||||
(k) => errors[k]
|
setDraft((d) => ({
|
||||||
);
|
...d,
|
||||||
const doc = useStrategyDoc(params);
|
strategyIds: d.strategyIds.includes(id)
|
||||||
|
? d.strategyIds.filter((x) => x !== id)
|
||||||
|
: [...d.strategyIds, id],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!params) return;
|
|
||||||
if (blocking.length) {
|
if (blocking.length) {
|
||||||
setError(errors[blocking[0]]);
|
setError(errors[blocking[0]]);
|
||||||
setRevealErrors(true);
|
setRevealErrors(true);
|
||||||
// 焦点管理:直接把人带到出问题的控件上,而不是只丢一句提示让人自己找
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.querySelector<HTMLElement>(".input--invalid, .field--invalid input");
|
const el = document.querySelector<HTMLElement>(".input--invalid, .field--invalid");
|
||||||
el?.focus();
|
|
||||||
el?.scrollIntoView({ block: "center", behavior: "smooth" });
|
el?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
});
|
});
|
||||||
return;
|
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);
|
setRunning(true);
|
||||||
|
setRunningComboId(comboId);
|
||||||
setError("");
|
setError("");
|
||||||
setNotice("");
|
setNotice("");
|
||||||
setJobId("");
|
setJobId("");
|
||||||
setArchiveId("");
|
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const spec = {
|
const { job_id } = await submit();
|
||||||
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);
|
|
||||||
setJobId(job_id);
|
setJobId(job_id);
|
||||||
setStage("queued");
|
setStage("queued");
|
||||||
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
|
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
|
||||||
@@ -270,20 +250,7 @@ function BacktestInner() {
|
|||||||
if (out.status === "success" && out.result) {
|
if (out.status === "success" && out.result) {
|
||||||
setResult(out.result);
|
setResult(out.result);
|
||||||
const exp = out.experimentId ?? null;
|
const exp = out.experimentId ?? null;
|
||||||
if (exp) {
|
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
|
||||||
setLastExp(exp);
|
|
||||||
setArchiveId(exp);
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem("qlib:last-backtest-experiment", exp);
|
|
||||||
} catch {
|
|
||||||
/* 忽略:仅影响下次进入的便捷入口 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setNotice(
|
|
||||||
exp
|
|
||||||
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
|
|
||||||
: "回测完成,已自动归档到「实验对比」页。"
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||||
}
|
}
|
||||||
@@ -291,44 +258,83 @@ function BacktestInner() {
|
|||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
|
setRunningComboId(null);
|
||||||
setJobId("");
|
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("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const exp = await apiGet<{ result?: BacktestResult }>(`/experiments/${encodeURIComponent(lastExp)}`);
|
await apiDelete(`/combos/${encodeURIComponent(c.id)}`);
|
||||||
if (exp.result?.equity_curve?.length) {
|
if (savedComboId === c.id) setSavedComboId(null);
|
||||||
setResult(exp.result);
|
setNotice(`已删除回测组合「${c.name}」。`);
|
||||||
setArchiveId(lastExp);
|
await loadCombos();
|
||||||
setNotice(`已载入上次归档结果(${lastExp}),未重新执行作业。`);
|
|
||||||
} else {
|
|
||||||
setError(`实验 ${lastExp} 没有可载入的回测结果`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setRestoring(false);
|
setDeletingComboId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAsStrategy() {
|
async function saveCombo() {
|
||||||
if (!params) return;
|
if (blocking.length) {
|
||||||
const need = validateParams(params, { requireMeta: true });
|
setError(errors[blocking[0]]);
|
||||||
if (Object.keys(need).length) {
|
setRevealErrors(true);
|
||||||
setError(Object.values(need)[0]);
|
return;
|
||||||
|
}
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
setError("保存组合需要起个名字");
|
||||||
|
setRevealErrors(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const saved = await apiPost<StrategyDefinition>("/strategies", strategyFromParams(params));
|
const combo = draftToCombo(draft);
|
||||||
setNotice(`已保存到策略库:「${saved.name}」(${saved.id})—— 可在策略库一键回测/编辑。`);
|
if (savedComboId) {
|
||||||
setLoaded({ kind: "strategy", label: `已保存为策略 ${saved.name}(${saved.id})` });
|
await apiPut<BacktestCombo>(`/combos/${encodeURIComponent(savedComboId)}`, combo);
|
||||||
|
setNotice(`已更新回测组合「${combo.name}」`);
|
||||||
|
} else {
|
||||||
|
const saved = await apiPost<BacktestCombo>("/combos", combo);
|
||||||
|
setSavedComboId(saved.id ?? null);
|
||||||
|
setNotice(`已保存回测组合「${saved.name}」(${saved.id})`);
|
||||||
|
}
|
||||||
|
await loadCombos();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -336,126 +342,289 @@ function BacktestInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showErr = (k: string) => (revealErrors ? errors[k] : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="选股回测"
|
title="回测组合"
|
||||||
sub="两级截断择股(候选池 n → 持仓 x)+ 择股/调仓双周期(m/y),收盘价撮合,买卖点与个股收益曲线可视化,结果自动归档可复现。"
|
sub="选一个或多个选股策略,填上回测参数(资金/持仓/持仓天数区间/调仓时机/区间),保存为组合或直接运行;已保存的组合在下方「组合库」里可载入、直接运行、删除。费率与复权口径取自公共配置。"
|
||||||
actions={
|
actions={
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
<Link href="/strategies" className="btn">
|
<Link href="/strategies" className="btn"><span>选股策略库</span></Link>
|
||||||
<span>策略库</span>
|
<Link href="/settings" className="btn"><span>公共配置</span></Link>
|
||||||
</Link>
|
<Link href="/experiments" className="btn"><span>实验对比</span></Link>
|
||||||
<Link href="/experiments" className="btn">
|
|
||||||
<span>实验对比</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lastExp && !result && !running ? (
|
|
||||||
<div className="sticky-bar" style={{ position: "static" }}>
|
|
||||||
<Pill tone="accent" icon="archive">
|
|
||||||
上次结果
|
|
||||||
</Pill>
|
|
||||||
<span className="hint">
|
|
||||||
本机上次回测已归档为实验 <span className="mono">{lastExp}</span>;
|
|
||||||
直接载入可省去重跑(全市场 8 年区间约 3~5 分钟)。
|
|
||||||
</span>
|
|
||||||
<Btn size="sm" icon="refresh" loading={restoring} disabled={restoring} onClick={loadLast}>
|
|
||||||
载入上次结果
|
|
||||||
</Btn>
|
|
||||||
<Btn
|
|
||||||
size="sm"
|
|
||||||
icon="x"
|
|
||||||
onClick={() => {
|
|
||||||
setLastExp(null);
|
|
||||||
try {
|
|
||||||
window.localStorage.removeItem("qlib:last-backtest-experiment");
|
|
||||||
} catch {
|
|
||||||
/* 忽略 */
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
忽略
|
|
||||||
</Btn>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loaded.kind !== "none" ? (
|
|
||||||
<Banner tone="info">
|
|
||||||
{loaded.label}
|
|
||||||
{loaded.note ? ` · ${loaded.note}` : ""}
|
|
||||||
</Banner>
|
|
||||||
) : null}
|
|
||||||
{error ? <Banner tone="error">{error}</Banner> : null}
|
{error ? <Banner tone="error">{error}</Banner> : null}
|
||||||
{notice ? (
|
{notice ? <Banner tone="info">{notice}</Banner> : null}
|
||||||
<Banner tone="info">
|
|
||||||
{notice}
|
{/* 公共配置(只读) */}
|
||||||
{archiveId ? (
|
{config ? (
|
||||||
<>
|
<Card icon="database" title="本次回测采用的公共配置(只读)" tools={
|
||||||
{" "}
|
<Link href="/settings" className="btn btn--sm"><span>去修改</span></Link>
|
||||||
<Link href={`/experiments/${encodeURIComponent(archiveId)}`} className="btn btn--sm">
|
}>
|
||||||
<span>打开归档(完整快照)</span>
|
<div className="chips">
|
||||||
</Link>
|
<span className="chip">佣金 <b>{(config.commission_rate * 100).toFixed(3)}%</b></span>
|
||||||
</>
|
<span className="chip">印花税 <b>{(config.stamp_tax_rate * 100).toFixed(2)}%</b></span>
|
||||||
) : null}
|
<span className="chip">滑点 <b>{(config.slippage_rate * 100).toFixed(2)}%</b></span>
|
||||||
</Banner>
|
<span className="chip">最低佣金 <b>{config.min_commission} 元</b></span>
|
||||||
|
<span className="chip">复权 <b>{adjustLabel(config.price_adjustment)}</b></span>
|
||||||
|
<span className="chip">基准 <b className="mono">{config.benchmark}</b></span>
|
||||||
|
</div>
|
||||||
|
<div className="hint" style={{ marginTop: 8 }}>
|
||||||
|
运行时这些值会被**快照**进归档,事后改公共配置不影响这次结果的数字。
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* 组合库:保存过的组合在这里能被找回(否则「保存」等于存进黑洞) */}
|
||||||
<Card
|
<Card
|
||||||
icon="gauge"
|
icon="archive"
|
||||||
title="研究参数(Research Specification)"
|
title={`已保存的回测组合${combos ? ` · ${combos.length}` : ""}`}
|
||||||
tools={
|
tools={
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
<Btn icon="target" disabled={running || !params} onClick={() => params && setParams({ ...casePreset(range), name: params.name, description: params.description })}>
|
<Btn size="sm" onClick={startNewCombo} disabled={running}>
|
||||||
载入高股息案例默认参数
|
新建组合
|
||||||
</Btn>
|
</Btn>
|
||||||
<Btn icon="book" loading={saving} disabled={running || saving || !params} onClick={saveAsStrategy}>
|
<Btn size="sm" onClick={() => void loadCombos()}>
|
||||||
保存为策略
|
刷新
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{params ? (
|
{combos === null ? (
|
||||||
<>
|
<Loading label="读取已保存的回测组合…" />
|
||||||
<StrategyParamsForm
|
) : combos.length === 0 ? (
|
||||||
value={params}
|
<Empty
|
||||||
onChange={setParams}
|
icon="archive"
|
||||||
factorOptions={factors}
|
title="还没有保存过回测组合"
|
||||||
showMeta
|
hint="勾选策略、填好参数后点「保存为回测组合」,之后就能在这里一键载入或直接运行。"
|
||||||
showPeriod
|
/>
|
||||||
disabled={running}
|
|
||||||
errors={errors}
|
|
||||||
revealErrors={revealErrors}
|
|
||||||
/>
|
|
||||||
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}>
|
|
||||||
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
|
|
||||||
{running ? "后台运行中…" : "运行回测"}
|
|
||||||
</Btn>
|
|
||||||
<span className="hint">
|
|
||||||
n={params.topN} → x={params.holdX} · m={params.mMonths} / y={params.yMonths} ·{" "}
|
|
||||||
{adjustLabel(params.priceAdjustment)} · {params.start} ~ {params.end}
|
|
||||||
</span>
|
|
||||||
{blocking.length ? (
|
|
||||||
<Pill tone="warn" icon="alert">
|
|
||||||
有 {blocking.length} 处参数需要修正
|
|
||||||
</Pill>
|
|
||||||
) : (
|
|
||||||
<Pill tone="pos" icon="check">
|
|
||||||
参数校验通过
|
|
||||||
</Pill>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<Loading label="准备参数…" />
|
<div className="combo-list">
|
||||||
|
{combos.map((c) => {
|
||||||
|
const isCurrent = savedComboId === c.id;
|
||||||
|
const isRunning = runningComboId === c.id;
|
||||||
|
return (
|
||||||
|
<div key={c.id ?? c.name} className={`combo-item${isCurrent ? " is-on" : ""}`}>
|
||||||
|
<div className="combo-item__main">
|
||||||
|
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<b>{c.name}</b>
|
||||||
|
{isCurrent ? (
|
||||||
|
<Pill tone="violet" icon="check">当前载入</Pill>
|
||||||
|
) : null}
|
||||||
|
{isRunning ? <Pill tone="warn">运行中…</Pill> : null}
|
||||||
|
<span className="mono hint">{c.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chips" style={{ marginTop: 6 }}>
|
||||||
|
<span className="chip">持仓数 <b>{c.hold_count}</b></span>
|
||||||
|
<span className="chip">
|
||||||
|
持仓区间 <b>[{c.hold_min_days}, {c.hold_max_days ?? "∞"}] 天</b>
|
||||||
|
</span>
|
||||||
|
<span className="chip">调仓 <b>{FREQ_LABEL[c.rebalance_freq] ?? c.rebalance_freq}</b></span>
|
||||||
|
<span className="chip">区间 <b>{c.period[0]} ~ {c.period[1]}</b></span>
|
||||||
|
<span className="chip">引用策略 <b>{c.strategy_ids.length}</b> 个</span>
|
||||||
|
</div>
|
||||||
|
{c.description ? <div className="strategy-desc">{c.description}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="strategy-actions">
|
||||||
|
<Btn size="sm" icon="edit" disabled={running} onClick={() => loadCombo(c)}>
|
||||||
|
载入到表单
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
icon="play"
|
||||||
|
loading={isRunning}
|
||||||
|
disabled={running}
|
||||||
|
onClick={() => void runSaved(c)}
|
||||||
|
>
|
||||||
|
直接运行
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
icon="trash"
|
||||||
|
loading={deletingComboId === c.id}
|
||||||
|
disabled={running}
|
||||||
|
onClick={() => void removeCombo(c)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="hint" style={{ marginTop: 8 }}>
|
||||||
|
「直接运行」用库里保存的那份参数(不受表单里未保存改动影响);「载入到表单」把参数摆出来,
|
||||||
|
改完点上方「更新组合」覆盖,或直接点「运行回测组合」按当前草稿跑。
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 选股策略多选 */}
|
||||||
|
<Card icon="book" title={`选择选股策略(已选 ${draft.strategyIds.length})`} tools={
|
||||||
|
<span className="hint">多策略取并集后按 Borda 秩和统一打分排序</span>
|
||||||
|
}>
|
||||||
|
{strategies.length === 0 ? (
|
||||||
|
<Empty icon="book" title="还没有选股策略" hint={
|
||||||
|
<Link href="/strategies">先去策略库建一个「怎么选」的策略</Link>
|
||||||
|
} />
|
||||||
|
) : (
|
||||||
|
<div className="strategy-pick">
|
||||||
|
{strategies.map((s) => {
|
||||||
|
const on = draft.strategyIds.includes(s.id ?? "");
|
||||||
|
return (
|
||||||
|
<label key={s.id} className={`strategy-pick__item${on ? " is-on" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={on}
|
||||||
|
onChange={() => s.id && toggleStrategy(s.id)}
|
||||||
|
aria-label={`选择选股策略 ${s.name}`}
|
||||||
|
/>
|
||||||
|
<span className="strategy-pick__name">{s.name}</span>
|
||||||
|
<span className="strategy-pick__meta mono">{s.id}</span>
|
||||||
|
<span className="strategy-pick__desc">{s.description || "(无说明)"}</span>
|
||||||
|
<span className="strategy-pick__factors">
|
||||||
|
{s.factors?.map((f) => f.name).join(" + ") || "—"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showErr("strategies") && <div className="field-err" role="alert">{showErr("strategies")}</div>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 回测参数 */}
|
||||||
|
<Card
|
||||||
|
icon="gauge"
|
||||||
|
title={savedComboId ? `回测参数 · 正在编辑组合 ${savedComboId}` : "回测参数 · 新组合"}
|
||||||
|
tools={
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<Btn icon="book" loading={saving} disabled={running || saving} onClick={saveCombo}>
|
||||||
|
{savedComboId ? "更新组合" : "保存为回测组合"}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="params-form">
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="组合名称(保存时必填)" hint="便于在组合列表里识别">
|
||||||
|
<input
|
||||||
|
className={`input${showErr("name") ? " input--invalid" : ""}`}
|
||||||
|
value={draft.name}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="例:高股息双策略 · 月频"
|
||||||
|
disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="组合说明(可选)"
|
||||||
|
hint={`记下这个组合的用意(${draft.description.length}/300)`}
|
||||||
|
className="field--wide"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={draft.description}
|
||||||
|
maxLength={300}
|
||||||
|
placeholder="例:用两个高股息选股策略取并集,月频调仓,验证 Tmax=30 天强制了结的影响"
|
||||||
|
disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="起始资金(元)" hint="回测初始本金,默认 100 万">
|
||||||
|
<input
|
||||||
|
className={`input mono${showErr("capital") ? " input--invalid" : ""}`}
|
||||||
|
type="number" inputMode="numeric" step={100000} min={10000}
|
||||||
|
value={draft.initialCapital} disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, initialCapital: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="持仓数 N" hint="目标等权持有的股票只数">
|
||||||
|
<input
|
||||||
|
className={`input mono${showErr("holdCount") ? " input--invalid" : ""}`}
|
||||||
|
type="number" inputMode="numeric" min={1} max={1000}
|
||||||
|
value={draft.holdCount} disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, holdCount: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="调仓时机" hint="多久重新打分并调仓一次">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={draft.rebalanceFreq} disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, rebalanceFreq: e.target.value as Draft["rebalanceFreq"] })}
|
||||||
|
>
|
||||||
|
<option value="daily">每日</option>
|
||||||
|
<option value="weekly">每周</option>
|
||||||
|
<option value="monthly">每月</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="最短持仓 Tmin(天)" hint="掉出 TopN 时未满此天数暂不卖(防频繁换手);0=不保护">
|
||||||
|
<input
|
||||||
|
className={`input mono${showErr("holdMinDays") ? " input--invalid" : ""}`}
|
||||||
|
type="number" inputMode="numeric" min={0}
|
||||||
|
value={draft.holdMinDays} disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, holdMinDays: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="最长持仓 Tmax(天)" hint="超过即强制了结(每个交易日检查);留空=不限">
|
||||||
|
<div className="row" style={{ gap: 8, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
className={`input mono${showErr("holdMaxDays") ? " input--invalid" : ""}`}
|
||||||
|
type="number" inputMode="numeric" min={1}
|
||||||
|
value={draft.holdMaxDays ?? ""} disabled={running}
|
||||||
|
placeholder="不限"
|
||||||
|
onChange={(e) => setDraft({ ...draft, holdMaxDays: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<label className="check" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.holdMaxDays === null}
|
||||||
|
disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, holdMaxDays: e.target.checked ? null : 30 })}
|
||||||
|
/>
|
||||||
|
不限
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="开始日期" className={showErr("period") ? "field--invalid" : undefined}>
|
||||||
|
<input type="date" className="input" value={draft.start} disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, start: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="结束日期" className={showErr("period") ? "field--invalid" : undefined}>
|
||||||
|
<input type="date" className="input" value={draft.end} disabled={running}
|
||||||
|
onChange={(e) => setDraft({ ...draft, end: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{showErr("period") && <div className="field-err" role="alert">{showErr("period")}</div>}
|
||||||
|
|
||||||
|
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}>
|
||||||
|
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
|
||||||
|
{running ? "后台运行中…" : "运行回测组合"}
|
||||||
|
</Btn>
|
||||||
|
<span className="hint">
|
||||||
|
N={draft.holdCount} · [{draft.holdMinDays}, {draft.holdMaxDays ?? "∞"}] 天 ·{" "}
|
||||||
|
{draft.rebalanceFreq === "daily" ? "日频" : draft.rebalanceFreq === "weekly" ? "周频" : "月频"} ·{" "}
|
||||||
|
{draft.start} ~ {draft.end} · {draft.strategyIds.length} 个策略
|
||||||
|
</span>
|
||||||
|
{blocking.length ? (
|
||||||
|
<Pill tone="warn" icon="alert">有 {blocking.length} 处需要修正</Pill>
|
||||||
|
) : (
|
||||||
|
<Pill tone="pos" icon="check">参数校验通过</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
<div className="stages" style={{ marginTop: 12 }}>
|
<div className="stages" style={{ marginTop: 12 }}>
|
||||||
{["data_loading", "selection", "backtesting", "analysis"].map((st) => {
|
{["data_loading", "backtesting", "analysis"].map((st) => {
|
||||||
const order = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"];
|
const order = ["queued", "data_loading", "backtesting", "analysis", "done"];
|
||||||
const cur = order.indexOf(stage);
|
const cur = order.indexOf(stage);
|
||||||
const mine = order.indexOf(st);
|
const mine = order.indexOf(st);
|
||||||
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
|
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
|
||||||
@@ -466,31 +635,21 @@ function BacktestInner() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<span className="hint">
|
<span className="hint">
|
||||||
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用{" "}
|
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用 {Math.round(elapsed / 1000)}s
|
||||||
{Math.round(elapsed / 1000)}s(全市场 8 年区间约 3~5 分钟,可离开本页,结果会归档)
|
(全市场多年区间约 3~5 分钟,可离开本页,结果会归档)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<StrategyDocCard
|
|
||||||
doc={doc.doc}
|
|
||||||
loading={doc.loading}
|
|
||||||
error={doc.error}
|
|
||||||
title="策略说明与计算公式(随参数实时更新)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!result && !running && !error ? (
|
{!result && !running && !error ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Empty
|
<Empty icon="gauge" title="尚未运行回测"
|
||||||
icon="gauge"
|
hint="先勾选至少一个选股策略、填好回测参数,再点「运行回测组合」。可先「保存为回测组合」以便复用。" />
|
||||||
title="尚未运行回测"
|
|
||||||
hint="可点「载入高股息案例默认参数」一键填充;调好后点「保存为策略」即可在策略库复用、对比。"
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{result ? <BacktestResultView result={result} name={params?.name ?? ""} /> : null}
|
{result ? <BacktestResultView result={result} name={draft.name || "回测组合"} /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { archivedFieldSummary } from "@/lib/archive";
|
|||||||
import { PageHeader, Card, Pill, Empty, Banner } from "@/components/ui";
|
import { PageHeader, Card, Pill, Empty, Banner } from "@/components/ui";
|
||||||
import { adjustLabel, experimentKindLabel, rebalanceLabel } from "@/lib/labels";
|
import { adjustLabel, experimentKindLabel, rebalanceLabel } from "@/lib/labels";
|
||||||
import { RichText } from "@/components/RichText";
|
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"; // 归档可能被删除/新增,禁止静态化缓存
|
export const dynamic = "force-dynamic"; // 归档可能被删除/新增,禁止静态化缓存
|
||||||
|
|
||||||
@@ -74,14 +74,26 @@ export default async function ExperimentArchivePage({
|
|||||||
// 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实)
|
// 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实)
|
||||||
if (!detail) notFound();
|
if (!detail) notFound();
|
||||||
|
|
||||||
const spec = (detail.spec ?? null) as Partial<ResearchSpec> | null;
|
const rawSpec = (detail.spec ?? null) as Record<string, unknown> | 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 result = detail.result;
|
||||||
|
const spec = isComboArchive ? null : (rawSpec as Partial<ResearchSpec> | null);
|
||||||
|
const comboSpec = isComboArchive ? (rawSpec as unknown as BacktestCombo) : null;
|
||||||
|
const comboCosts = isComboArchive
|
||||||
|
? ((result as BacktestResult | null)?.config_snapshot as Record<string, unknown> | undefined)
|
||||||
|
: undefined;
|
||||||
// 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的)
|
// 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的)
|
||||||
const fieldSummary = archivedFieldSummary(detail.kind, result);
|
const fieldSummary = archivedFieldSummary(detail.kind, result);
|
||||||
// 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误)
|
// 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误)
|
||||||
// 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径,
|
// 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径,
|
||||||
// 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。
|
// 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。
|
||||||
const doc = spec && detail.kind === "backtest"
|
const doc = spec && detail.kind === "backtest" && !isComboArchive
|
||||||
? await serverPost<StrategyDoc & { doc?: StrategyDoc }>("/strategies/describe", spec)
|
? await serverPost<StrategyDoc & { doc?: StrategyDoc }>("/strategies/describe", spec)
|
||||||
: null;
|
: null;
|
||||||
const docBody = doc ? ((doc as { doc?: StrategyDoc }).doc ?? doc) : null;
|
const docBody = doc ? ((doc as { doc?: StrategyDoc }).doc ?? doc) : null;
|
||||||
@@ -95,9 +107,9 @@ export default async function ExperimentArchivePage({
|
|||||||
|
|
||||||
<ArchiveActions id={detail.id} spec={detail.spec} />
|
<ArchiveActions id={detail.id} spec={detail.spec} />
|
||||||
|
|
||||||
<ArchivedSpecCards detail={detail} spec={spec} factors={factors ?? []} />
|
<ArchivedSpecCards detail={detail} spec={spec} factors={factors ?? []} combo={comboSpec} comboCosts={comboCosts} />
|
||||||
|
|
||||||
{detail.kind === "backtest" ? (
|
{detail.kind === "backtest" && !isComboArchive ? (
|
||||||
<StrategyDocCard
|
<StrategyDocCard
|
||||||
doc={docBody}
|
doc={docBody}
|
||||||
loading={false}
|
loading={false}
|
||||||
@@ -237,6 +249,66 @@ function condText(c: ResearchCondition): string {
|
|||||||
* (含 `selection` / `costs` / `initial_capital`)—— 把这些字段按回测口径展示,
|
* (含 `selection` / `costs` / `initial_capital`)—— 把这些字段按回测口径展示,
|
||||||
* 会让人以为这次跑过调仓与撮合。所以这里只列生效项,并显式说明哪些字段未生效。
|
* 会让人以为这次跑过调仓与撮合。所以这里只列生效项,并显式说明哪些字段未生效。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 组合回测归档的 spec 卡:如实展示「引用了哪些选股策略 + 回测参数 + 成本/复权快照」。
|
||||||
|
*
|
||||||
|
* 关键点(诚实性,AGENT §7/§24):组合归档的 spec_json 是 BacktestCombo,不含
|
||||||
|
* factors/selection/costs;成本与复权在运行时从公共配置快照进了 result.config_snapshot,
|
||||||
|
* 这里从 `costs`(即 config_snapshot)读取并明确标注「来自运行时的公共配置快照」。
|
||||||
|
*/
|
||||||
|
function ComboArchiveCards({
|
||||||
|
combo,
|
||||||
|
costs,
|
||||||
|
}: {
|
||||||
|
combo: BacktestCombo;
|
||||||
|
costs?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const costObj = (costs?.costs ?? {}) as Record<string, number>;
|
||||||
|
const adj = String(costs?.price_adjustment ?? "未记录");
|
||||||
|
const freqLabel =
|
||||||
|
combo.rebalance_freq === "daily" ? "每日" : combo.rebalance_freq === "weekly" ? "每周" : "每月";
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
icon="layers"
|
||||||
|
title="回测组合(本次实际使用的规则)"
|
||||||
|
tools={<Pill tone="violet">来自归档 spec + 运行时配置快照</Pill>}
|
||||||
|
>
|
||||||
|
<div className="spec-grid">
|
||||||
|
<SpecBlock title="引用的选股策略">
|
||||||
|
{(combo.strategy_ids ?? []).length === 0 ? (
|
||||||
|
<SpecRow k="策略" v="—" />
|
||||||
|
) : (
|
||||||
|
(combo.strategy_ids ?? []).map((id) => (
|
||||||
|
<SpecRow key={id} k={id} v="选股条件组合(详见策略库)" mono />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<SpecRow k="打分方式" v="多策略取并集后 Borda 秩和统一排序" />
|
||||||
|
</SpecBlock>
|
||||||
|
<SpecBlock title="回测参数">
|
||||||
|
<SpecRow k="持仓数 N" v={String(combo.hold_count ?? "—")} />
|
||||||
|
<SpecRow k="持仓天数区间" v={`[${combo.hold_min_days ?? 0}, ${combo.hold_max_days ?? "∞"}] 天`} />
|
||||||
|
<SpecRow k="调仓时机" v={freqLabel} />
|
||||||
|
<SpecRow k="起始资金" v={`${Number(combo.initial_capital ?? 0).toLocaleString()} 元`} />
|
||||||
|
<SpecRow k="回测区间" v={combo.period ? `${combo.period[0]} ~ ${combo.period[1]}` : "—"} mono />
|
||||||
|
</SpecBlock>
|
||||||
|
<SpecBlock title="交易执行依据(来自运行时的公共配置快照)">
|
||||||
|
<SpecRow k="佣金" v={costObj.commission_rate != null ? `${(costObj.commission_rate * 100).toFixed(3)}%(买卖各一次)` : "未记录"} />
|
||||||
|
<SpecRow k="印花税" v={costObj.stamp_tax_rate != null ? `${(costObj.stamp_tax_rate * 100).toFixed(2)}%(仅卖出)` : "未记录"} />
|
||||||
|
<SpecRow k="滑点" v={costObj.slippage_rate != null ? `${(costObj.slippage_rate * 100).toFixed(2)}%` : "未记录"} />
|
||||||
|
<SpecRow k="最低佣金" v={costObj.min_commission != null ? `${costObj.min_commission} 元/笔` : "未启用"} />
|
||||||
|
<SpecRow k="复权口径" v={adj === "hfq" ? "后复权 hfq" : adj === "qfq" ? "前复权 qfq" : adj === "none" ? "不复权" : adj} />
|
||||||
|
<SpecRow k="成交时点" v="调仓日收盘(调仓在当日收盘生效,自次日起计收益)" />
|
||||||
|
<SpecRow k="Tmax 强制了结" v="每个交易日检查;超过最长持仓天数即卖出" />
|
||||||
|
<SpecRow k="Tmin 保护" v="掉出 TopN 但未满最短持仓天数时暂不卖(防频繁换手)" />
|
||||||
|
</SpecBlock>
|
||||||
|
</div>
|
||||||
|
<div className="hint" style={{ marginTop: 10 }}>
|
||||||
|
<RichText text="这是一次**回测组合**归档:它引用了上面的选股策略(「怎么选」),并用这里的回测参数(资金/持仓/持仓天数区间/调仓时机)执行。成本与复权是**运行那一刻从公共配置快照**下来的,所以即使之后改了公共配置,本归档的数字也不会变 —— 这正是可复现的依据。" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FactorTestSpecCards({
|
function FactorTestSpecCards({
|
||||||
spec,
|
spec,
|
||||||
factors,
|
factors,
|
||||||
@@ -377,11 +449,21 @@ function ArchivedSpecCards({
|
|||||||
detail,
|
detail,
|
||||||
spec,
|
spec,
|
||||||
factors,
|
factors,
|
||||||
|
combo,
|
||||||
|
comboCosts,
|
||||||
}: {
|
}: {
|
||||||
detail: ExperimentDetail;
|
detail: ExperimentDetail;
|
||||||
spec: Partial<ResearchSpec> | null;
|
spec: Partial<ResearchSpec> | null;
|
||||||
factors: FactorMeta[];
|
factors: FactorMeta[];
|
||||||
|
combo?: BacktestCombo | null;
|
||||||
|
comboCosts?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
|
// 组合回测归档:spec 是 BacktestCombo,用专属卡片如实展示(引用的策略 + 回测参数 +
|
||||||
|
// 从 config_snapshot 取的成本/复权快照)。绝不能套单策略回测的「选股条件/交易执行依据」。
|
||||||
|
if (combo) {
|
||||||
|
return <ComboArchiveCards combo={combo} costs={comboCosts} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
return (
|
return (
|
||||||
<Card icon="book" title="选股条件与交易执行依据">
|
<Card icon="book" title="选股条件与交易执行依据">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import { submitJob, waitJob } from "@/lib/jobs";
|
import { submitJob, waitJob } from "@/lib/jobs";
|
||||||
|
import { factorLabel, pickableFactors } from "@/lib/factors";
|
||||||
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
|
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
|
||||||
import { recentRange } from "@/lib/dates";
|
import { recentRange } from "@/lib/dates";
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +46,8 @@ export default function ComposePage() {
|
|||||||
apiGet<FactorMeta[]>("/factors")
|
apiGet<FactorMeta[]>("/factors")
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setFactors(list);
|
// 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中)
|
||||||
|
setFactors(pickableFactors(list));
|
||||||
setPicks([
|
setPicks([
|
||||||
{ name: "momentum_60", weight: 1.5 },
|
{ name: "momentum_60", weight: 1.5 },
|
||||||
{ name: "volatility_60", weight: 1.0 },
|
{ name: "volatility_60", weight: 1.0 },
|
||||||
@@ -159,15 +161,23 @@ export default function ComposePage() {
|
|||||||
return (
|
return (
|
||||||
<tr key={f.name} className={pick ? "row-active" : undefined}>
|
<tr key={f.name} className={pick ? "row-active" : undefined}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
{/* 勾选框要用 .check--cell 包一层:裸 16px 的 checkbox 点击目标太小
|
||||||
type="checkbox"
|
(本项目 UI 自检要求 ≥28px,且与同排输入框等高) */}
|
||||||
checked={!!pick}
|
<label className="check check--cell">
|
||||||
onChange={() => togglePick(f.name)}
|
<input
|
||||||
aria-label={`选择因子 ${f.name}`}
|
type="checkbox"
|
||||||
/>
|
checked={!!pick}
|
||||||
|
onChange={() => togglePick(f.name)}
|
||||||
|
aria-label={`选择因子 ${f.name}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td className="cell-strong">
|
<td className="cell-strong">
|
||||||
<span className="mono">{f.name}</span>
|
{factorLabel(f)}
|
||||||
|
{/* 名字即身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */}
|
||||||
|
<span className="hint mono cell-hint" style={{ fontWeight: 400, overflowWrap: "anywhere" }}>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
|
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||||
import { submitJob, waitJob } from "@/lib/jobs";
|
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 { recentRange } from "@/lib/dates";
|
||||||
import {
|
import {
|
||||||
PageHeader,
|
PageHeader,
|
||||||
@@ -18,8 +24,156 @@ import {
|
|||||||
SkeletonLines,
|
SkeletonLines,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 因子研究页。
|
||||||
|
*
|
||||||
|
* 参数化(2026-10 后端能力)之后,这个页面的诚实性要求变了:参数(窗口 / 方向)**可以改**,
|
||||||
|
* 但改的方式是「从模板新建一个参数化因子」—— 参数写进因子名,名字即身份。所以本页要
|
||||||
|
* ① 说清「改参数 = 新身份,旧因子与既有策略不变义」;② 把每行的真实参数摆出来;
|
||||||
|
* ③ 给一个受控的新建表单(范围来自 param_specs,越界由后端 422 拒绝,界面不静默纠正)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 方向枚举的中文说法:界面上不暴露 higher_is_better 这种原始值。 */
|
||||||
|
const DIRECTION_TEXT: Record<string, string> = {
|
||||||
|
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<string, number | string> {
|
||||||
|
const out: Record<string, number | string> = {};
|
||||||
|
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, number | string>): 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, number | string>): 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, number | string>): 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() {
|
export default function FactorsPage() {
|
||||||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<FactorTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
@@ -31,6 +185,26 @@ export default function FactorsPage() {
|
|||||||
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
|
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
|
||||||
const [jobId, setJobId] = useState("");
|
const [jobId, setJobId] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
/** 目录级成功提示(停用 / 启用)。 */
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
// 新建参数化因子表单
|
||||||
|
const [tplName, setTplName] = useState("");
|
||||||
|
const [paramValues, setParamValues] = useState<Record<string, number | string>>({});
|
||||||
|
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<FactorMeta[]>("/factors");
|
||||||
|
setFactors(list);
|
||||||
|
return list;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -43,8 +217,22 @@ export default function FactorsPage() {
|
|||||||
);
|
);
|
||||||
setChecked(new Set(picks));
|
setChecked(new Set(picks));
|
||||||
})
|
})
|
||||||
.catch((e: Error) => setError(e.message))
|
.catch((e: Error) => alive && setError(e.message))
|
||||||
.finally(() => alive && setLoading(false));
|
.finally(() => alive && setLoading(false));
|
||||||
|
// 模板清单单独取:它挂了只影响「新建参数化因子」卡片,不该把整个目录一起变成错误页
|
||||||
|
apiGet<FactorTemplate[]>("/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();
|
const { start: s, end: e } = recentRange();
|
||||||
setStart(s);
|
setStart(s);
|
||||||
setEnd(e);
|
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<FactorMeta>("/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<FactorMeta>("/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() {
|
async function run() {
|
||||||
const names = factors.map((f) => f.name).filter((n) => checked.has(n));
|
const names = factors.map((f) => f.name).filter((n) => checked.has(n));
|
||||||
if (names.length === 0) {
|
if (names.length === 0) {
|
||||||
@@ -119,6 +361,9 @@ export default function FactorsPage() {
|
|||||||
const reportNames = Object.keys(reports);
|
const reportNames = Object.keys(reports);
|
||||||
const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -132,6 +377,7 @@ export default function FactorsPage() {
|
|||||||
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
|
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
|
||||||
</Banner>
|
</Banner>
|
||||||
) : null}
|
) : null}
|
||||||
|
{notice && !running ? <Banner tone="info">{notice}</Banner> : null}
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="因子目录"
|
title="因子目录"
|
||||||
@@ -140,6 +386,18 @@ export default function FactorsPage() {
|
|||||||
tools={<Pill tone="violet">已选 {selectedCount} 个</Pill>}
|
tools={<Pill tone="violet">已选 {selectedCount} 个</Pill>}
|
||||||
flush
|
flush
|
||||||
>
|
>
|
||||||
|
{/* 目录的性质说明:放在 loading 分支之外,加载中也能看到(也是 SSR 可断言的静态文案)。
|
||||||
|
参数化之后这里必须说清新分工:口径文案仍以代码为准,参数则靠「新建参数化因子」来改。
|
||||||
|
overflowWrap:示例键里没有空格,窄屏必须能断行,否则整页会横向溢出。 */}
|
||||||
|
<div className="hint" style={{ padding: "10px 14px 0", overflowWrap: "anywhere" }}>
|
||||||
|
目录是代码注册表(<span className="mono">quant/factors.py</span>)的<b>投影</b>:
|
||||||
|
口径文案(描述 / 公式 / 方向 / 回看)由引擎决定并自动同步(手改会在下次读取时被纠正回代码文本)。
|
||||||
|
<b>参数(窗口、方向)可以改</b>,改的方式是「从模板新建一个参数化因子」——
|
||||||
|
参数写进因子的名字里(如 <span className="mono">momentum(window=90,direction=higher_is_better)</span>),
|
||||||
|
所以新因子是一个<b>新身份</b>:旧因子、既有策略与归档都按各自名字里的参数计算,
|
||||||
|
<b>不会变义</b>。参数只在模板给定的受控范围内可选 / 可填,<b>越界会被后端拒绝</b>(不会静默截断成边界值)。
|
||||||
|
依赖列(<span className="mono">requires</span>)仍不可改 —— 它是「引擎能不能算」的事实,不是配置。
|
||||||
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ padding: 18 }}>
|
<div style={{ padding: 18 }}>
|
||||||
<SkeletonLines n={7} />
|
<SkeletonLines n={7} />
|
||||||
@@ -158,9 +416,11 @@ export default function FactorsPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 44 }}></th>
|
<th style={{ width: 44 }}></th>
|
||||||
<th>因子</th>
|
<th>因子</th>
|
||||||
|
<th>参数</th>
|
||||||
<th>回看</th>
|
<th>回看</th>
|
||||||
<th>方向</th>
|
<th>方向</th>
|
||||||
<th>简介(用法 / 何时有效)</th>
|
<th>简介(用法 / 何时有效)</th>
|
||||||
|
<th style={{ width: 96 }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -170,8 +430,10 @@ export default function FactorsPage() {
|
|||||||
factor={f}
|
factor={f}
|
||||||
checked={checked.has(f.name)}
|
checked={checked.has(f.name)}
|
||||||
expanded={expanded.has(f.name)}
|
expanded={expanded.has(f.name)}
|
||||||
|
toggling={toggling === f.name}
|
||||||
onToggleCheck={() => toggleCheck(f.name)}
|
onToggleCheck={() => toggleCheck(f.name)}
|
||||||
onToggleExpand={() => toggleExpand(f.name)}
|
onToggleExpand={() => toggleExpand(f.name)}
|
||||||
|
onToggleEnabled={() => void toggleEnabled(f)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -184,6 +446,139 @@ export default function FactorsPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
icon="plus"
|
||||||
|
title="新建参数化因子"
|
||||||
|
sub="参数写进名字里 —— 新因子是一个新身份,旧因子与既有策略不变义"
|
||||||
|
tools={creating ? <Pill tone="accent" icon="spinner">创建中</Pill> : undefined}
|
||||||
|
>
|
||||||
|
{tplLoading ? (
|
||||||
|
<SkeletonLines n={3} />
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="stack" style={{ gap: 10 }}>
|
||||||
|
<div className="hint">
|
||||||
|
模板清单没加载出来(<span className="mono">GET /api/factors/templates</span> 未返回模板),
|
||||||
|
所以暂时无法新建参数化因子;目录本身不受影响。
|
||||||
|
</div>
|
||||||
|
{createErr ? <Banner tone="error">{createErr}</Banner> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="stack" style={{ gap: 12 }}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field
|
||||||
|
label="模板"
|
||||||
|
className="field--wide"
|
||||||
|
htmlFor="factor-new-template"
|
||||||
|
hint="只能从代码注册的模板派生:引擎算不出来的参数组合在这里根本不会出现"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="factor-new-template"
|
||||||
|
className="input"
|
||||||
|
value={tplName}
|
||||||
|
onChange={(e) => pickTemplate(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">选择模板…</option>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.name} value={t.name}>
|
||||||
|
{t.label}({t.name}
|
||||||
|
{t.instances.length > 0
|
||||||
|
? `,已有 ${t.instances.join(" / ")}`
|
||||||
|
: ",还没有实例"}
|
||||||
|
)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* 受控表单:参数从模板 param_specs 来,顺序也照它(方向恒在最后) */}
|
||||||
|
{tplSpecs.map((s) =>
|
||||||
|
s.kind === "int" ? (
|
||||||
|
<Field
|
||||||
|
key={s.name}
|
||||||
|
label={`${s.label}(${s.name})`}
|
||||||
|
htmlFor={`factor-new-${s.name}`}
|
||||||
|
hint={`${paramRangeText(s)};越界会被后端拒绝,不会截断成边界值`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={`factor-new-${s.name}`}
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={s.minimum ?? undefined}
|
||||||
|
max={s.maximum ?? undefined}
|
||||||
|
step={1}
|
||||||
|
value={String(paramValues[s.name] ?? "")}
|
||||||
|
onChange={(e) => setParam(s.name, e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
key={s.name}
|
||||||
|
label={`${s.label}(${s.name})`}
|
||||||
|
htmlFor={`factor-new-${s.name}`}
|
||||||
|
hint={paramRangeText(s)}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id={`factor-new-${s.name}`}
|
||||||
|
className="input"
|
||||||
|
value={String(paramValues[s.name] ?? s.default)}
|
||||||
|
onChange={(e) => setParam(s.name, e.target.value)}
|
||||||
|
>
|
||||||
|
{(s.choices ?? []).map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{DIRECTION_TEXT[c] ?? c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="plus"
|
||||||
|
loading={creating}
|
||||||
|
disabled={creating || !tpl}
|
||||||
|
onClick={createFactor}
|
||||||
|
>
|
||||||
|
新建参数化因子
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tpl ? (
|
||||||
|
/* overflowWrap:预览键是长且无空格的引擎键,窄屏要能断行 */
|
||||||
|
<div className="stack" style={{ gap: 4, overflowWrap: "anywhere" }}>
|
||||||
|
<div className="field__label">创建预览</div>
|
||||||
|
<div className="hint">
|
||||||
|
因子键:<b className="mono">{previewKey(tpl, paramValues)}</b>
|
||||||
|
{" "}(参数顺序与模板一致,方向恒在最后;这个键就是身份)
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
中文名:{previewLabel(tpl, paramValues)}
|
||||||
|
{" "}· 公式 <span className="mono">{renderFormula(tpl, paramValues)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
中文名按引擎的通用规则预览(量比这类有定制命名的模板,创建后以引擎返回的名字为准)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{created ? (
|
||||||
|
<Banner tone="info">
|
||||||
|
已创建因子 <b className="mono">{created.name}</b>
|
||||||
|
{created.label ? <>({created.label})</> : null}:参数已经写进名字里,
|
||||||
|
这是一个新身份 —— <b>旧因子与既有策略不变义</b>。
|
||||||
|
</Banner>
|
||||||
|
) : null}
|
||||||
|
{createErr ? <Banner tone="error">{createErr}</Banner> : null}
|
||||||
|
|
||||||
|
<div className="hint">
|
||||||
|
参数相同不会重复创建:同一模板、同一组参数只对应一个因子键,重复提交会收到后端的重名提示。
|
||||||
|
要换参数就再建一个(新键),不要指望改旧键 —— 旧键被改了,引用它的策略与归档就会变义。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="运行单因子测试" icon="play" tools={running ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
|
<Card title="运行单因子测试" icon="play" tools={running ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<Field label="开始日期">
|
<Field label="开始日期">
|
||||||
@@ -238,8 +633,16 @@ export default function FactorsPage() {
|
|||||||
<Card
|
<Card
|
||||||
key={name}
|
key={name}
|
||||||
icon="chartLine"
|
icon="chartLine"
|
||||||
title={name}
|
title={meta ? factorLabel(meta) : name}
|
||||||
sub={meta?.brief}
|
sub={
|
||||||
|
meta && meta.label && meta.label !== name ? (
|
||||||
|
<span style={{ overflowWrap: "anywhere" }}>
|
||||||
|
<span className="mono">{name}</span> · {meta.brief}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
meta?.brief
|
||||||
|
)
|
||||||
|
}
|
||||||
tools={
|
tools={
|
||||||
<Pill tone="pos" icon="check">
|
<Pill tone="pos" icon="check">
|
||||||
已完成
|
已完成
|
||||||
@@ -258,13 +661,23 @@ function FactorRow(props: {
|
|||||||
factor: FactorMeta;
|
factor: FactorMeta;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
|
toggling: boolean;
|
||||||
onToggleCheck: () => void;
|
onToggleCheck: () => void;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
|
onToggleEnabled: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { factor: f } = props;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}>
|
<tr
|
||||||
|
style={{ cursor: "pointer", opacity: f.enabled === false ? 0.55 : 1 }}
|
||||||
|
onClick={props.onToggleExpand}
|
||||||
|
>
|
||||||
<td onClick={(e) => e.stopPropagation()}>
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
<label className="check check--cell" title={`选择因子 ${f.name}`}>
|
<label className="check check--cell" title={`选择因子 ${f.name}`}>
|
||||||
<input
|
<input
|
||||||
@@ -276,29 +689,128 @@ function FactorRow(props: {
|
|||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td className="cell-strong">
|
<td className="cell-strong">
|
||||||
<span className="mono">{f.name}</span>
|
{label}
|
||||||
|
{/* 名字就是身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */}
|
||||||
|
{f.label && f.label !== f.name ? (
|
||||||
|
<span
|
||||||
|
className="hint mono cell-hint"
|
||||||
|
style={{ fontWeight: 400, overflowWrap: "anywhere" }}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{f.enabled === false ? <> <Pill tone="warn">已停用</Pill></> : null}
|
||||||
|
{f.resolvable === false ? <> <Pill tone="neg">引擎算不出来</Pill></> : null}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="hint">{paramSummary(f)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="tag tag--mono">{f.lookback} 日</span>
|
<span className="tag tag--mono">{f.lookback} 日</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
|
{/* 算不出来的历史行没有可信方向(direction 只是 DB 默认值),不摆一个假的方向 */}
|
||||||
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
|
{f.resolvable === false ? (
|
||||||
</Pill>
|
<span className="hint">—</span>
|
||||||
|
) : (
|
||||||
|
<Pill tone={high ? "accent" : "violet"}>{high ? "越高越好" : "越低越好"}</Pill>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="hint">{f.brief ?? f.description}</td>
|
<td className="hint">{f.brief ?? f.description}</td>
|
||||||
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
|
{canToggle ? (
|
||||||
|
<Btn
|
||||||
|
disabled={props.toggling}
|
||||||
|
loading={props.toggling}
|
||||||
|
onClick={props.onToggleEnabled}
|
||||||
|
title={f.enabled === false ? "启用后重新出现在选择列表里" : "停用只是不出现在选择列表里,历史解析不变"}
|
||||||
|
>
|
||||||
|
{f.enabled === false ? "启用" : "停用"}
|
||||||
|
</Btn>
|
||||||
|
) : (
|
||||||
|
<Btn
|
||||||
|
disabled
|
||||||
|
title={
|
||||||
|
f.resolvable === false
|
||||||
|
? "引擎算不出来(历史手工登记行),不能启用或停用"
|
||||||
|
: "内置实例的开关由代码决定,不能在目录里改;要不同参数请从模板新建参数化因子"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{props.expanded ? (
|
{props.expanded ? (
|
||||||
<tr className="expand-cell">
|
<tr className="expand-cell">
|
||||||
<td colSpan={5}>
|
<td colSpan={7}>
|
||||||
<div>
|
<div className="stack" style={{ gap: 8 }}>
|
||||||
<b className="mono">{f.name}</b> · {f.description}
|
{/* 展开行里也会出现长参数键,窄屏同样要能断行 */}
|
||||||
<div className="hint" style={{ marginTop: 6 }}>
|
<div style={{ overflowWrap: "anywhere" }}>
|
||||||
公式:<span className="mono">{f.formula}</span>;回看 {f.lookback} 个交易日;频率 {f.frequency};
|
<b className="mono">{f.name}</b> · {f.description}
|
||||||
方向:
|
|
||||||
{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
|
|
||||||
</div>
|
</div>
|
||||||
{f.brief ? <div className="hint" style={{ marginTop: 6 }}>{f.brief}</div> : null}
|
|
||||||
|
<div className="stack" style={{ gap: 3 }}>
|
||||||
|
<div className="field__label">参数(写进名字里的身份)</div>
|
||||||
|
{(f.param_specs ?? []).length > 0 ? (
|
||||||
|
(f.param_specs ?? []).map((s) => (
|
||||||
|
<div key={s.name} className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<span className="mono" style={{ minWidth: 72 }}>{s.name}</span>
|
||||||
|
<b>{paramValueText(s, (f.params ?? {})[s.name])}</b>
|
||||||
|
{/* 枚举参数的 note 往往已把可选值抄了一遍(如方向),就不再重复一次 */}
|
||||||
|
<span className="hint">
|
||||||
|
{s.kind === "int"
|
||||||
|
? `允许 ${paramRangeText(s)}${s.note ? ` · ${s.note}` : ""}`
|
||||||
|
: `可选 ${paramRangeText(s)}${
|
||||||
|
s.note && !s.note.includes(paramRangeText(s)) ? ` · ${s.note}` : ""
|
||||||
|
}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="hint">
|
||||||
|
该因子没有声明可编辑参数{f.resolvable === false ? "(历史手工登记行)" : "(时点值口径,无窗口)"}。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hint">
|
||||||
|
公式:<span className="mono">{f.formula}</span>;频率 {f.frequency};回看 {f.lookback} 个交易日;
|
||||||
|
方向:
|
||||||
|
{f.resolvable === false
|
||||||
|
? "未知(引擎算不出来)"
|
||||||
|
: high
|
||||||
|
? "因子值越高得分越高"
|
||||||
|
: "因子值越低得分越高(引擎自动反向)"}
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
依赖列:
|
||||||
|
{f.requires && f.requires.length > 0 ? (
|
||||||
|
<span className="mono">{f.requires.join(" / ")}</span>
|
||||||
|
) : (
|
||||||
|
"无"
|
||||||
|
)}
|
||||||
|
(引擎能不能算的事实,不可改)
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<Pill>{sourceText(f)}</Pill>
|
||||||
|
{f.template ? (
|
||||||
|
<span className="hint">
|
||||||
|
模板 <span className="mono">{f.template}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Pill tone={f.enabled === false ? "warn" : "pos"}>
|
||||||
|
{f.enabled === false ? "已停用" : "启用中"}
|
||||||
|
</Pill>
|
||||||
|
{f.enabled === false ? (
|
||||||
|
<span className="hint">既有策略 / 归档仍按它的名字解析,停用只是不出现在选择列表里。</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{f.resolvable === false ? (
|
||||||
|
<Banner tone="warn">
|
||||||
|
引擎算不出来(历史手工登记行),引用时会报错:这不是「配置不一致」,
|
||||||
|
而是代码注册表里没有能解析它的模板 / 参数。
|
||||||
|
</Banner>
|
||||||
|
) : null}
|
||||||
|
{f.brief ? <div className="hint">{f.brief}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -365,4 +877,4 @@ function ReportView({ report }: { report: FactorTestReport }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段库(/fields)—— 过滤条件可用字段的目录与维护页。
|
||||||
|
*
|
||||||
|
* 解决三个问题(用户 2026-10 反馈):
|
||||||
|
* 1. 过滤条件的字段此前要**手填**(dv_ratio / static.industry …),看不到含义、写错不报错;
|
||||||
|
* 现在字段有中文名 + 口径说明 + 单位 + 类型,条件编辑器里是分组下拉。
|
||||||
|
* 2. 字段库可增减、可编辑:改中文名/含义/单位/分组,停用不用的,新增少用但引擎支持的。
|
||||||
|
* 3. 说清「打分因子 vs 过滤条件」的关系 —— 见下方说明卡。
|
||||||
|
*
|
||||||
|
* 诚实性约束:能加什么由**引擎**决定(后端校验)。加一个引擎算不出来的字段,条件会
|
||||||
|
* 永远不通过 —— 所以这里只允许从「引擎支持但尚未进库」的清单里挑,不允许手填乱造。
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
||||||
|
import type { ConditionField, ConditionFieldOption } from "@/lib/types";
|
||||||
|
import { factorOf } from "@/lib/units";
|
||||||
|
import { Banner, Btn, Card, Empty, Field, Loading, PageHeader, Pill } from "@/components/ui";
|
||||||
|
|
||||||
|
/** 编辑中的行(null=未编辑)。 */
|
||||||
|
type Draft = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
unit: string;
|
||||||
|
group_name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<ConditionField["kind"], string> = { num: "数值", str: "文本" };
|
||||||
|
|
||||||
|
/** 单位选择器:**只能从注册表给的阶梯里选**(万元 ⇄ 亿元 这类固定换算),不给自由文本。
|
||||||
|
*
|
||||||
|
* 单位分两层:基准单位(引擎存储/比较用,改不了)与界面单位(这里选的,只影响输入/显示)。
|
||||||
|
* 选成亿元后,策略表单里按亿元输入、提交时 ×10000 存成万元 —— 引擎比较口径不变,
|
||||||
|
* 所以改单位不会让任何历史策略变义。只有一个单位的字段直接显示出来(没什么可选的)。
|
||||||
|
*/
|
||||||
|
function UnitSelect({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
field: ConditionField;
|
||||||
|
value: string;
|
||||||
|
onChange: (unit: string) => void;
|
||||||
|
}) {
|
||||||
|
const units = field.units ?? [];
|
||||||
|
const base = field.base_unit || field.unit;
|
||||||
|
if (units.length < 2) {
|
||||||
|
return (
|
||||||
|
<span className="hint" title={`引擎按基准单位「${base}」存储与比较`}>
|
||||||
|
{value || base}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="cell-stack" style={{ gap: 2 }}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ width: 88 }}
|
||||||
|
aria-label={`${field.label} 的界面单位`}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{units.map((u) => (
|
||||||
|
<option key={u.unit} value={u.unit}>
|
||||||
|
{u.unit}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="hint cell-hint">
|
||||||
|
1 {value} = {factorOf(units, value)} {base}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FieldsPage() {
|
||||||
|
const [rows, setRows] = useState<ConditionField[] | null>(null);
|
||||||
|
const [options, setOptions] = useState<ConditionFieldOption[]>([]);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// 行内编辑状态
|
||||||
|
const [editName, setEditName] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
|
||||||
|
// 新增表单
|
||||||
|
const [addName, setAddName] = useState("");
|
||||||
|
const [addLabel, setAddLabel] = useState("");
|
||||||
|
const [addDesc, setAddDesc] = useState("");
|
||||||
|
const [addUnit, setAddUnit] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [list, avail] = await Promise.all([
|
||||||
|
apiGet<ConditionField[]>("/condition-fields"),
|
||||||
|
apiGet<ConditionFieldOption[]>("/condition-fields/available"),
|
||||||
|
]);
|
||||||
|
setRows(list);
|
||||||
|
setOptions(avail);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
function startEdit(f: ConditionField) {
|
||||||
|
setErr("");
|
||||||
|
setMsg("");
|
||||||
|
setEditName(f.name);
|
||||||
|
setDraft({
|
||||||
|
label: f.label,
|
||||||
|
description: f.description,
|
||||||
|
unit: f.unit,
|
||||||
|
group_name: f.group_name,
|
||||||
|
enabled: f.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editName || !draft) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
await apiPut<ConditionField>(`/condition-fields/${encodeURIComponent(editName)}`, draft);
|
||||||
|
setMsg(`已保存字段「${draft.label}」`);
|
||||||
|
setEditName(null);
|
||||||
|
setDraft(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用/启用:内置字段不能删,只能停用(删了下次读取会自动补回)。 */
|
||||||
|
async function toggleEnabled(f: ConditionField) {
|
||||||
|
setBusy(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
await apiPut<ConditionField>(`/condition-fields/${encodeURIComponent(f.name)}`, {
|
||||||
|
enabled: !f.enabled,
|
||||||
|
});
|
||||||
|
setMsg(`字段「${f.label}」已${f.enabled ? "停用(条件编辑器里不再出现)" : "启用"}`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(f: ConditionField) {
|
||||||
|
if (!window.confirm(`删除自定义字段「${f.label}」(${f.name})?删掉后可重新从「新增字段」加回来。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
await apiDelete(`/condition-fields/${encodeURIComponent(f.name)}`);
|
||||||
|
setMsg(`已删除自定义字段「${f.label}」`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickOption(name: string) {
|
||||||
|
setAddName(name);
|
||||||
|
const o = options.find((x) => x.name === name);
|
||||||
|
if (o) {
|
||||||
|
setAddLabel(o.label);
|
||||||
|
setAddDesc(o.description);
|
||||||
|
setAddUnit(o.unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addField() {
|
||||||
|
if (!addName) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr("");
|
||||||
|
setMsg("");
|
||||||
|
try {
|
||||||
|
const created = await apiPost<ConditionField>("/condition-fields", {
|
||||||
|
name: addName,
|
||||||
|
label: addLabel,
|
||||||
|
description: addDesc,
|
||||||
|
unit: addUnit,
|
||||||
|
});
|
||||||
|
setMsg(`已新增字段「${created.label}」(${created.name}),现在可以在过滤条件里选它`);
|
||||||
|
setAddName("");
|
||||||
|
setAddLabel("");
|
||||||
|
setAddDesc("");
|
||||||
|
setAddUnit("");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组展示:后端已按 sort_order 排序,这里只做「保持首次出现顺序」的分组
|
||||||
|
const groups: { name: string; items: ConditionField[] }[] = [];
|
||||||
|
for (const f of rows ?? []) {
|
||||||
|
const g = groups.find((x) => x.name === f.group_name);
|
||||||
|
if (g) g.items.push(f);
|
||||||
|
else groups.push({ name: f.group_name, items: [f] });
|
||||||
|
}
|
||||||
|
const groupNames = groups.map((g) => g.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="字段库"
|
||||||
|
sub="过滤条件能用的字段目录:中文名、口径、单位都在这里定义。内置字段随引擎自动同步;中文名与含义可以改成你自己的说法,用不到的可以停用,少用的可以从「新增字段」加进来。"
|
||||||
|
actions={
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<Link href="/strategies" className="btn"><span>去编辑选股策略</span></Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{err ? <Banner tone="error">{err}</Banner> : null}
|
||||||
|
{msg ? <Banner tone="info">{msg}</Banner> : null}
|
||||||
|
|
||||||
|
{/* 因子 vs 条件的关系(用户问题第 3 条) */}
|
||||||
|
<Card icon="info" title="打分因子 与 过滤条件 是什么关系?">
|
||||||
|
<div className="stack" style={{ gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
<b>一句话:条件决定「有没有资格」,因子决定「谁排前面」。</b>
|
||||||
|
两者在选股流程里是<b>先后两步</b>,不是二选一。
|
||||||
|
</div>
|
||||||
|
<div className="chips">
|
||||||
|
<span className="chip"><b>1 股票池</b> 剔除 ST / 上市天数不足 / 非指数成分</span>
|
||||||
|
<span className="chip"><b>2 过滤条件</b> 全部 AND 通过才有资格(不排序)</span>
|
||||||
|
<span className="chip"><b>3 打分因子</b> 横截面 z-score 加权 → 复合分排序</span>
|
||||||
|
<span className="chip"><b>4 取 TopN</b> N 在回测组合里定,不在策略里</span>
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
<b>用你库里的策略举例(高股息 Top20):</b>
|
||||||
|
条件 <code className="mono">dv_ratio ≤ 30</code> 先剔掉「股息率 > 30% 的异常样本」
|
||||||
|
(多为一次性特别分红或股价暴跌,是典型的「高股息陷阱」)—— 这是<b>准入</b>;
|
||||||
|
因子 <code className="mono">dividend_yield</code> 给剩下的股票打分排序,
|
||||||
|
让股息率更高的排前面 —— 这是<b>优先级</b>。
|
||||||
|
同一个字段(比如股息率)既可以是条件也可以是因子:当条件用就是「筛掉」,当因子用就是「排序」。
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
另外两点容易混:<b>股票池</b>(剔 ST、上市天数、指数成分)也是过滤,但它属于策略的
|
||||||
|
「universe」,在条件之前执行;<b>取多少只(N)</b>不属于选股策略 ——
|
||||||
|
同一个策略配不同 N 是不同风险收益,所以它在「回测组合」里填。
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
<b>这里只列内置因子名</b>(如 <code className="mono">momentum_60</code>)。
|
||||||
|
自己新建的<b>参数化因子</b>(如 <code className="mono">momentum(window=90,direction=higher_is_better)</code>)
|
||||||
|
在<a href="/factors">因子研究</a>页管理,选股策略的条件字段下拉里会一并出现 ——
|
||||||
|
它们算的是同一个引擎字段,只是参数不同。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 新增字段 */}
|
||||||
|
<Card icon="plus" title="新增字段" sub="只能从「引擎支持但尚未进库」的字段里挑:加一个算不出来的字段,条件会永远不通过">
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="hint">没有可新增的字段了 —— 引擎支持且未进库的字段都已加入。</div>
|
||||||
|
) : (
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="字段" className="field--wide">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
aria-label="可新增字段"
|
||||||
|
value={addName}
|
||||||
|
onChange={(e) => pickOption(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">选择字段…</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.name} value={o.name}>
|
||||||
|
{o.group_name} · {o.label}({o.name})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="中文名" hint="空则用默认">
|
||||||
|
<input className="input" value={addLabel} onChange={(e) => setAddLabel(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="界面单位" hint="只能从引擎给定的单位里选">
|
||||||
|
{(() => {
|
||||||
|
const opt = options.find((o) => o.name === addName);
|
||||||
|
const units = opt?.units ?? [];
|
||||||
|
const base = opt?.unit ?? "";
|
||||||
|
if (units.length < 2) {
|
||||||
|
return <span className="hint">{base || "—"}(该字段只有基准单位)</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
aria-label="新增字段的界面单位"
|
||||||
|
value={addUnit}
|
||||||
|
onChange={(e) => setAddUnit(e.target.value)}
|
||||||
|
>
|
||||||
|
{units.map((u) => (
|
||||||
|
<option key={u.unit} value={u.unit}>
|
||||||
|
{u.unit}(1 {u.unit} = {u.factor} {base})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Field>
|
||||||
|
<Field label="含义 / 口径" className="field--wide" hint="写清口径,将来才看得懂">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={addDesc}
|
||||||
|
onChange={(e) => setAddDesc(e.target.value)}
|
||||||
|
placeholder="例:总市值 = 总股本 × 收盘价(万元)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Btn icon="plus" variant="primary" disabled={busy || !addName} onClick={addField}>
|
||||||
|
加入字段库
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card icon="filter" title={`字段目录 · ${rows?.length ?? 0} 个`} tools={
|
||||||
|
<Btn icon="refresh" size="sm" onClick={() => void load()} disabled={busy}>刷新</Btn>
|
||||||
|
}>
|
||||||
|
{rows === null ? (
|
||||||
|
<Loading label="加载字段库…" />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Empty icon="filter" title="字段库为空" hint="刷新后会从引擎注册表自动补齐内置字段。" />
|
||||||
|
) : (
|
||||||
|
<div className="stack" style={{ gap: 14 }}>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g.name}>
|
||||||
|
<div className="form-section__title">{g.name} · {g.items.length}</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 150 }}>中文名</th>
|
||||||
|
<th style={{ width: 190 }}>字段名</th>
|
||||||
|
<th style={{ width: 96 }}>类型 / 单位</th>
|
||||||
|
<th>含义 / 口径</th>
|
||||||
|
<th style={{ width: 96 }}>来源</th>
|
||||||
|
<th style={{ width: 170 }}>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{g.items.map((f) =>
|
||||||
|
editName === f.name && draft ? (
|
||||||
|
<tr key={f.name} className="row-active">
|
||||||
|
<td><input className="input" value={draft.label} onChange={(e) => setDraft({ ...draft, label: e.target.value })} /></td>
|
||||||
|
<td className="cell-mono">{f.name}</td>
|
||||||
|
<td>
|
||||||
|
<UnitSelect
|
||||||
|
field={f}
|
||||||
|
value={draft.unit}
|
||||||
|
onChange={(unit) => setDraft({ ...draft, unit })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="input" value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
|
||||||
|
</td>
|
||||||
|
<td><Pill>{f.source === "builtin" ? "内置" : "自定义"}</Pill></td>
|
||||||
|
<td>
|
||||||
|
<div className="row" style={{ gap: 6 }}>
|
||||||
|
<Btn size="sm" variant="primary" disabled={busy} onClick={saveEdit}>保存</Btn>
|
||||||
|
<Btn size="sm" disabled={busy} onClick={() => { setEditName(null); setDraft(null); }}>取消</Btn>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={f.name} style={f.enabled ? undefined : { opacity: 0.55 }}>
|
||||||
|
<td className="cell-strong">
|
||||||
|
{f.label}
|
||||||
|
{!f.enabled ? <> <Pill tone="warn">已停用</Pill></> : null}
|
||||||
|
</td>
|
||||||
|
<td className="cell-mono">{f.name}</td>
|
||||||
|
<td>
|
||||||
|
{KIND_LABEL[f.kind]}
|
||||||
|
{f.unit ? (
|
||||||
|
<span className="hint" style={{ marginLeft: 4 }}>
|
||||||
|
{f.unit}
|
||||||
|
{f.base_unit && f.unit !== f.base_unit ? (
|
||||||
|
<span title={`引擎按基准单位 ${f.base_unit} 存储与比较;界面按 ${factorOf(f.units, f.unit)} 倍换算`}>
|
||||||
|
(基准 {f.base_unit},×{factorOf(f.units, f.unit)})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>{f.description}</td>
|
||||||
|
<td><Pill>{f.source === "builtin" ? "内置" : "自定义"}</Pill></td>
|
||||||
|
<td>
|
||||||
|
<div className="row" style={{ gap: 6 }}>
|
||||||
|
<Btn size="sm" icon="edit" disabled={busy} onClick={() => startEdit(f)}>编辑</Btn>
|
||||||
|
<Btn size="sm" disabled={busy} onClick={() => toggleEnabled(f)}>
|
||||||
|
{f.enabled ? "停用" : "启用"}
|
||||||
|
</Btn>
|
||||||
|
{f.source === "custom" ? (
|
||||||
|
<Btn size="sm" variant="danger" icon="trash" disabled={busy} onClick={() => remove(f)}>
|
||||||
|
删除
|
||||||
|
</Btn>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="hint">
|
||||||
|
分组顺序与可用比较符由引擎类型决定:{groupNames.length} 个分组。文本字段只能「等于 / 不等于 / 属于 /
|
||||||
|
不属于」,数值字段才能比大小 —— 这样不会摆出「行业 > 5」这种永远为假的选项。
|
||||||
|
内置字段不能删除(删掉下次读取会自动补回),不想看到就「停用」。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -708,11 +708,13 @@ input[type="date"].input {
|
|||||||
.factor-row {
|
.factor-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* 因子名列封顶 460px:再宽就是「momentum_60」配 1500px 下拉的失调(截图实测)。
|
/* 因子名列封顶 460px:再宽就是「momentum_60」配 1500px 下拉的失调(截图实测)。
|
||||||
权重列固定,操作列按内容。整行也限宽,避免在宽屏上拉成一条长线。 */
|
权重列固定,操作列按内容。整行也限宽,避免在宽屏上拉成一条长线。
|
||||||
|
start 对齐:因子列比别列高(下拉 + 口径/方向/简介两行说明),
|
||||||
|
居中会让权重框浮在说明旁边显得没对齐。 */
|
||||||
grid-template-columns: minmax(150px, 460px) 118px auto;
|
grid-template-columns: minmax(150px, 460px) 118px auto;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
max-width: 720px;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.factor-rows__head {
|
.factor-rows__head {
|
||||||
@@ -731,17 +733,69 @@ input[type="date"].input {
|
|||||||
.cond-rows {
|
.cond-rows {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
max-width: 760px;
|
max-width: 940px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cond-rows__head,
|
.cond-rows__head,
|
||||||
.cond-row {
|
.cond-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(150px, 360px) 84px minmax(96px, 160px) auto;
|
grid-template-columns: minmax(150px, 260px) 84px 110px minmax(120px, 190px) auto;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
gap: var(--sp-2);
|
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 {
|
.cond-rows__head {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
@@ -2026,3 +2080,108 @@ button.chip:hover {
|
|||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
color: var(--text-1);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { apiGet, apiPost } from "@/lib/api";
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
import { SymbolLink } from "@/lib/symbols";
|
import { SymbolLink } from "@/lib/symbols";
|
||||||
import { waitJob } from "@/lib/jobs";
|
import { waitJob } from "@/lib/jobs";
|
||||||
|
import { factorOptionLabel, pickableFactors } from "@/lib/factors";
|
||||||
import type {
|
import type {
|
||||||
FactorMeta,
|
FactorMeta,
|
||||||
SelectionCondition,
|
SelectionCondition,
|
||||||
@@ -87,7 +88,8 @@ export default function SelectionPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
apiGet<FactorMeta[]>("/factors")
|
apiGet<FactorMeta[]>("/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));
|
.catch((e: Error) => alive && setError(e.message));
|
||||||
apiGet<SelectionMeta[]>("/selections?limit=8")
|
apiGet<SelectionMeta[]>("/selections?limit=8")
|
||||||
.then((rows) => alive && setHistory(rows))
|
.then((rows) => alive && setHistory(rows))
|
||||||
@@ -241,7 +243,7 @@ export default function SelectionPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{factors.map((f) => (
|
{factors.map((f) => (
|
||||||
<option key={f.name} value={f.name}>{f.name}</option>
|
<option key={f.name} value={f.name}>{factorOptionLabel(f)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公共配置(/settings)—— 全局唯一一份费率 / 滑点 / 最低佣金 / 复权口径 / 基准。
|
||||||
|
*
|
||||||
|
* 为什么单独一页:这些是「常规配置」,所有回测组合共用,不该在每个策略里重复填
|
||||||
|
* (用户目标第 1 条)。改这里会影响**之后**的回测;已跑过的归档不受影响
|
||||||
|
* (归档的 config_snapshot 固化了当时的成本/复权,可复现)。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiGet, apiPut } from "@/lib/api";
|
||||||
|
import type { GlobalConfig } from "@/lib/types";
|
||||||
|
import { Banner, Btn, Card, Field, Loading, PageHeader, Pill } from "@/components/ui";
|
||||||
|
|
||||||
|
const DEFAULTS: GlobalConfig = {
|
||||||
|
commission_rate: 0.0003,
|
||||||
|
stamp_tax_rate: 0.0005,
|
||||||
|
slippage_rate: 0.001,
|
||||||
|
min_commission: 5,
|
||||||
|
price_adjustment: "hfq",
|
||||||
|
benchmark: "000300.SH",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [cfg, setCfg] = useState<GlobalConfig | null>(null);
|
||||||
|
const [draft, setDraft] = useState<GlobalConfig>(DEFAULTS);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
apiGet<GlobalConfig>("/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<GlobalConfig>("/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 (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="公共配置"
|
||||||
|
sub="全局唯一的交易成本与行情口径 —— 所有回测组合共用。改动只影响之后的回测,已归档结果按各自快照复现。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{err ? <Banner tone="error">{err}</Banner> : null}
|
||||||
|
{msg ? <Banner tone="info">{msg}</Banner> : null}
|
||||||
|
|
||||||
|
{!cfg ? (
|
||||||
|
<Card><Loading label="读取公共配置…" /></Card>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
icon="database"
|
||||||
|
title="交易成本与行情口径"
|
||||||
|
tools={
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
{dirty ? <Pill tone="warn">有未保存的修改</Pill> : null}
|
||||||
|
<Btn
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
loading={busy}
|
||||||
|
disabled={busy || !dirty}
|
||||||
|
onClick={save}
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="佣金率" hint="如 0.0003 = 万三;买卖双向收取">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.00001"
|
||||||
|
min={0}
|
||||||
|
max={0.01}
|
||||||
|
value={draft.commission_rate}
|
||||||
|
onChange={(e) => setDraft({ ...draft, commission_rate: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="印花税率" hint="仅卖出收取;A 股 0.0005(2023-08 起减半)">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.00001"
|
||||||
|
min={0}
|
||||||
|
max={0.01}
|
||||||
|
value={draft.stamp_tax_rate}
|
||||||
|
onChange={(e) => setDraft({ ...draft, stamp_tax_rate: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="滑点率" hint="按成交价比例估计冲击成本;0.0005~0.001 较常见">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.0001"
|
||||||
|
min={0}
|
||||||
|
max={0.05}
|
||||||
|
value={draft.slippage_rate}
|
||||||
|
onChange={(e) => setDraft({ ...draft, slippage_rate: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="最低佣金(元/笔)" hint="单笔佣金下限,小额单影响显著;常见 5 元">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step={1}
|
||||||
|
min={0}
|
||||||
|
value={draft.min_commission}
|
||||||
|
onChange={(e) => setDraft({ ...draft, min_commission: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="复权口径" hint="高股息类建议后复权 hfq(把分红再投资计入收益)">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={draft.price_adjustment}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, price_adjustment: e.target.value as GlobalConfig["price_adjustment"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="hfq">后复权 hfq</option>
|
||||||
|
<option value="qfq">前复权 qfq</option>
|
||||||
|
<option value="none">不复权 none</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="对照基准" hint="仅用于展示对比,不参与交易">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
value={draft.benchmark}
|
||||||
|
onChange={(e) => setDraft({ ...draft, benchmark: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hint" style={{ marginTop: 12 }}>
|
||||||
|
费率以**小数**填写(0.0003 = 万三,不是 0.03%)。这里的值是全局默认;每个回测组合运行时
|
||||||
|
会把当时的成本/复权**快照**进归档,所以事后改这里不会改变历史结果的数字。
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { apiGet, apiPost } from "@/lib/api";
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
import { SymbolLink } from "@/lib/symbols";
|
import { SymbolLink } from "@/lib/symbols";
|
||||||
|
import { factorOptionLabel, pickableFactors } from "@/lib/factors";
|
||||||
import type {
|
import type {
|
||||||
FactorMeta,
|
FactorMeta,
|
||||||
SelectionQuery,
|
SelectionQuery,
|
||||||
@@ -55,7 +56,8 @@ export default function SignalsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
apiGet<FactorMeta[]>("/factors")
|
apiGet<FactorMeta[]>("/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));
|
.catch((e: Error) => alive && setError(e.message));
|
||||||
apiGet<SignalMeta[]>("/signals?limit=8").then(setHistory).catch(() => alive && setHistory([]));
|
apiGet<SignalMeta[]>("/signals?limit=8").then(setHistory).catch(() => alive && setHistory([]));
|
||||||
return () => {
|
return () => {
|
||||||
@@ -116,7 +118,7 @@ export default function SignalsPage() {
|
|||||||
<Field label="评分因子">
|
<Field label="评分因子">
|
||||||
<select className="input" value={factor} onChange={(e) => setFactor(e.target.value)}>
|
<select className="input" value={factor} onChange={(e) => setFactor(e.target.value)}>
|
||||||
{factors.map((f) => (
|
{factors.map((f) => (
|
||||||
<option key={f.name} value={f.name}>{f.name}</option>
|
<option key={f.name} value={f.name}>{factorOptionLabel(f)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 策略库(/strategies)—— 策略的命名资产中心。
|
* 选股策略库(/strategies)—— 只管理「选股条件组合」。
|
||||||
*
|
*
|
||||||
* 职责:
|
* 2026-09 重构后这里**不再有回测参数**(资金/持仓/调仓/费率/区间都移到回测组合与公共配置)。
|
||||||
* - 列表 + 检索:每个策略显示**一句话说明**与**计算公式**(后端 describe_strategy 推导)。
|
* 每个策略只回答「怎么选」:股票池 + 因子 + 过滤条件。要验证它,去 /backtest 把它
|
||||||
* - 新建 / 编辑:复用 StrategyParamsForm(与回测页同一份参数模型与校验)。
|
* (可与其他策略一起)放进一个回测组合再跑。
|
||||||
* - 一键回测:`POST /strategies/{id}/expand`(补 period + 资金)→ `POST /jobs` → 轮询 →
|
|
||||||
* 就地展示核心指标,并提供「查看实验详情 / 回到回测页看曲线」。
|
|
||||||
* - 去回测页:`/backtest?strategy={id}`(回测页载入后可保存为策略、可微调)。
|
|
||||||
*
|
|
||||||
* 设计取舍:编辑不弹窗而是就地展开表单(页面滚动位置不丢,参数多,弹窗太挤)。
|
|
||||||
*/
|
*/
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
|
||||||
import { STAGE_LABEL, submitJob, waitJob, type JobOutcome } from "@/lib/jobs";
|
import type { ConditionField, FactorMeta, ResearchCondition, SelectionStrategy } from "@/lib/types";
|
||||||
import { recentRange } from "@/lib/dates";
|
import { displayUnitOf, fromBase, unitScale } from "@/lib/units";
|
||||||
import type { BacktestResult, FactorMeta, StrategyDefinition } from "@/lib/types";
|
|
||||||
import {
|
import {
|
||||||
Btn,
|
Btn,
|
||||||
Banner,
|
Banner,
|
||||||
@@ -26,40 +20,67 @@ import {
|
|||||||
Loading,
|
Loading,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Pill,
|
Pill,
|
||||||
Progress,
|
|
||||||
BacktestMetrics,
|
|
||||||
Field,
|
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import {
|
import {
|
||||||
StrategyParamsForm,
|
SelectionStrategyForm,
|
||||||
casePreset,
|
emptySelectionParams,
|
||||||
emptyParams,
|
paramsFromSelectionStrategy,
|
||||||
paramsFromStrategy,
|
selectionStrategyFromParams,
|
||||||
strategyFromParams,
|
validateSelectionParams,
|
||||||
validateParams,
|
type SelectionStrategyParams,
|
||||||
type StrategyParams,
|
} from "@/components/SelectionStrategyForm";
|
||||||
} from "@/components/StrategyParamsForm";
|
|
||||||
import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard";
|
import { StrategyDocBody, StrategyDocCard } from "@/components/StrategyDocCard";
|
||||||
import { useStrategyDoc, useStrategyDocById } from "@/lib/strategy";
|
import { useStrategyDocById } from "@/lib/strategy";
|
||||||
|
|
||||||
export default function StrategiesPage() {
|
export default function StrategiesPage() {
|
||||||
const [list, setList] = useState<StrategyDefinition[] | null>(null);
|
const [list, setList] = useState<SelectionStrategy[] | null>(null);
|
||||||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||||||
|
const [conditionFields, setConditionFields] = useState<ConditionField[]>([]);
|
||||||
|
/**
|
||||||
|
* 条件下拉用的字段表 = 字段库 + 因子目录里「字段库还没收录」的因子。
|
||||||
|
*
|
||||||
|
* 为什么:参数化因子(`momentum(window=90,direction=…)`)是引擎真认的过滤字段
|
||||||
|
* (`momentum_60 > 0` 一直合法),但它们不在字段库的注册表投影里。这里按名字去重地
|
||||||
|
* 并进来 —— 内置因子本来就在字段库的「因子」分组里,不会重复出现。
|
||||||
|
* 依赖列之类仍由字段库提供;因子条目的单位恒为空(因子是没有单位的无量纲量)。
|
||||||
|
*/
|
||||||
|
const filterFields = useMemo<ConditionField[]>(() => {
|
||||||
|
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 [query, setQuery] = useState("");
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
const [editing, setEditing] = useState<StrategyParams | null>(null);
|
const [editing, setEditing] = useState<SelectionStrategyParams | null>(null);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
// 保存被拦下过 → 立即展开全部校验错误(见 StrategyParamsFormProps.revealErrors)
|
|
||||||
const [revealErrors, setRevealErrors] = useState(false);
|
const [revealErrors, setRevealErrors] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [docId, setDocId] = useState<string | null>(null);
|
const [docId, setDocId] = useState<string | null>(null);
|
||||||
|
|
||||||
const range = useMemo(() => recentRange(), []);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await apiGet<StrategyDefinition[]>("/strategies");
|
const rows = await apiGet<SelectionStrategy[]>("/strategies");
|
||||||
setList(rows);
|
setList(rows);
|
||||||
setErr("");
|
setErr("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -74,6 +95,10 @@ export default function StrategiesPage() {
|
|||||||
apiGet<FactorMeta[]>("/factors")
|
apiGet<FactorMeta[]>("/factors")
|
||||||
.then((f) => alive && setFactors(f))
|
.then((f) => alive && setFactors(f))
|
||||||
.catch(() => alive && setFactors([]));
|
.catch(() => alive && setFactors([]));
|
||||||
|
// 字段库只取启用项:停用的字段不该出现在条件下拉里(管理在 /fields)
|
||||||
|
apiGet<ConditionField[]>("/condition-fields?include_disabled=false")
|
||||||
|
.then((f) => alive && setConditionFields(f))
|
||||||
|
.catch(() => alive && setConditionFields([]));
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
@@ -96,19 +121,18 @@ export default function StrategiesPage() {
|
|||||||
});
|
});
|
||||||
}, [list, query]);
|
}, [list, query]);
|
||||||
|
|
||||||
/* ---------- 新建 / 编辑 ---------- */
|
|
||||||
function startCreate() {
|
function startCreate() {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setEditing(emptyParams(range));
|
setEditing(emptySelectionParams());
|
||||||
setDocId(null);
|
setDocId(null);
|
||||||
setMsg("");
|
setMsg("");
|
||||||
setErr("");
|
setErr("");
|
||||||
setRevealErrors(false);
|
setRevealErrors(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(s: StrategyDefinition) {
|
function startEdit(s: SelectionStrategy) {
|
||||||
setEditingId(s.id ?? null);
|
setEditingId(s.id ?? null);
|
||||||
setEditing(paramsFromStrategy(s, { ...emptyParams(range) }));
|
setEditing(paramsFromSelectionStrategy(s));
|
||||||
setDocId(s.id ?? null);
|
setDocId(s.id ?? null);
|
||||||
setMsg("");
|
setMsg("");
|
||||||
setErr("");
|
setErr("");
|
||||||
@@ -117,22 +141,22 @@ export default function StrategiesPage() {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
const errors = validateParams(editing, { requireMeta: true });
|
const errors = validateSelectionParams(editing);
|
||||||
if (Object.keys(errors).length) {
|
if (Object.keys(errors).length) {
|
||||||
setErr(Object.values(errors)[0]);
|
setErr(Object.values(errors)[0]);
|
||||||
setRevealErrors(true); // 一次把问题全列出来,而不是让用户逐个试
|
setRevealErrors(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr("");
|
setErr("");
|
||||||
try {
|
try {
|
||||||
const body = strategyFromParams(editing, editingId ?? undefined);
|
const body = selectionStrategyFromParams(editing, editingId ?? undefined);
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await apiPut<StrategyDefinition>(`/strategies/${encodeURIComponent(editingId)}`, body);
|
await apiPut<SelectionStrategy>(`/strategies/${encodeURIComponent(editingId)}`, body);
|
||||||
setMsg(`已更新策略「${body.name}」`);
|
setMsg(`已更新选股策略「${body.name}」`);
|
||||||
} else {
|
} else {
|
||||||
const saved = await apiPost<StrategyDefinition>("/strategies", body);
|
const saved = await apiPost<SelectionStrategy>("/strategies", body);
|
||||||
setMsg(`已保存策略「${saved.name}」(${saved.id})`);
|
setMsg(`已保存选股策略「${saved.name}」(${saved.id})`);
|
||||||
}
|
}
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setEditingId(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 (!s.id) return;
|
||||||
if (!window.confirm(`删除策略「${s.name}」?此操作不可撤销(已跑过的实验不受影响)。`)) return;
|
if (!window.confirm(`删除选股策略「${s.name}」?引用它的回测组合将无法运行。`)) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await apiDelete(`/strategies/${encodeURIComponent(s.id)}`);
|
await apiDelete(`/strategies/${encodeURIComponent(s.id)}`);
|
||||||
setMsg(`已删除策略「${s.name}」`);
|
setMsg(`已删除选股策略「${s.name}」`);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
@@ -163,15 +187,15 @@ export default function StrategiesPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="策略库"
|
title="选股策略库"
|
||||||
sub="把调好的参数存成命名策略:每个策略都有「一句话说明 + 计算公式 + 执行步骤」,可一键回测、可复现、可对比。"
|
sub="每个策略只定义「怎么选」(股票池 + 因子 + 过滤条件)。要验证收益,去「回测组合」把它和一个或多个策略组合起来、填上回测参数再跑。"
|
||||||
actions={
|
actions={
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
<Link href="/experiments" className="btn">
|
<Link href="/backtest" className="btn">
|
||||||
<span>查看实验</span>
|
<span>去建回测组合</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Btn variant="primary" icon="plus" onClick={startCreate} disabled={busy}>
|
<Btn variant="primary" icon="plus" onClick={startCreate} disabled={busy}>
|
||||||
新建策略
|
新建选股策略
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -180,11 +204,10 @@ export default function StrategiesPage() {
|
|||||||
{err ? <Banner tone="error">{err}</Banner> : null}
|
{err ? <Banner tone="error">{err}</Banner> : null}
|
||||||
{msg ? <Banner tone="info">{msg}</Banner> : null}
|
{msg ? <Banner tone="info">{msg}</Banner> : null}
|
||||||
|
|
||||||
{/* ---------- 编辑器 ---------- */}
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Card
|
<Card
|
||||||
icon={editingId ? "edit" : "plus"}
|
icon={editingId ? "edit" : "plus"}
|
||||||
title={editingId ? `编辑策略 · ${editingId}` : "新建策略"}
|
title={editingId ? `编辑选股策略 · ${editingId}` : "新建选股策略"}
|
||||||
tools={
|
tools={
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
<Btn onClick={() => { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
|
<Btn onClick={() => { setEditing(null); setEditingId(null); setDocId(null); }} disabled={busy}>
|
||||||
@@ -196,68 +219,48 @@ export default function StrategiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StrategyParamsForm
|
<SelectionStrategyForm
|
||||||
value={editing}
|
value={editing}
|
||||||
onChange={setEditing}
|
onChange={setEditing}
|
||||||
factorOptions={factors}
|
factorOptions={factors}
|
||||||
showMeta
|
conditionFields={filterFields}
|
||||||
showPeriod={false}
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
errors={validateParams(editing, { requireMeta: true })}
|
errors={validateSelectionParams(editing)}
|
||||||
revealErrors={revealErrors}
|
revealErrors={revealErrors}
|
||||||
/>
|
/>
|
||||||
<div className="hint" style={{ marginTop: 10 }}>
|
<div className="hint" style={{ marginTop: 12 }}>
|
||||||
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
|
这里<b>不填</b>资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 回测区间 ——
|
||||||
这两项在运行回测时才指定,因此同一策略可用于不同区间的复现与对比。
|
那些是回测时才定的,在「回测组合」里填;费率与复权在「公共配置」里设。
|
||||||
</div>
|
|
||||||
<div className="row" style={{ marginTop: 12, gap: 8 }}>
|
|
||||||
<Btn
|
|
||||||
icon="target"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() =>
|
|
||||||
setEditing({
|
|
||||||
...casePreset(range),
|
|
||||||
name: editing.name,
|
|
||||||
description: editing.description,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
载入高股息案例默认参数
|
|
||||||
</Btn>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{editing ? (
|
{editing ? <EditingDoc id={editingId} /> : null}
|
||||||
<EditingDoc params={editing} savedId={editingId} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{docId && !editing ? <SavedDoc id={docId} onClose={() => setDocId(null)} /> : null}
|
{docId && !editing ? <SavedDoc id={docId} onClose={() => setDocId(null)} /> : null}
|
||||||
|
|
||||||
{/* ---------- 列表 ---------- */}
|
|
||||||
<Card
|
<Card
|
||||||
icon="archive"
|
icon="archive"
|
||||||
title={`已保存策略${list ? ` · ${list.length}` : ""}`}
|
title={`选股策略${list ? ` · ${list.length}` : ""}`}
|
||||||
tools={
|
tools={
|
||||||
<input
|
<input
|
||||||
className="input input--search"
|
className="input input--search"
|
||||||
placeholder="搜索策略名 / 说明 / 因子 / 条件"
|
placeholder="搜索策略名 / 说明 / 因子 / 条件"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
aria-label="搜索策略"
|
aria-label="搜索选股策略"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{filtered === null ? (
|
{filtered === null ? (
|
||||||
<Loading label="读取策略库…" />
|
<Loading label="读取选股策略库…" />
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<Empty
|
<Empty
|
||||||
icon="archive"
|
icon="archive"
|
||||||
title={list && list.length ? "没有匹配的策略" : "策略库还是空的"}
|
title={list && list.length ? "没有匹配的选股策略" : "选股策略库还是空的"}
|
||||||
hint={
|
hint={
|
||||||
list && list.length
|
list && list.length
|
||||||
? "换个关键词试试。"
|
? "换个关键词试试。"
|
||||||
: "点右上角「新建策略」保存第一个策略;也可以先去回测页调好参数,再点「保存为策略」。"
|
: "点右上角「新建选股策略」保存第一个;定义好「怎么选」后,去回测组合里验证收益。"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -266,8 +269,9 @@ export default function StrategiesPage() {
|
|||||||
<StrategyCard
|
<StrategyCard
|
||||||
key={s.id ?? s.name}
|
key={s.id ?? s.name}
|
||||||
s={s}
|
s={s}
|
||||||
range={range}
|
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
fieldByName={fieldByName}
|
||||||
|
factorByKey={factorByKey}
|
||||||
onEdit={() => startEdit(s)}
|
onEdit={() => startEdit(s)}
|
||||||
onDelete={() => remove(s)}
|
onDelete={() => remove(s)}
|
||||||
onDoc={() => setDocId(s.id ?? null)}
|
onDoc={() => setDocId(s.id ?? null)}
|
||||||
@@ -282,17 +286,22 @@ export default function StrategiesPage() {
|
|||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/** 未保存参数的实时说明预览 */
|
function EditingDoc({ id }: { id: string | null }) {
|
||||||
function EditingDoc({ params, savedId }: { params: StrategyParams; savedId: string | null }) {
|
// 编辑未保存时无法预览后端说明(需要 id);保存后才能看
|
||||||
const live = useStrategyDoc(params, !savedId);
|
const state = useStrategyDocById(id);
|
||||||
const saved = useStrategyDocById(savedId);
|
if (!id) {
|
||||||
const state = savedId ? saved : live;
|
return (
|
||||||
|
<Card icon="book" title="策略说明与计算公式">
|
||||||
|
<div className="hint">保存后即可在此预览后端按选股条件推导的说明与公式。</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<StrategyDocCard
|
<StrategyDocCard
|
||||||
doc={state.doc}
|
doc={state.doc}
|
||||||
loading={state.loading}
|
loading={state.loading}
|
||||||
error={state.error}
|
error={state.error}
|
||||||
title="策略说明与计算公式(随参数实时更新)"
|
title="策略说明与计算公式(后端按选股条件推导)"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -318,120 +327,64 @@ function SavedDoc({ id, onClose }: { id: string; onClose: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单个策略卡片:说明 + 参数摘要 + 一键回测 */
|
|
||||||
function StrategyCard({
|
function StrategyCard({
|
||||||
s,
|
s,
|
||||||
range,
|
|
||||||
busy,
|
busy,
|
||||||
|
fieldByName,
|
||||||
|
factorByKey,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDoc,
|
onDoc,
|
||||||
}: {
|
}: {
|
||||||
s: StrategyDefinition;
|
s: SelectionStrategy;
|
||||||
range: { start: string; end: string };
|
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
fieldByName: Map<string, ConditionField>;
|
||||||
|
factorByKey: Map<string, FactorMeta>;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onDoc: () => void;
|
onDoc: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [start, setStart] = useState("2020-01-01");
|
const u = s.universe ?? {};
|
||||||
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<BacktestResult | null>(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<Record<string, unknown>>(
|
|
||||||
`/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<BacktestResult> = await waitJob<BacktestResult>(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("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
<div className="between">
|
<div className="between">
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div className="card__title" style={{ marginBottom: 2 }}>
|
<div className="card__title" style={{ marginBottom: 2 }}>{s.name}</div>
|
||||||
{s.name}
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||||
<span className="mono hint">{s.id}</span>
|
<span className="mono hint">{s.id}</span>
|
||||||
{s.created_at ? <span className="hint">创建 {String(s.created_at).slice(0, 10)}</span> : null}
|
{s.created_at ? <span className="hint">创建 {String(s.created_at).slice(0, 10)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Pill tone="violet" icon="target">
|
<Pill tone="violet" icon="target">
|
||||||
{s.factors?.map((f) => f.name).join(" + ") || "—"}
|
{s.factors?.map((f) => factorLabel(factorByKey.get(f.name), f.name)).join(" + ") || "—"}
|
||||||
</Pill>
|
</Pill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="strategy-desc">{s.description || "(无说明:建议补一句话说明,便于日后识别)"}</div>
|
<div className="strategy-desc">{s.description || "(无说明:建议补一句话,便于日后识别)"}</div>
|
||||||
|
|
||||||
<div className="chips">
|
<div className="chips">
|
||||||
<span className="chip">
|
<span className="chip">
|
||||||
候选池 <b>{sel.top_n ?? "—"}</b> → 持仓 <b>{sel.hold_top_x ?? sel.top_n ?? "—"}</b>
|
股票池 <b>{u.index_code ? `${u.index_code} 成分` : "全市场"}</b>
|
||||||
</span>
|
{u.exclude_st ? " · 剔 ST" : ""}
|
||||||
<span className="chip">
|
{u.min_listing_days ? ` · ≥${u.min_listing_days}天` : ""}
|
||||||
择股 <b>{s.selection_interval_months ?? "跟随 y"}</b> 月 / 调仓{" "}
|
|
||||||
<b>{s.rebalance_interval_months ?? "跟随 m"}</b> 月
|
|
||||||
</span>
|
|
||||||
<span className="chip">
|
|
||||||
复权 <b>{adjLabel(s.price_adjustment)}</b>
|
|
||||||
</span>
|
|
||||||
<span className="chip">
|
|
||||||
{s.universe?.exclude_st ? "剔除 ST" : "含 ST"} · 费率{" "}
|
|
||||||
<b>{((costs.commission_rate ?? 0) * 100).toFixed(3)}%</b> + 印花{" "}
|
|
||||||
<b>{((costs.stamp_tax_rate ?? 0) * 100).toFixed(2)}%</b>
|
|
||||||
</span>
|
</span>
|
||||||
{(s.conditions ?? []).length ? (
|
{(s.conditions ?? []).length ? (
|
||||||
<span className="chip">
|
<span className="chip">
|
||||||
条件 <b>{(s.conditions ?? []).map((c) => `${c.field} ${opLabel(c.op)} ${c.value ?? ""}`).join(" 且 ")}</b>
|
条件{" "}
|
||||||
|
<b>
|
||||||
|
{(s.conditions ?? [])
|
||||||
|
.map((c) => `${fieldByName.get(c.field)?.label ?? c.field} ${opLabel(c.op)} ${condValueText(c, fieldByName.get(c.field))}`)
|
||||||
|
.join(" 且 ")}
|
||||||
|
</b>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : (
|
||||||
|
<span className="chip">无过滤条件</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="strategy-actions">
|
<div className="strategy-actions">
|
||||||
<Btn size="sm" variant="primary" icon="play" loading={running} disabled={running || busy} onClick={run}>
|
|
||||||
{running ? "后台运行中…" : "一键回测"}
|
|
||||||
</Btn>
|
|
||||||
<Link href={`/backtest?strategy=${encodeURIComponent(s.id ?? "")}`} className="btn btn--sm">
|
<Link href={`/backtest?strategy=${encodeURIComponent(s.id ?? "")}`} className="btn btn--sm">
|
||||||
<span>载入回测页(可微调)</span>
|
<span>加入回测组合</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Btn size="sm" icon="book" onClick={onDoc} disabled={busy}>
|
<Btn size="sm" icon="book" onClick={onDoc} disabled={busy}>
|
||||||
看说明/公式
|
看说明/公式
|
||||||
@@ -443,77 +396,19 @@ function StrategyCard({
|
|||||||
删除
|
删除
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 运行区间(默认 2020-01-01 ~ 最近交易日) */}
|
|
||||||
<details open={open} onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}>
|
|
||||||
<summary className="hint" style={{ cursor: "pointer" }}>
|
|
||||||
回测区间与资金(一键回测使用)
|
|
||||||
</summary>
|
|
||||||
<div className="form-grid" style={{ marginTop: 8 }}>
|
|
||||||
<Field label="开始日期">
|
|
||||||
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} disabled={running} />
|
|
||||||
</Field>
|
|
||||||
<Field label="结束日期">
|
|
||||||
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} disabled={running} />
|
|
||||||
</Field>
|
|
||||||
<Field label="初始资金(元)">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input"
|
|
||||||
step={100000}
|
|
||||||
min={10000}
|
|
||||||
value={capital}
|
|
||||||
onChange={(e) => setCapital(Number(e.target.value))}
|
|
||||||
disabled={running}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
{running ? (
|
|
||||||
<Progress
|
|
||||||
value={35}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中(全市场回测通常 1~5 分钟)
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{error ? <Banner tone="error">{error}</Banner> : null}
|
|
||||||
|
|
||||||
{result ? (
|
|
||||||
<div>
|
|
||||||
<div className="between" style={{ marginBottom: 8 }}>
|
|
||||||
<Pill tone="pos" icon="check">
|
|
||||||
回测完成
|
|
||||||
</Pill>
|
|
||||||
<Link
|
|
||||||
href={expId ? `/experiments?exp=${encodeURIComponent(expId)}` : "/experiments"}
|
|
||||||
className="btn btn--sm"
|
|
||||||
>
|
|
||||||
<span>在实验中对比</span>
|
|
||||||
</Link>
|
|
||||||
{expId ? (
|
|
||||||
<>
|
|
||||||
<Link href={`/experiments/${encodeURIComponent(expId)}`} className="btn btn--sm">
|
|
||||||
<span>打开归档(完整快照)</span>
|
|
||||||
</Link>
|
|
||||||
<Link href={`/backtest?from_experiment=${encodeURIComponent(expId)}`} className="btn btn--sm">
|
|
||||||
<span>以此参数再跑</span>
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<BacktestMetrics s={result.summary} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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
|
{ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string> = 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<string, string> {
|
||||||
|
const e: Record<string, string> = {};
|
||||||
|
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<string, string>;
|
||||||
|
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<Record<string, boolean>>({});
|
||||||
|
const blur = (key: string) => () => setTouched((t) => ({ ...t, [key]: true }));
|
||||||
|
const showErr = (key: string) => (revealErrors || touched[key] ? errors[key] : undefined);
|
||||||
|
const set = (patch: Partial<SelectionStrategyParams>) => 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 (
|
||||||
|
<div className="params-form">
|
||||||
|
{/* 名字 + 一句话说明 */}
|
||||||
|
<div className="params-meta">
|
||||||
|
<Field label="策略名(必填)" hint="在策略库中唯一" className="params-meta__name">
|
||||||
|
<input
|
||||||
|
className={`input${showErr("name") ? " input--invalid" : ""}`}
|
||||||
|
value={p.name}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="例:高股息防御"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => set({ name: e.target.value })}
|
||||||
|
onBlur={blur("name")}
|
||||||
|
/>
|
||||||
|
{showErr("name") && <div className="field-err" role="alert">{showErr("name")}</div>}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="一句话说明(必填)"
|
||||||
|
hint={`说清这个策略怎么选(${p.description.length}/300)`}
|
||||||
|
className="params-meta__desc"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className={`input${showErr("description") ? " input--invalid" : ""}`}
|
||||||
|
rows={2}
|
||||||
|
value={p.description}
|
||||||
|
maxLength={300}
|
||||||
|
placeholder="例:全市场股息率最高、且 dv_ratio ≤ 30 的股票,剔除 ST 与次新股"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => set({ description: e.target.value })}
|
||||||
|
onBlur={blur("description")}
|
||||||
|
/>
|
||||||
|
{showErr("description") && (
|
||||||
|
<div className="field-err" role="alert">{showErr("description")}</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 股票池 */}
|
||||||
|
<b className="form-section__title">股票池(universe)</b>
|
||||||
|
<div className="form-grid" style={{ marginTop: 8 }}>
|
||||||
|
<Field label="标的范围">
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.excludeSt}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => set({ excludeSt: e.target.checked })}
|
||||||
|
/>
|
||||||
|
剔除 ST
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
<Field label="最少上市天数" hint="避免次新股噪声;0 = 不限制">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
step={10}
|
||||||
|
value={p.minListingDays}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => set({ minListingDays: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="指数成分(可选)" hint="如 000300.SH = 仅沪深300成分股;留空 = 不限">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
value={p.indexCode}
|
||||||
|
placeholder="000300.SH"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => set({ indexCode: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 因子 */}
|
||||||
|
<div className="between" style={{ marginTop: 14, marginBottom: 8 }}>
|
||||||
|
<b className="form-section__title">打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
|
||||||
|
<Btn
|
||||||
|
icon="layers"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() =>
|
||||||
|
set({
|
||||||
|
factors: [
|
||||||
|
...p.factors,
|
||||||
|
// 默认选中第一个「可用」的因子(停用的不该被默认塞进新策略)
|
||||||
|
{
|
||||||
|
name: (factorOptions.find((o) => o.enabled !== false) ?? factorOptions[0])?.name ?? "",
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
添加因子
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
<div className="factor-rows">
|
||||||
|
<div className="factor-rows__head" aria-hidden="true">
|
||||||
|
<span>因子</span>
|
||||||
|
<span>权重</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{p.factors.map((f, i) => {
|
||||||
|
const meta = factorOptions.find((o) => o.name === f.name);
|
||||||
|
return (
|
||||||
|
<div className="factor-row" key={i}>
|
||||||
|
<div className="cell-stack">
|
||||||
|
<select
|
||||||
|
id={`${fid}-name-${i}`}
|
||||||
|
className="input"
|
||||||
|
aria-label={`第 ${i + 1} 个因子的名称`}
|
||||||
|
value={f.name}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setFactor(i, { name: e.target.value })}
|
||||||
|
>
|
||||||
|
{!meta && f.name ? (
|
||||||
|
<option value={f.name}>{f.name}(未在因子表)</option>
|
||||||
|
) : null}
|
||||||
|
{/* 停用的因子不出现在候选里(管理在 /factors),但**当前已选中的那个必须留着**,
|
||||||
|
否则编辑历史策略时会显示成「未在因子表」,读起来像因子被删了。 */}
|
||||||
|
{factorOptions
|
||||||
|
.filter((o) => o.enabled !== false || o.name === f.name)
|
||||||
|
.map((o) => (
|
||||||
|
<option key={o.name} value={o.name}>
|
||||||
|
{factorOptionLabel(o)}
|
||||||
|
{o.enabled === false ? "(已停用)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="hint cell-hint">
|
||||||
|
{meta
|
||||||
|
? factorHint(meta)
|
||||||
|
: "该名称不在因子目录里:可能是历史策略引用了已删除的因子,运行时会报错"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id={`${fid}-weight-${i}`}
|
||||||
|
className="input mono"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.1"
|
||||||
|
min={0}
|
||||||
|
aria-label={`第 ${i + 1} 个因子的权重`}
|
||||||
|
title="权重:越大表示该因子越重要(1 = 等权)"
|
||||||
|
value={f.weight}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setFactor(i, { weight: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
{p.factors.length > 1 ? (
|
||||||
|
<Btn
|
||||||
|
icon="x"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`删除第 ${i + 1} 个因子`}
|
||||||
|
title="删除该因子"
|
||||||
|
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{showErr("factors") && <div className="field-err" role="alert">{showErr("factors")}</div>}
|
||||||
|
<div className="hint" style={{ marginTop: 6 }}>
|
||||||
|
权重 1 = 等权;多个因子会先各自做横截面 z-score 标准化再加权,避免量纲不同互相压制。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 过滤条件 */}
|
||||||
|
<div className="between" style={{ marginTop: 14, marginBottom: 8 }}>
|
||||||
|
<b className="form-section__title">过滤条件(AND,股票池之后、因子排序之前执行)</b>
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<Link href="/fields" className="btn btn--sm"><span>管理字段库</span></Link>
|
||||||
|
<Btn
|
||||||
|
icon="layers"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
|
||||||
|
>
|
||||||
|
添加条件
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.conditions.length === 0 ? (
|
||||||
|
<div className="hint">
|
||||||
|
未设置条件:候选 = 股票池内因子分最高的若干只(具体取多少只在回测组合里定)。
|
||||||
|
条件用来「筛掉不要的」,因子用来「排序」—— 两者可以同时用。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="cond-rows">
|
||||||
|
<div className="cond-rows__head" aria-hidden="true">
|
||||||
|
<span>字段</span>
|
||||||
|
<span>比较</span>
|
||||||
|
<span>取值方式</span>
|
||||||
|
<span>取值</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{p.conditions.map((c, i) => {
|
||||||
|
const meta = fieldByName.get(c.field);
|
||||||
|
const isRef = !!c.ref;
|
||||||
|
const kind = meta?.kind ?? "num";
|
||||||
|
// 单位换算:存储/引擎用基准单位,输入/显示用字段库选的界面单位
|
||||||
|
const scale = kind === "str" ? 1 : unitScale(meta);
|
||||||
|
const baseUnit = baseUnitOf(meta);
|
||||||
|
const displayUnit = displayUnitOf(meta);
|
||||||
|
const setCond = (patch: Partial<ResearchCondition>) =>
|
||||||
|
set({ conditions: p.conditions.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
|
||||||
|
return (
|
||||||
|
<div className="cond-row" key={i}>
|
||||||
|
<div className="cell-stack">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
aria-label={`第 ${i + 1} 个条件的字段`}
|
||||||
|
value={c.field}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nf = e.target.value;
|
||||||
|
const nm = fieldByName.get(nf);
|
||||||
|
const op = pickOp(c.op, nm);
|
||||||
|
// 从数值字段换到文本字段时清空取值:拿数字去比「行业」是永远为假的组合
|
||||||
|
const keepValue = !(nm?.kind === "str" && kind !== "str");
|
||||||
|
setCond({
|
||||||
|
field: nf,
|
||||||
|
op,
|
||||||
|
value: keepValue ? coerceValue(c.value, op) : coerceValue("", op),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">选择字段…</option>
|
||||||
|
{fieldGroups.map((g) => (
|
||||||
|
<optgroup key={g.name} label={g.name}>
|
||||||
|
{g.items.map((f) => (
|
||||||
|
<option key={f.name} value={f.name}>
|
||||||
|
{f.label}
|
||||||
|
{f.unit ? `(${f.unit})` : ""} · {f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
{c.field && !meta ? (
|
||||||
|
<option value={c.field}>⚠ 不在字段库:{c.field}</option>
|
||||||
|
) : null}
|
||||||
|
</select>
|
||||||
|
<span className="hint cell-hint">
|
||||||
|
{meta ? (
|
||||||
|
<>
|
||||||
|
{meta.description}
|
||||||
|
{scale !== 1 ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<b>
|
||||||
|
界面单位 {meta.unit}
|
||||||
|
</b>
|
||||||
|
:按 {meta.unit} 输入,提交时 ×{scale} 换算成基准单位 {baseUnit}
|
||||||
|
(引擎只按基准单位比较,所以改单位不会让已有策略变义)。
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : c.field ? (
|
||||||
|
"该字段不在字段库中(可能已停用或删除):请重新选择,否则条件会一直不通过"
|
||||||
|
) : (
|
||||||
|
"选一个字段 —— 下方会显示它的口径与单位"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
aria-label={`第 ${i + 1} 个条件的比较符`}
|
||||||
|
value={c.op}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const op = e.target.value as Op;
|
||||||
|
setCond({ op, value: coerceValue(c.value, op) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{OPS.filter((o) => opsFor(meta).includes(o.value)).map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
aria-label={`第 ${i + 1} 个条件的取值方式`}
|
||||||
|
value={isRef ? "ref" : "value"}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === "ref") {
|
||||||
|
setCond({ ref: "", value: null });
|
||||||
|
} else {
|
||||||
|
setCond({ ref: null, value: "" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="取值:和固定数值/文本比;字段:和另一个字段比(如 收盘价 > MA60)"
|
||||||
|
>
|
||||||
|
<option value="value">数值/文本</option>
|
||||||
|
<option value="ref">另一字段</option>
|
||||||
|
</select>
|
||||||
|
{isRef ? (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
aria-label={`第 ${i + 1} 个条件比较的字段`}
|
||||||
|
value={c.ref ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setCond({ ref: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">选择字段…</option>
|
||||||
|
{fieldGroups.map((g) => (
|
||||||
|
<optgroup key={g.name} label={g.name}>
|
||||||
|
{g.items.map((f) => (
|
||||||
|
<option key={f.name} value={f.name}>
|
||||||
|
{f.label} · {f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="unit-input">
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
aria-label={`第 ${i + 1} 个条件的取值`}
|
||||||
|
inputMode={kind === "num" && c.op !== "in" && c.op !== "not_in" ? "decimal" : "text"}
|
||||||
|
placeholder={
|
||||||
|
c.op === "in" || c.op === "not_in"
|
||||||
|
? "多个值用逗号分隔"
|
||||||
|
: kind === "str"
|
||||||
|
? "文本,如 银行"
|
||||||
|
: "数值"
|
||||||
|
}
|
||||||
|
value={valueToText(c.value, scale)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setCond({ value: textToValue(e.target.value, c.op, scale, kind) })}
|
||||||
|
/>
|
||||||
|
{displayUnit ? (
|
||||||
|
<span className="unit-suffix" title={`界面单位 ${displayUnit};提交时按 ×${scale} 换算成基准单位 ${baseUnit}`}>
|
||||||
|
{displayUnit}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Btn
|
||||||
|
icon="x"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`删除第 ${i + 1} 个条件`}
|
||||||
|
title="删除该条件"
|
||||||
|
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showErr("conditions") && <div className="field-err" role="alert">{showErr("conditions")}</div>}
|
||||||
|
|
||||||
|
{/* 因子 vs 条件:关系说明(用户 2026-10 反馈第 3 条) */}
|
||||||
|
<div className="relation-note">
|
||||||
|
<div className="relation-note__title">打分因子 与 过滤条件 的关系</div>
|
||||||
|
<div className="relation-note__flow">
|
||||||
|
<span className="chip"><b>1 股票池</b> 剔 ST / 上市天数 / 指数成分</span>
|
||||||
|
<span className="chip"><b>2 过滤条件</b> 全部 AND 通过才有资格</span>
|
||||||
|
<span className="chip"><b>3 打分因子</b> z-score 加权 → 排序</span>
|
||||||
|
<span className="chip"><b>4 取 TopN</b> N 在回测组合里定</span>
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
<b>条件 = 准入</b>(筛掉不要的,不决定顺序);<b>因子 = 优先级</b>(决定谁排前面,
|
||||||
|
不筛掉任何人)。同一个字段两种用法都行:你的高股息策略里
|
||||||
|
<code className="mono">dv_ratio ≤ 30</code> 当条件用,是剔掉股息率异常偏高的
|
||||||
|
「高股息陷阱」样本;<code className="mono">dividend_yield</code> 当因子用,
|
||||||
|
是让股息率高的排前面。字段的含义与单位见
|
||||||
|
<Link href="/fields"> 字段库</Link>。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,820 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 策略参数表单(受控组件)—— 策略库 / 回测页 / 选股直通 共用同一份参数模型。
|
|
||||||
*
|
|
||||||
* 为什么抽出来:策略库要「新建/编辑策略」、回测页要「保存为策略/从策略载入」、
|
|
||||||
* 选股页要「按此条件回测」,三处字段与校验完全同构。若各写一遍,必然出现
|
|
||||||
* 「选股页能设的条件在回测页设不了」这类口径漂移(本平台的核心风险)。
|
|
||||||
* 因此参数只有一个模型 `StrategyParams`,一个表单组件,一套校验。
|
|
||||||
*
|
|
||||||
* 组件**不持有业务状态**:value/onChange 由父组件控制,父组件负责提交与落库。
|
|
||||||
*/
|
|
||||||
import { useId, useState } from "react";
|
|
||||||
import type { FactorMeta, ResearchCondition, ResearchSpec, StrategyDefinition } from "@/lib/types";
|
|
||||||
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: "不属于" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface StrategyParams {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
/** 加权因子列表(打分公式 = Σ weight × factor) */
|
|
||||||
factors: { name: string; weight: number }[];
|
|
||||||
priceAdjustment: "none" | "qfq" | "hfq";
|
|
||||||
/** n:候选池(择股条件选出的股数) */
|
|
||||||
topN: number;
|
|
||||||
/** x:实际持仓数,必须 ≤ n */
|
|
||||||
holdX: number;
|
|
||||||
/** m:择股间隔(月),0 = 不单独设(跟随 y) */
|
|
||||||
mMonths: number;
|
|
||||||
/** y:调仓间隔(月),0 = 跟随 m */
|
|
||||||
yMonths: number;
|
|
||||||
rebalance: "monthly" | "weekly";
|
|
||||||
/**
|
|
||||||
* 买不进(涨停/停牌)时的补位策略 —— 三态,与后端两个互斥字段一一对应:
|
|
||||||
* - `substitute`:allow_substitute=true, defer_buy=false(换一只买)
|
|
||||||
* - `defer`:allow_substitute=false, defer_buy=true(顺延到之后首个不涨停的交易日)
|
|
||||||
* - `none`:两者皆 false(不补位,可能少持几只)
|
|
||||||
* 后端拒绝两者同时为 true,因此这里不提供「都选」的组合。
|
|
||||||
*/
|
|
||||||
fillPolicy: FillPolicy;
|
|
||||||
conditions: ResearchCondition[];
|
|
||||||
excludeSt: boolean;
|
|
||||||
minListingDays: number;
|
|
||||||
commission: number; // %
|
|
||||||
stamp: number; // %
|
|
||||||
slippage: number; // %
|
|
||||||
minCommission: number; // 元/笔
|
|
||||||
capital: number;
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户案例默认参数:全市场股息率最高的 n 只 → 持仓前 x 只,每 m 个月择股、每 y 个月调仓。 */
|
|
||||||
export type FillPolicy = "substitute" | "defer" | "none";
|
|
||||||
|
|
||||||
export const CASE_PRESET: Omit<StrategyParams, "start" | "end" | "name" | "description"> = {
|
|
||||||
factors: [{ name: "dividend_yield", weight: 1 }],
|
|
||||||
priceAdjustment: "hfq",
|
|
||||||
topN: 20,
|
|
||||||
holdX: 20,
|
|
||||||
mMonths: 6,
|
|
||||||
yMonths: 6,
|
|
||||||
rebalance: "monthly",
|
|
||||||
fillPolicy: "defer",
|
|
||||||
conditions: [{ field: "dv_ratio", op: "lte", value: 30 }],
|
|
||||||
excludeSt: true,
|
|
||||||
minListingDays: 250,
|
|
||||||
commission: 0.03,
|
|
||||||
stamp: 0.05,
|
|
||||||
slippage: 0.1,
|
|
||||||
minCommission: 5,
|
|
||||||
capital: 1_000_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function emptyParams(range: { start: string; end: string }): StrategyParams {
|
|
||||||
return {
|
|
||||||
...CASE_PRESET,
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
factors: [{ name: "momentum_60", weight: 1 }],
|
|
||||||
conditions: [],
|
|
||||||
start: range.start,
|
|
||||||
end: range.end,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function casePreset(range: { start: string; end: string }): StrategyParams {
|
|
||||||
return { ...CASE_PRESET, name: "", description: "", start: "2020-01-01", end: range.end };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 参数 → ResearchSpec(回测提交体)。m/y 的 0 语义与后端一致。 */
|
|
||||||
export function paramsToSpec(p: StrategyParams): ResearchSpec {
|
|
||||||
const factors = p.factors.filter((f) => f.name.trim() !== "");
|
|
||||||
return {
|
|
||||||
type: "backtest",
|
|
||||||
universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays },
|
|
||||||
price_adjustment: p.priceAdjustment,
|
|
||||||
factors: factors.length ? factors : [{ name: "momentum_60", weight: 1 }],
|
|
||||||
conditions: p.conditions.filter((c) => c.field.trim() !== ""),
|
|
||||||
selection: {
|
|
||||||
top_n: p.topN,
|
|
||||||
hold_top_x: p.holdX,
|
|
||||||
allow_substitute: p.fillPolicy === "substitute",
|
|
||||||
defer_buy: p.fillPolicy === "defer",
|
|
||||||
},
|
|
||||||
rebalance: p.rebalance,
|
|
||||||
// m=0 表示「每次调仓都择股」:若同时给了 y>0,则择股间隔跟随 y
|
|
||||||
// (后端禁止只给 y 而不给 m —— 无锚点无法确定择股日集合)
|
|
||||||
selection_interval_months: p.mMonths > 0 ? p.mMonths : p.yMonths > 0 ? p.yMonths : null,
|
|
||||||
rebalance_interval_months: p.yMonths > 0 ? p.yMonths : p.mMonths > 0 ? p.mMonths : null,
|
|
||||||
costs: {
|
|
||||||
commission_rate: p.commission / 100,
|
|
||||||
stamp_tax_rate: p.stamp / 100,
|
|
||||||
slippage_rate: p.slippage / 100,
|
|
||||||
min_commission: p.minCommission,
|
|
||||||
},
|
|
||||||
initial_capital: p.capital,
|
|
||||||
period: [p.start, p.end],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ResearchSpec → 参数(从实验详情「以此参数回测」时使用) */
|
|
||||||
export function paramsFromSpec(
|
|
||||||
spec: Partial<ResearchSpec> & { config_snapshot?: Record<string, unknown> },
|
|
||||||
base: StrategyParams
|
|
||||||
): StrategyParams {
|
|
||||||
const snap = (spec.config_snapshot ?? {}) as Partial<ResearchSpec>;
|
|
||||||
const s = (snap.factors ? snap : spec) as Partial<ResearchSpec>;
|
|
||||||
const sel: NonNullable<ResearchSpec["selection"]> = s.selection ?? { top_n: base.topN };
|
|
||||||
const costs: NonNullable<ResearchSpec["costs"]> = s.costs ?? {};
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
factors: s.factors?.length ? s.factors.map((f) => ({ ...f })) : base.factors,
|
|
||||||
priceAdjustment: s.price_adjustment ?? base.priceAdjustment,
|
|
||||||
topN: sel.top_n ?? base.topN,
|
|
||||||
holdX: sel.hold_top_x ?? sel.top_n ?? base.holdX,
|
|
||||||
mMonths: s.selection_interval_months ?? base.mMonths,
|
|
||||||
yMonths: s.rebalance_interval_months ?? base.yMonths,
|
|
||||||
rebalance: s.rebalance ?? base.rebalance,
|
|
||||||
fillPolicy: sel.defer_buy ? "defer" : sel.allow_substitute === false ? "none" : "substitute",
|
|
||||||
conditions: (s.conditions ?? []).map((c) => ({ ...c })),
|
|
||||||
excludeSt: s.universe?.exclude_st ?? base.excludeSt,
|
|
||||||
minListingDays: s.universe?.min_listing_days ?? base.minListingDays,
|
|
||||||
commission: (costs.commission_rate ?? base.commission / 100) * 100,
|
|
||||||
stamp: (costs.stamp_tax_rate ?? base.stamp / 100) * 100,
|
|
||||||
slippage: (costs.slippage_rate ?? base.slippage / 100) * 100,
|
|
||||||
minCommission: costs.min_commission ?? base.minCommission,
|
|
||||||
capital: s.initial_capital ?? base.capital,
|
|
||||||
start: s.period?.[0] ?? base.start,
|
|
||||||
end: s.period?.[1] ?? base.end,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 已保存策略 → 参数 */
|
|
||||||
export function paramsFromStrategy(st: StrategyDefinition, base: StrategyParams): StrategyParams {
|
|
||||||
const sel = st.selection ?? {};
|
|
||||||
const costs = st.costs ?? {};
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
name: st.name,
|
|
||||||
description: st.description ?? "",
|
|
||||||
factors: st.factors?.length ? st.factors.map((f) => ({ ...f })) : base.factors,
|
|
||||||
priceAdjustment: st.price_adjustment ?? base.priceAdjustment,
|
|
||||||
topN: sel.top_n ?? base.topN,
|
|
||||||
holdX: sel.hold_top_x ?? sel.top_n ?? base.holdX,
|
|
||||||
mMonths: st.selection_interval_months ?? base.mMonths,
|
|
||||||
yMonths: st.rebalance_interval_months ?? base.yMonths,
|
|
||||||
rebalance: st.rebalance ?? base.rebalance,
|
|
||||||
fillPolicy: sel.defer_buy ? "defer" : sel.allow_substitute === false ? "none" : "substitute",
|
|
||||||
conditions: (st.conditions ?? []).map((c) => ({ ...c })),
|
|
||||||
excludeSt: st.universe?.exclude_st ?? base.excludeSt,
|
|
||||||
minListingDays: st.universe?.min_listing_days ?? base.minListingDays,
|
|
||||||
commission: (costs.commission_rate ?? base.commission / 100) * 100,
|
|
||||||
stamp: (costs.stamp_tax_rate ?? base.stamp / 100) * 100,
|
|
||||||
slippage: (costs.slippage_rate ?? base.slippage / 100) * 100,
|
|
||||||
minCommission: costs.min_commission ?? base.minCommission,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 参数 → 策略定义(保存到策略库;period/capital 不入库,回测时再补) */
|
|
||||||
export function strategyFromParams(p: StrategyParams, id?: string): StrategyDefinition {
|
|
||||||
const factors = p.factors.filter((f) => f.name.trim() !== "");
|
|
||||||
return {
|
|
||||||
...(id ? { id } : {}),
|
|
||||||
name: p.name.trim(),
|
|
||||||
description: p.description.trim(),
|
|
||||||
spec_type: "backtest",
|
|
||||||
universe: { exclude_st: p.excludeSt, min_listing_days: p.minListingDays },
|
|
||||||
price_adjustment: p.priceAdjustment,
|
|
||||||
factors: factors.length ? factors : [{ name: "momentum_60", weight: 1 }],
|
|
||||||
conditions: p.conditions.filter((c) => c.field.trim() !== ""),
|
|
||||||
selection: {
|
|
||||||
top_n: p.topN,
|
|
||||||
hold_top_x: p.holdX,
|
|
||||||
allow_substitute: p.fillPolicy === "substitute",
|
|
||||||
defer_buy: p.fillPolicy === "defer",
|
|
||||||
},
|
|
||||||
rebalance: p.rebalance,
|
|
||||||
selection_interval_months: p.mMonths > 0 ? p.mMonths : p.yMonths > 0 ? p.yMonths : null,
|
|
||||||
rebalance_interval_months: p.yMonths > 0 ? p.yMonths : p.mMonths > 0 ? p.mMonths : null,
|
|
||||||
costs: {
|
|
||||||
commission_rate: p.commission / 100,
|
|
||||||
stamp_tax_rate: p.stamp / 100,
|
|
||||||
slippage_rate: p.slippage / 100,
|
|
||||||
min_commission: p.minCommission,
|
|
||||||
},
|
|
||||||
portfolio: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 表单校验(返回 field → 错误文案;空对象 = 通过) */
|
|
||||||
export function validateParams(
|
|
||||||
p: StrategyParams,
|
|
||||||
opts: { requireMeta?: boolean } = {}
|
|
||||||
): Record<string, string> {
|
|
||||||
const e: Record<string, string> = {};
|
|
||||||
if (opts.requireMeta) {
|
|
||||||
if (!p.name.trim()) e.name = "策略名必填(便于在策略库中识别)";
|
|
||||||
else if (p.name.trim().length > 64) e.name = "策略名最多 64 字";
|
|
||||||
if (!p.description.trim()) e.description = "一句话说明必填:说清这个策略做什么";
|
|
||||||
// strategy.description 落库列为 String(300):超长会被 MySQL 严格模式拒绝,
|
|
||||||
// 因此在表单层就拦住并说明原因(而不是让用户在保存时吃一个 500)
|
|
||||||
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 {
|
|
||||||
// 后端 ResearchSpec 校验会拒绝重复因子名(z-score 叠加两次没有意义且易误读),
|
|
||||||
// 这里提前拦住,避免用户填完参数后才吃一个 400
|
|
||||||
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}」重复了:同一因子只应出现一次(想加权重请调权重值)`;
|
|
||||||
}
|
|
||||||
if (p.topN < 1) e.topN = "候选池 n 至少为 1";
|
|
||||||
if (p.holdX < 1) e.holdX = "持仓数 x 至少为 1";
|
|
||||||
if (p.holdX > p.topN) e.holdX = `持仓数 x=${p.holdX} 不能大于候选池 n=${p.topN}`;
|
|
||||||
if (p.mMonths < 0 || p.mMonths > 60) e.mMonths = "m 需在 0~60 之间";
|
|
||||||
if (p.yMonths < 0 || p.yMonths > 60) e.yMonths = "y 需在 0~60 之间";
|
|
||||||
if (p.commission < 0 || p.stamp < 0 || p.slippage < 0) e.costs = "费率不能为负";
|
|
||||||
if (p.minCommission < 0) e.minCommission = "最低佣金不能为负";
|
|
||||||
if (p.capital < 10000) e.capital = "初始资金建议 ≥ 1 万";
|
|
||||||
if (!p.start || !p.end) e.period = "起止日期都必填";
|
|
||||||
else if (p.start >= p.end) e.period = "开始日期必须早于结束日期";
|
|
||||||
for (const c of p.conditions) {
|
|
||||||
if (!c.field.trim()) {
|
|
||||||
e.conditions = "存在空的条件字段:请填写字段名或删除该条件";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StrategyParamsFormProps {
|
|
||||||
value: StrategyParams;
|
|
||||||
onChange: (next: StrategyParams) => void;
|
|
||||||
factorOptions: FactorMeta[];
|
|
||||||
/** 显示策略名 + 一句话说明(策略库编辑/保存为策略时) */
|
|
||||||
showMeta?: boolean;
|
|
||||||
/** 显示回测区间与初始资金(回测执行时才需要) */
|
|
||||||
showPeriod?: boolean;
|
|
||||||
/** 显示操作按钮区(表单内提交按钮) */
|
|
||||||
disabled?: boolean;
|
|
||||||
errors?: Record<string, string>;
|
|
||||||
/** 因子选择是否允许加权多项(默认允许) */
|
|
||||||
multiFactor?: boolean;
|
|
||||||
/**
|
|
||||||
* 是否立即显示全部校验错误。
|
|
||||||
*
|
|
||||||
* 默认 false:只在**字段失焦过**之后才显示该字段的错误。理由:受控输入在用户
|
|
||||||
* 清空内容准备重填的瞬间就会被判为「至少为 1」,立刻标红属于打扰式提示;
|
|
||||||
* 提交被拦下时父组件把本值设为 true,确保此时所有问题一次看清。
|
|
||||||
*/
|
|
||||||
revealErrors?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StrategyParamsForm({
|
|
||||||
value: p,
|
|
||||||
onChange,
|
|
||||||
factorOptions,
|
|
||||||
showMeta = false,
|
|
||||||
showPeriod = true,
|
|
||||||
disabled = false,
|
|
||||||
errors = {},
|
|
||||||
multiFactor = true,
|
|
||||||
revealErrors = false,
|
|
||||||
}: StrategyParamsFormProps) {
|
|
||||||
// 稳定唯一前缀:同一个页面可能挂两份表单(策略库编辑 + 回测页),
|
|
||||||
// 写死 id 会撞车,label/for 与 aria 关联就会指错控件。
|
|
||||||
const fid = useId();
|
|
||||||
// 失焦过的字段才提示错误(见 revealErrors 说明)
|
|
||||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
||||||
const blur = (key: string) => () => setTouched((t) => ({ ...t, [key]: true }));
|
|
||||||
const showErr = (key: string) => (revealErrors || touched[key] ? errors[key] : undefined);
|
|
||||||
const set = (patch: Partial<StrategyParams>) => 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)) });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="params-form">
|
|
||||||
{showMeta && (
|
|
||||||
/* 策略名(定宽)+ 一句话说明(占满剩余宽度、多行)并排:
|
|
||||||
说明最长 300 字,塞进单行 input 必然截断(截图实测「例:全市场股息率最高的 2」
|
|
||||||
就被切掉),所以这里用 textarea 并给足高度。 */
|
|
||||||
<div className="params-meta">
|
|
||||||
<Field label="策略名(必填)" hint="在策略库中唯一" className="params-meta__name">
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={p.name}
|
|
||||||
maxLength={64}
|
|
||||||
placeholder="例:高股息 6 月择股 · 低频"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ name: e.target.value })}
|
|
||||||
onBlur={blur("name")}
|
|
||||||
/>
|
|
||||||
{showErr("name") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("name")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label="一句话说明(必填)"
|
|
||||||
hint={`说清这个策略做什么、怎么选股(${p.description.length}/300,落库列宽上限)`}
|
|
||||||
className="params-meta__desc"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
className={`input${showErr("description") ? " input--invalid" : ""}`}
|
|
||||||
rows={2}
|
|
||||||
value={p.description}
|
|
||||||
maxLength={300}
|
|
||||||
placeholder="例:全市场股息率最高的 20 只,每 6 个月重新择股并等权持有"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ description: e.target.value })}
|
|
||||||
onBlur={blur("description")}
|
|
||||||
/>
|
|
||||||
{showErr("description") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("description")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ---------- 因子(打分公式) ----------
|
|
||||||
用「表头 + 栅格行」表达多项因子:列宽由 CSS 栅格统一控制(不再在 JSX 里写
|
|
||||||
内联宽度),这样因子下拉、权重输入、删除按钮在所有行严格成列对齐;
|
|
||||||
视觉表头给正常用户,aria-label 给读屏(重复行只有表头时读屏无法分辨)。 */}
|
|
||||||
<div className="between" style={{ marginBottom: 8 }}>
|
|
||||||
<b className="form-section__title">打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
|
|
||||||
{multiFactor && (
|
|
||||||
<Btn
|
|
||||||
icon="layers"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })}
|
|
||||||
>
|
|
||||||
添加因子
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="factor-rows">
|
|
||||||
<div className="factor-rows__head" aria-hidden="true">
|
|
||||||
<span>因子</span>
|
|
||||||
<span>权重</span>
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
{p.factors.map((f, i) => (
|
|
||||||
<div className="factor-row" key={i}>
|
|
||||||
<select
|
|
||||||
id={`${fid}-name-${i}`}
|
|
||||||
className="input"
|
|
||||||
aria-label={`第 ${i + 1} 个因子的名称`}
|
|
||||||
value={f.name}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => setFactor(i, { name: e.target.value })}
|
|
||||||
>
|
|
||||||
{!factorOptions.some((o) => o.name === f.name) && f.name ? (
|
|
||||||
<option value={f.name}>{f.name}(未在因子表)</option>
|
|
||||||
) : null}
|
|
||||||
{factorOptions.map((o) => (
|
|
||||||
<option key={o.name} value={o.name}>
|
|
||||||
{o.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
id={`${fid}-weight-${i}`}
|
|
||||||
className="input mono"
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="0.1"
|
|
||||||
min={0}
|
|
||||||
aria-label={`第 ${i + 1} 个因子的权重`}
|
|
||||||
title="权重:越大表示该因子在打分中越重要(1 = 等权)"
|
|
||||||
value={f.weight}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => setFactor(i, { weight: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
{multiFactor && p.factors.length > 1 ? (
|
|
||||||
/* 与同行的下拉/输入同为 md 高度:行内控件必须等高,否则整行看起来是斜的 */
|
|
||||||
<Btn
|
|
||||||
icon="x"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={`删除第 ${i + 1} 个因子`}
|
|
||||||
title="删除该因子"
|
|
||||||
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="hint" style={{ marginTop: 6 }}>
|
|
||||||
权重 1 = 等权;只想用单个因子时把其它因子删掉即可。多个因子会先各自做横截面
|
|
||||||
z-score 标准化再加权,避免量纲不同互相压制。
|
|
||||||
</div>
|
|
||||||
{errors.factors && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{errors.factors}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ---------- 选股规模与周期 ---------- */}
|
|
||||||
<div className="form-grid" style={{ marginTop: 12 }}>
|
|
||||||
<Field
|
|
||||||
label="候选池 n(择股条件选出股数)"
|
|
||||||
hint="范围 1~500;n 越大越分散,越小越集中"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className={`input${errors.topN ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={1}
|
|
||||||
max={500}
|
|
||||||
value={p.topN}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ topN: Number(e.target.value) })}
|
|
||||||
onBlur={blur("topN")}
|
|
||||||
/>
|
|
||||||
{showErr("topN") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("topN")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label="持仓数 x(≤ n)" hint="最终实际持有只数,通常等于 n">
|
|
||||||
<input
|
|
||||||
className={`input${errors.holdX ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={1}
|
|
||||||
max={p.topN}
|
|
||||||
value={p.holdX}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ holdX: Number(e.target.value) })}
|
|
||||||
onBlur={blur("holdX")}
|
|
||||||
/>
|
|
||||||
{showErr("holdX") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("holdX")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label="择股间隔 m(月)" hint="多久重新挑一次股;0 = 跟随 y">
|
|
||||||
<input
|
|
||||||
className={`input${errors.mMonths ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
value={p.mMonths}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ mMonths: Number(e.target.value) })}
|
|
||||||
onBlur={blur("mMonths")}
|
|
||||||
/>
|
|
||||||
{showErr("mMonths") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("mMonths")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label="调仓间隔 y(月)" hint="多久按最新选股结果换一次仓;0 = 跟随 m">
|
|
||||||
<input
|
|
||||||
className={`input${errors.yMonths ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
max={60}
|
|
||||||
value={p.yMonths}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ yMonths: Number(e.target.value) })}
|
|
||||||
onBlur={blur("yMonths")}
|
|
||||||
/>
|
|
||||||
{showErr("yMonths") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("yMonths")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label="调仓频率" hint="仅当 m、y 都为 0 时生效(否则由 m/y 决定)">
|
|
||||||
<select
|
|
||||||
className="input"
|
|
||||||
value={p.rebalance}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ rebalance: e.target.value as "monthly" | "weekly" })}
|
|
||||||
>
|
|
||||||
<option value="monthly">月度</option>
|
|
||||||
<option value="weekly">周度</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label="买不进时的补位(涨停 / 停牌)"
|
|
||||||
hint="后端两个字段互斥,这里用三选一表达,避免出现「都不生效」的静默组合"
|
|
||||||
className="field--wide"
|
|
||||||
>
|
|
||||||
<div className="radio-col">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
["substitute", "换一只买", "从候选池之外按复合分往下找可买标的,补足持仓数"],
|
|
||||||
["defer", "顺延买入", "等它到之后首个不涨停的交易日再按收盘价买入(到下次调仓仍未成交则作废)"],
|
|
||||||
["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"],
|
|
||||||
] as const
|
|
||||||
).map(([val, label, desc]) => (
|
|
||||||
<label key={val} className="radio-row">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
/* 同一页面可能挂两份表单:name 必须带表单实例前缀,
|
|
||||||
否则两边的单选会互相取消选中(原生 radio 按 name 分组) */
|
|
||||||
name={`${fid}-fill-policy`}
|
|
||||||
checked={p.fillPolicy === val}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={() => set({ fillPolicy: val })}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<b>{label}</b>
|
|
||||||
<span className="hint"> {desc}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------- 口径与成本 ---------- */}
|
|
||||||
<div className="form-grid" style={{ marginTop: 12 }}>
|
|
||||||
<Field label="复权口径" hint="股息类策略建议后复权 hfq(把分红再投资计入)">
|
|
||||||
<select
|
|
||||||
className="input"
|
|
||||||
value={p.priceAdjustment}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ priceAdjustment: e.target.value as "none" | "qfq" | "hfq" })}
|
|
||||||
>
|
|
||||||
<option value="hfq">后复权 hfq</option>
|
|
||||||
<option value="qfq">前复权 qfq</option>
|
|
||||||
<option value="none">不复权 none</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label="标的范围" hint="ST 按择股日当时的股票名称判定">
|
|
||||||
<label className="check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={p.excludeSt}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ excludeSt: e.target.checked })}
|
|
||||||
/>
|
|
||||||
剔除 ST
|
|
||||||
</label>
|
|
||||||
</Field>
|
|
||||||
<Field label="最少上市天数" hint="避免次新股噪声;0 = 不限制">
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
step={10}
|
|
||||||
value={p.minListingDays}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ minListingDays: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label="手续费率 %"
|
|
||||||
hint="买卖双向收取;A 股常见 0.025%~0.03%"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className={`input${errors.costs ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="0.01"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
value={p.commission}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ commission: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="印花税率 %" hint="仅卖出收取;A 股 0.05%(2023 年 8 月起减半)">
|
|
||||||
<input
|
|
||||||
className={`input${errors.costs ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="0.01"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
value={p.stamp}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ stamp: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="滑点率 %" hint="按成交价的比例估计冲击成本;0.05%~0.1% 较常见">
|
|
||||||
<input
|
|
||||||
className={`input${errors.costs ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="0.01"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
value={p.slippage}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ slippage: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="最低佣金(元/笔)" hint="按笔收取的下限,如 5 元">
|
|
||||||
<input
|
|
||||||
className={`input${errors.minCommission ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
step="1"
|
|
||||||
min={0}
|
|
||||||
value={p.minCommission}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ minCommission: Number(e.target.value) })}
|
|
||||||
onBlur={blur("minCommission")}
|
|
||||||
/>
|
|
||||||
{showErr("minCommission") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("minCommission")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
{/* 费率类错误的行内落点:三个费率共用一个错误键(费率不能为负) */}
|
|
||||||
{errors.costs && (
|
|
||||||
<div className="field-err" role="alert" style={{ gridColumn: "1 / -1" }}>
|
|
||||||
{errors.costs}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showPeriod && (
|
|
||||||
<>
|
|
||||||
<Field label="初始资金(元)" hint="回测初始本金,默认 100 万">
|
|
||||||
<input
|
|
||||||
className={`input${errors.capital ? " input--invalid" : ""}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
step={100000}
|
|
||||||
min={10000}
|
|
||||||
value={p.capital}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ capital: Number(e.target.value) })}
|
|
||||||
onBlur={blur("capital")}
|
|
||||||
/>
|
|
||||||
{showErr("capital") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("capital")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label="开始日期"
|
|
||||||
hint="回测起始日(含)"
|
|
||||||
className={showErr("period") ? "field--invalid" : undefined}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className={`input${showErr("period") ? " input--invalid" : ""}`}
|
|
||||||
value={p.start}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ start: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label="结束日期"
|
|
||||||
hint="回测结束日(含)"
|
|
||||||
className={showErr("period") ? "field--invalid" : undefined}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className={`input${showErr("period") ? " input--invalid" : ""}`}
|
|
||||||
value={p.end}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => set({ end: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showErr("period") && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{showErr("period")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ---------- 选股过滤条件 ---------- */}
|
|
||||||
<div style={{ marginTop: 14 }}>
|
|
||||||
<div className="between" style={{ marginBottom: 8 }}>
|
|
||||||
<b className="form-section__title">
|
|
||||||
选股过滤条件(AND,universe 之后、因子排序之前执行)
|
|
||||||
</b>
|
|
||||||
<Btn
|
|
||||||
icon="layers"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
|
|
||||||
>
|
|
||||||
添加条件
|
|
||||||
</Btn>
|
|
||||||
</div>
|
|
||||||
{p.conditions.length === 0 ? (
|
|
||||||
<div className="hint">未设置条件:候选池 = universe 内因子分最高的 n 只。</div>
|
|
||||||
) : (
|
|
||||||
<div className="cond-rows">
|
|
||||||
<div className="cond-rows__head" aria-hidden="true">
|
|
||||||
<span>字段</span>
|
|
||||||
<span>比较</span>
|
|
||||||
<span>取值</span>
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
{p.conditions.map((c, i) => (
|
|
||||||
<div className="cond-row" key={i}>
|
|
||||||
<input
|
|
||||||
className="input mono"
|
|
||||||
list={`${fid}-cond-fields`}
|
|
||||||
aria-label={`第 ${i + 1} 个条件的字段名`}
|
|
||||||
placeholder="字段(dv_ratio / pe / ma60 …)"
|
|
||||||
value={c.field}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
set({
|
|
||||||
conditions: p.conditions.map((x, j) =>
|
|
||||||
j === i ? { ...x, field: e.target.value } : x
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="input"
|
|
||||||
aria-label={`第 ${i + 1} 个条件的比较符`}
|
|
||||||
value={c.op}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
set({
|
|
||||||
conditions: p.conditions.map((x, j) =>
|
|
||||||
j === i ? { ...x, op: e.target.value as Op } : x
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{OPS.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
className="input mono"
|
|
||||||
aria-label={`第 ${i + 1} 个条件的取值`}
|
|
||||||
inputMode="decimal"
|
|
||||||
placeholder="数值或字段名"
|
|
||||||
value={String(c.value ?? "")}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => {
|
|
||||||
const raw = e.target.value;
|
|
||||||
const num = Number(raw);
|
|
||||||
set({
|
|
||||||
conditions: p.conditions.map((x, j) =>
|
|
||||||
j === i
|
|
||||||
? { ...x, value: raw !== "" && !Number.isNaN(num) ? num : raw }
|
|
||||||
: x
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Btn
|
|
||||||
icon="x"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={`删除第 ${i + 1} 个条件`}
|
|
||||||
title="删除该条件"
|
|
||||||
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{errors.conditions && (
|
|
||||||
<div className="field-err" role="alert">
|
|
||||||
{errors.conditions}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="hint" style={{ marginTop: 6 }}>
|
|
||||||
可用字段:每日指标 dv_ratio / dv_ttm / pe / pb / total_mv、行情 close / volume /
|
|
||||||
amount、技术 ma20 / ma60、已注册因子名、static.industry 等、fundamental.roe 等
|
|
||||||
(财务按公告日 ≤ 择股日取用)。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,9 +29,11 @@ const GROUPS: { title: string; items: NavItem[] }[] = [
|
|||||||
{
|
{
|
||||||
title: "策略",
|
title: "策略",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/strategies", label: "策略库", icon: "book" },
|
{ href: "/strategies", label: "选股策略库", icon: "book" },
|
||||||
{ href: "/backtest", label: "选股回测", icon: "gauge" },
|
{ href: "/fields", label: "字段库", icon: "filter" },
|
||||||
|
{ href: "/backtest", label: "回测组合", icon: "gauge" },
|
||||||
{ href: "/experiments", label: "实验对比", icon: "archive" },
|
{ href: "/experiments", label: "实验对比", icon: "archive" },
|
||||||
|
{ href: "/settings", label: "公共配置", icon: "database" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
|||||||
return (await resp.json()) as T;
|
return (await resp.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH:局部更新(如「启用/停用因子」只改 enabled,不该把整行 PUT 回去)。
|
||||||
|
*
|
||||||
|
* 为什么单列:因子参数化新增了 `PATCH /api/factors {name, enabled}` ——
|
||||||
|
* 名字里有括号/等号/逗号,放路径会被代理折腾,所以放在 body 里用 PATCH。
|
||||||
|
*/
|
||||||
|
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const resp = await fetch(`${BASE}${path}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`PATCH ${path} → ${resp.status}: ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiDelete<T = { deleted?: string }>(path: string): Promise<T> {
|
export async function apiDelete<T = { deleted?: string }>(path: string): Promise<T> {
|
||||||
const resp = await fetch(`${BASE}${path}`, { method: "DELETE" });
|
const resp = await fetch(`${BASE}${path}`, { method: "DELETE" });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 因子目录在界面上的共用小工具(参数化之后才需要)。
|
||||||
|
*
|
||||||
|
* 为什么单独一个文件:因子的「名字」在参数化之后变成了带参数的引擎键
|
||||||
|
* (`momentum(window=90,direction=lower_is_better)`)。它**不能**直接当界面文字用 ——
|
||||||
|
* 太长、挤爆布局、也没人想读 `direction=higher_is_better`。而「哪些因子能被选」的规则
|
||||||
|
* (停用的不出现、算不出来的不出现)必须各处一致,否则「停用」在某个页面就成了假开关。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FactorMeta } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可以被**选中/引用**的因子。
|
||||||
|
*
|
||||||
|
* - `enabled === false`:在目录里停了用 —— 只是不出现在选择列表里,既有策略/归档仍按名字解析。
|
||||||
|
* - `resolvable === false`:引擎算不出来(历史手工登记行),选了也只会报错,不该摆给人点。
|
||||||
|
*
|
||||||
|
* 注意:这**不是**「能不能解析」的判断(那是引擎的事),只是界面候选集的过滤。
|
||||||
|
*/
|
||||||
|
export function pickableFactors(list: FactorMeta[]): FactorMeta[] {
|
||||||
|
return list.filter((f) => f.enabled !== false && f.resolvable !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 界面显示名:优先后端给的中文名(含参数),没有才退回引擎键。 */
|
||||||
|
export function factorLabel(f: FactorMeta | undefined, fallback = ""): string {
|
||||||
|
return f?.label || f?.name || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参数化因子的「短键」:只用于展示。
|
||||||
|
*
|
||||||
|
* 参数化因子的名字把参数写全了,直接放进下拉会很长;而 direction 在别处(提示里)已经
|
||||||
|
* 说成「越高越好 / 越低越好」,展示层去掉它不丢信息。**下拉的 value 始终是完整键**
|
||||||
|
* (引擎身份),只有显示文字被缩短。非参数化名(`momentum_60`)原样返回。
|
||||||
|
*/
|
||||||
|
export function shortFactorKey(name: string): string {
|
||||||
|
const m = /^([A-Za-z_][A-Za-z0-9_]*)\(([^()]*)\)$/.exec(name);
|
||||||
|
if (!m) return name;
|
||||||
|
const kept = m[2]
|
||||||
|
.split(",")
|
||||||
|
.filter((part) => !part.trim().startsWith("direction="))
|
||||||
|
.join(",");
|
||||||
|
return kept ? `${m[1]}(${kept})` : m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下拉/列表里的一行文字:中文名(含参数)· 引擎键。藏着名字就等于藏着身份。 */
|
||||||
|
export function factorOptionLabel(f: FactorMeta): string {
|
||||||
|
const d = (f.label || f.description).trim();
|
||||||
|
const head = d.length > 30 ? `${d.slice(0, 30)}…` : d || f.name;
|
||||||
|
return `${head} · ${shortFactorKey(f.name)}`;
|
||||||
|
}
|
||||||
@@ -74,3 +74,19 @@ export const STAGE_LABEL: Record<string, string> = {
|
|||||||
analysis: "汇总指标与曲线",
|
analysis: "汇总指标与曲线",
|
||||||
done: "完成",
|
done: "完成",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
|
||||||
|
export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
|
||||||
|
return apiPost<JobSubmit>("/combos/run", combo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行**已保存**的回测组合(POST /api/combos/{id}/run)。
|
||||||
|
*
|
||||||
|
* 与 submitComboJob 的区别:这里用库里的那份参数,页面上未保存的改动不参与 ——
|
||||||
|
* 「从组合库直接运行」必须跑库里存的那套,否则用户改了一半的表单会污染既有组合的结果。
|
||||||
|
*/
|
||||||
|
export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
|
||||||
|
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 策略说明的获取钩子。
|
* 策略说明的获取钩子(2026-09 重构后简化)。
|
||||||
*
|
*
|
||||||
* 说明/公式由后端 `describe_strategy` 从 spec **真实推导**(不是前端拼字符串):
|
* 说明/公式由后端 `describe_strategy` 从 spec **真实推导**(不是前端拼字符串):
|
||||||
* 这样「页面显示的公式」与「引擎实际执行的规则」只有一个来源,
|
* 「页面显示的公式」与「引擎实际执行的规则」只有一个来源,不会文案与实现漂移。
|
||||||
* 不会出现文案与实现漂移(本平台最怕的问题)。
|
|
||||||
*
|
*
|
||||||
* 两个入口:
|
* 重构后选股策略只含选股条件,说明走 `GET /strategies/{id}/describe`(需要已保存的 id);
|
||||||
* - `useStrategyDoc(params)`:未保存的参数也能实时预览(POST /strategies/describe),
|
* 未保存参数的实时预览不再有意义(选股策略没有可即时预览的回测公式),故移除。
|
||||||
* 带去抖,避免每次按键都请求。
|
|
||||||
* - `useStrategyDocById(id)`:已保存策略(GET /strategies/{id}/describe)。
|
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { apiGet, apiPost } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import type { StrategyDoc } from "@/lib/types";
|
import type { StrategyDoc } from "@/lib/types";
|
||||||
import { paramsToSpec, type StrategyParams } from "@/components/StrategyParamsForm";
|
|
||||||
|
|
||||||
export interface DocState {
|
export interface DocState {
|
||||||
doc: StrategyDoc | null;
|
doc: StrategyDoc | null;
|
||||||
@@ -26,54 +22,7 @@ export interface DocState {
|
|||||||
|
|
||||||
const EMPTY: DocState = { doc: null, loading: false, error: "" };
|
const EMPTY: DocState = { doc: null, loading: false, error: "" };
|
||||||
|
|
||||||
/** 未保存参数 → 说明(去抖 500ms) */
|
/** 已保存选股策略 → 说明(后端按选股条件推导) */
|
||||||
export function useStrategyDoc(params: StrategyParams | null, enabled = true): DocState {
|
|
||||||
const [state, setState] = useState<DocState>(EMPTY);
|
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
// 只依赖会改变说明的字段,避免改「初始资金」也重新请求
|
|
||||||
const key = useMemo(() => {
|
|
||||||
if (!params) return "";
|
|
||||||
return JSON.stringify({
|
|
||||||
f: params.factors,
|
|
||||||
a: params.priceAdjustment,
|
|
||||||
n: params.topN,
|
|
||||||
x: params.holdX,
|
|
||||||
m: params.mMonths,
|
|
||||||
y: params.yMonths,
|
|
||||||
r: params.rebalance,
|
|
||||||
fp: params.fillPolicy,
|
|
||||||
c: params.conditions,
|
|
||||||
s: params.excludeSt,
|
|
||||||
l: params.minListingDays,
|
|
||||||
cost: [params.commission, params.stamp, params.slippage, params.minCommission],
|
|
||||||
});
|
|
||||||
}, [params]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!params || !enabled || !key) {
|
|
||||||
setState(EMPTY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let alive = true;
|
|
||||||
if (timer.current) clearTimeout(timer.current);
|
|
||||||
setState((s) => ({ ...s, loading: true }));
|
|
||||||
timer.current = setTimeout(() => {
|
|
||||||
apiPost<StrategyDoc>("/strategies/describe", paramsToSpec(params))
|
|
||||||
.then((doc) => alive && setState({ doc, loading: false, error: "" }))
|
|
||||||
.catch((e: Error) => alive && setState({ doc: null, loading: false, error: e.message }));
|
|
||||||
}, 500);
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
if (timer.current) clearTimeout(timer.current);
|
|
||||||
};
|
|
||||||
// key 已覆盖所有影响说明的字段
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [key, enabled]);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 已保存策略 → 说明 */
|
|
||||||
export function useStrategyDocById(id: string | null): DocState {
|
export function useStrategyDocById(id: string | null): DocState {
|
||||||
const [state, setState] = useState<DocState>(EMPTY);
|
const [state, setState] = useState<DocState>(EMPTY);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,4 +40,4 @@ export function useStrategyDocById(id: string | null): DocState {
|
|||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-25
@@ -12,6 +12,26 @@ export interface Stock {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 因子的一个**可编辑参数**的约束(/api/factors 的 param_specs;与后端 ParamSpec 对齐)。 */
|
||||||
|
export interface FactorParam {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
/** "int":整数,按 minimum/maximum 受控;"enum":只能取 choices 之一。 */
|
||||||
|
kind: "int" | "enum";
|
||||||
|
default: number | string;
|
||||||
|
minimum?: number | null;
|
||||||
|
maximum?: number | null;
|
||||||
|
choices?: string[];
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 因子目录条目(/api/factors)。
|
||||||
|
*
|
||||||
|
* 参数化的关键:因子实例的名字里带着全部参数
|
||||||
|
* (如 `momentum(window=90,direction=higher_is_better)`),所以**名字就是身份** ——
|
||||||
|
* 策略/归档存下名字就冻结了参数,改参数只会产生新名字,历史不会变义。
|
||||||
|
*/
|
||||||
export interface FactorMeta {
|
export interface FactorMeta {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -20,6 +40,38 @@ export interface FactorMeta {
|
|||||||
frequency: string;
|
frequency: string;
|
||||||
lookback: number;
|
lookback: number;
|
||||||
direction: "higher_is_better" | "lower_is_better";
|
direction: "higher_is_better" | "lower_is_better";
|
||||||
|
/** 该因子消费的数据列(引擎口径):策略表单据此提示「需要 dv_ratio」这类依赖。 */
|
||||||
|
requires?: string[];
|
||||||
|
/** 中文显示名(含参数),如「动量(窗口 90,越高越好)」;界面优先用它。 */
|
||||||
|
label?: string;
|
||||||
|
/** 模板名("momentum" / "volatility"…);老式手登记因子为空串。 */
|
||||||
|
template?: string;
|
||||||
|
/** 该实例冻结的参数取值,如 `{window: 90, direction: "higher_is_better"}`。 */
|
||||||
|
params?: Record<string, number | string>;
|
||||||
|
/** 可编辑参数与允许范围(来自模板):界面据此渲染受控表单。 */
|
||||||
|
param_specs?: FactorParam[];
|
||||||
|
/** builtin = 代码注册表实例;custom = 目录里创建的参数化实例。 */
|
||||||
|
source?: "builtin" | "custom";
|
||||||
|
/** 是否出现在因子的选择列表里(停用只影响「能否被选中」)。 */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** 引擎是否算得出来;false = 历史手登记行,引用时会报错。 */
|
||||||
|
resolvable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 因子模板(/api/factors/templates):新建参数化因子时的可编辑参数与默认值。 */
|
||||||
|
export interface FactorTemplate {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
formula: string;
|
||||||
|
brief: string;
|
||||||
|
requires: string[];
|
||||||
|
frequency: string;
|
||||||
|
direction_default: "higher_is_better" | "lower_is_better";
|
||||||
|
param_specs: FactorParam[];
|
||||||
|
defaults: Record<string, number | string>;
|
||||||
|
/** 该模板已有的内置实例名(如 momentum_60),供「目录里已有哪些」提示。 */
|
||||||
|
instances: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResearchCondition {
|
export interface ResearchCondition {
|
||||||
@@ -29,6 +81,47 @@ export interface ResearchCondition {
|
|||||||
ref?: string | null;
|
ref?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段库条目(/api/condition-fields)。name 是引擎字段名(写进 condition.field),
|
||||||
|
* label/description 是给人看的;ops 由后端按 kind 给出,避免前端自己猜比较符。
|
||||||
|
*/
|
||||||
|
/** 一个可选的**界面单位**及它到**基准单位**的换算系数(提交前 ×factor,回显时 ÷factor)。 */
|
||||||
|
export interface UnitOption {
|
||||||
|
unit: string;
|
||||||
|
factor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConditionField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
kind: "num" | "str";
|
||||||
|
group_name: string;
|
||||||
|
/** 当前**界面单位**(输入/显示用,可从 units 里选)。 */
|
||||||
|
unit: string;
|
||||||
|
/** **基准单位**:引擎存储与比较用的单位,不可改(注册表口径)。 */
|
||||||
|
base_unit?: string;
|
||||||
|
/** 可选界面单位(首项 = 基准单位、factor=1;只有一个时界面不给选择)。 */
|
||||||
|
units?: UnitOption[];
|
||||||
|
source: "builtin" | "custom";
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
ops: ("gt" | "gte" | "lt" | "lte" | "eq" | "ne" | "in" | "not_in")[];
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「新增字段」的可选项(引擎支持但尚未进库)。 */
|
||||||
|
export interface ConditionFieldOption {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
kind: "num" | "str";
|
||||||
|
group_name: string;
|
||||||
|
unit: string;
|
||||||
|
units?: UnitOption[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResearchSpec {
|
export interface ResearchSpec {
|
||||||
type: "factor_test" | "backtest";
|
type: "factor_test" | "backtest";
|
||||||
universe: {
|
universe: {
|
||||||
@@ -305,39 +398,60 @@ export interface ChartResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---- 策略库(M8.3,与 domain/entities/strategy.py 对应) ---- */
|
/* ---- 选股策略 / 公共配置 / 回测组合(2026-09 重构,与 domain/entities/{strategy,combo}.py 对应) ---- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 命名策略:完整策略定义(universe + factor + selection + rebalance + costs + portfolio),
|
* 选股策略:策略库现在**只存选股条件组合**(股票池 + 因子 + 过滤条件)。
|
||||||
* 不含回测区间 period 与初始资金 —— 回测时补全后展开为 ResearchSpec。
|
* 资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间一律移到「回测组合」与「公共配置」。
|
||||||
* 后端以 JSON 整体持久化,因此新增字段无需迁移即可保存。
|
* (旧名 StrategyDefinition 仍作为别名导出,便于过渡期引用。)
|
||||||
*/
|
*/
|
||||||
export interface StrategyDefinition {
|
export interface SelectionStrategy {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
/** 一句话说明(必填):说清这个策略做什么。为空时后端会用 describe_strategy 自动填充 */
|
/** 一句话说明(必填):说清怎么选。为空时后端用 describe_strategy 自动填充 */
|
||||||
description: string;
|
description: string;
|
||||||
spec_type?: "backtest" | "factor_test";
|
spec_type?: "selection" | "backtest";
|
||||||
universe?: { exclude_st?: boolean; min_listing_days?: number; symbols?: string[] };
|
universe?: {
|
||||||
price_adjustment?: "none" | "qfq" | "hfq";
|
market?: string;
|
||||||
|
exclude_st?: boolean;
|
||||||
|
exclude_suspended?: boolean;
|
||||||
|
min_listing_days?: number;
|
||||||
|
index_code?: string | null;
|
||||||
|
symbols?: string[];
|
||||||
|
};
|
||||||
factors: { name: string; weight: number }[];
|
factors: { name: string; weight: number }[];
|
||||||
selection?: {
|
|
||||||
top_n?: number;
|
|
||||||
hold_top_x?: number | null;
|
|
||||||
allow_substitute?: boolean;
|
|
||||||
defer_buy?: boolean;
|
|
||||||
};
|
|
||||||
rebalance?: "weekly" | "monthly";
|
|
||||||
conditions?: ResearchCondition[];
|
conditions?: ResearchCondition[];
|
||||||
selection_interval_months?: number | null;
|
version?: string;
|
||||||
rebalance_interval_months?: number | null;
|
created_at?: string | null;
|
||||||
costs?: {
|
}
|
||||||
commission_rate?: number;
|
|
||||||
stamp_tax_rate?: number;
|
/** 兼容别名:重构前的名字。新代码请用 SelectionStrategy。 */
|
||||||
slippage_rate?: number;
|
export type StrategyDefinition = SelectionStrategy;
|
||||||
min_commission?: number;
|
|
||||||
};
|
/** 公共配置(全局唯一一份):费率 / 滑点 / 最低佣金 / 复权口径 / 基准。 */
|
||||||
portfolio?: Record<string, unknown>;
|
export interface GlobalConfig {
|
||||||
|
id?: string;
|
||||||
|
commission_rate: number; // 小数,如 0.0003 = 万三
|
||||||
|
stamp_tax_rate: number;
|
||||||
|
slippage_rate: number;
|
||||||
|
min_commission: number; // 元/笔
|
||||||
|
price_adjustment: "none" | "qfq" | "hfq";
|
||||||
|
benchmark: string;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回测组合:引用若干选股策略 + 回测参数(费率/复权来自公共配置,运行时快照进归档)。 */
|
||||||
|
export interface BacktestCombo {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
strategy_ids: string[];
|
||||||
|
initial_capital: number;
|
||||||
|
hold_count: number; // 目标持仓只数 N
|
||||||
|
hold_min_days: number; // Tmin
|
||||||
|
hold_max_days: number | null; // Tmax;null = 不强制了结
|
||||||
|
rebalance_freq: "daily" | "weekly" | "monthly";
|
||||||
|
period: [string, string];
|
||||||
version?: string;
|
version?: string;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 单位换算(界面层)——「字段库单位」这件事唯一的一处实现。
|
||||||
|
*
|
||||||
|
* 背景(2026-10):字段库里的单位分两层,写错任何一层都会让策略静默算错:
|
||||||
|
*
|
||||||
|
* - **基准单位**(`ConditionField.base_unit`):引擎存储与比较用的单位,由数据源落库口径
|
||||||
|
* 决定(总市值=万元、成交额=元、成交量=股),**不可改**。策略 JSON、归档里的
|
||||||
|
* ConditionSpec、引擎求值全部只用基准单位 —— 所以归档永远复现得出来。
|
||||||
|
* - **界面单位**(`ConditionField.unit`):只在输入/显示这一层用的单位,用户可以在字段库里
|
||||||
|
* 从 `units` 给定的阶梯里选(万元 ⇄ 亿元)。提交前 ×factor,回显时 ÷factor。
|
||||||
|
*
|
||||||
|
* 这样「把总市值改成亿元」会立刻生效(输入 5 就是 5 亿元),但不会改动任何已存策略的
|
||||||
|
* 含义 —— 库里存的仍是 50000 万元。
|
||||||
|
*/
|
||||||
|
import type { ConditionField, UnitOption } from "@/lib/types";
|
||||||
|
|
||||||
|
/** 该字段当前界面单位 → 基准单位的换算系数(没有备选单位 → 1)。 */
|
||||||
|
export function unitScale(meta: ConditionField | undefined | null): number {
|
||||||
|
if (!meta?.units?.length || meta.units.length < 2) return 1;
|
||||||
|
return meta.units.find((u) => u.unit === meta.unit)?.factor ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基准单位值 → 界面单位值(除以系数)。 */
|
||||||
|
export function fromBase(v: number, scale: number): number {
|
||||||
|
return !scale || scale === 1 ? v : Number((v / scale).toPrecision(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 界面单位值 → 基准单位值(乘以系数)。 */
|
||||||
|
export function toBase(v: number, scale: number): number {
|
||||||
|
return !scale || scale === 1 ? v : Number((v * scale).toPrecision(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基准单位(引擎口径);字段不在库里时为空。 */
|
||||||
|
export function baseUnitOf(meta: ConditionField | undefined | null): string {
|
||||||
|
return meta?.base_unit || meta?.unit || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 界面上该显示的单位后缀:只有真的能换算(有备选且当前不是基准)时才显示。 */
|
||||||
|
export function displayUnitOf(meta: ConditionField | undefined | null): string {
|
||||||
|
return unitScale(meta) === 1 ? "" : (meta?.unit ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单位阶梯的说明文字,如「1 亿元 = 10000 万元」。 */
|
||||||
|
export function unitNote(meta: ConditionField | undefined | null): string {
|
||||||
|
const scale = unitScale(meta);
|
||||||
|
if (scale === 1) return "";
|
||||||
|
return `1 ${meta?.unit} = ${scale} ${baseUnitOf(meta)}(引擎按基准单位比较)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某个单位在阶梯里的系数(找不到 → 1)。 */
|
||||||
|
export function factorOf(units: UnitOption[] | undefined, unit: string): number {
|
||||||
|
return units?.find((u) => u.unit === unit)?.factor ?? 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user