feat(factor-editor): 算子/字段面板与模板库 UX 迭代

- 算子区去重: 移除裸 chips 行, 速查表升级为唯一算子面板(默认展开, 挂载时置 open 避免与折叠态打架)
- 速查表重设计: 时序(13)/截面(3)/工具(9)三张分组卡片, 每行=完整签名+中文说明+参数约束, 点击整行插入公式光标处; 与 chips 共用 OPERATOR_GROUPS 单一数据源
- 折叠标题突出: 白色前景色+medium 字重+accent 箭头(展开旋转), 悬停变色
- 可用字段: 展开高度 160→256px; 分组名提亮加粗; 字段英文 id 蓝色(与算子签名同色), 中文保持灰色
- 模板库 5→12: 新增 低波动/量比回落/RSI超卖/低振幅/隔夜反转/距高点回落/换手稳定, 全部经 /api/factors/validate 预校验
- 校验/试算按钮居右(ml-auto)
- factor-platform-plan.md §5.2 模板计数同步
This commit is contained in:
shy3130
2026-09-05 18:19:34 +08:00
parent 2ff909cd36
commit b3e492d890
2 changed files with 74 additions and 46 deletions
+1 -1
View File
@@ -258,7 +258,7 @@ P0P3 已全部实现并通过验证(后端 98 项因子相关测试 + 全
2. 校验通过 → 展示编译产物:依赖列集合、warmup_bars(旧文 §3 规则 4)。
3. 试算:选近 40 交易日 + 当前资产,跑一次 Rank IC 快照(复用检验引擎计算路径,**不另写计算逻辑**);展示 IC 序列 sparkline 与均值。
4. 注册:状态 `draft`,写入注册表;后续在检验 tab 跑完整检验后转 `active`(§8 状态机)。编辑已有因子时公式变化保存为新版本并回草稿态。
5. 模板起点:「从模板开始 / 我的因子」下拉——经典模板(5 个范式)+ 我的因子(已保存自定义因子公式导入);替换非初始公式前弹确认。
5. 模板起点:「从模板开始 / 我的因子」下拉——经典模板(12 个范式, 覆盖反转/动量/波动/量价/换手/超卖/位置)+ 我的因子(已保存自定义因子公式导入);替换非初始公式前弹确认。
6. 点选插入:全部 25 个 DSL 算子按「时序(13)/截面(3)/工具(9)」分组,点击插入光标处(与后端 `dsl.OPERATORS` 同步维护),悬停显示签名与参数约束;79 个可用字段 chip 双语显示(英文 id 等宽 + 中文标签),点击插入。
### 5.3 约束(红线)
+73 -45
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CircleAlert, CircleCheck, FlaskConical, PenLine, Play, Save, ShieldQuestion } from 'lucide-react'
import { ChevronRight, CircleAlert, CircleCheck, FlaskConical, PenLine, Play, Save, ShieldQuestion } from 'lucide-react'
import { toast } from '@/components/Toast'
import { api, type FactorTrialResponse, type FactorValidateResponse } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -17,45 +17,53 @@ const TEMPLATES: { label: string; formula: string; note: string }[] = [
{ label: '波动变化', formula: 'ts_std(close / ts_delay(close, 1) - 1, 20)', note: '20 日滚动日收益波动' },
{ label: '换手异动', formula: 'ts_zscore(turnover_rate, 60)', note: '换手率相对自身 60 日分布的 z 分' },
{ label: '乖离组合', formula: 'zscore(close / ma20 - 1) - zscore(ts_mean(turnover_rate, 5))', note: '价格乖离与换手均值的截面差' },
{ label: '低波动', formula: 'rank(-ts_std(change_pct, 20))', note: '20 日日收益波动最低 (低波动异象)' },
{ label: '量比回落', formula: 'rank(-ts_zscore(vol_ratio_5d, 60))', note: '量比回落到自身 60 日低位' },
{ label: 'RSI超卖', formula: 'rank(-ts_mean(rsi_14, 5))', note: '近 5 日 RSI 均值最低的超卖标的' },
{ label: '低振幅', formula: 'rank(-ts_mean(amplitude, 20))', note: '20 日平均振幅最低 (缩量休整)' },
{ label: '隔夜反转', formula: 'rank(-overnight_ret_20d)', note: '20 日累计隔夜收益的反转' },
{ label: '距高点回落', formula: 'rank(-distance_to_high_60d)', note: '距 60 日高点回撤最深的标的' },
{ label: '换手稳定', formula: 'rank(-ts_std(turnover_rate, 20))', note: '换手率波动最低 (筹码稳定)' },
]
/** 后端 dsl.OPERATORS 的全部 25 个算子, 按家族分组; 点击插入, 悬停显示签名与约束。 */
const OPERATOR_GROUPS: { label: string; ops: { name: string; desc: string }[] }[] = [
/** 后端 dsl.OPERATORS 的全部 25 个算子, 按家族分组; name=chip 名, sig=完整签名, desc=中文说明。
* chips 面板负责点击插入, 速查表负责阅读 — 同一份数据两个视图。 */
const OPERATOR_GROUPS: { label: string; ops: { name: string; sig: string; desc: string }[] }[] = [
{
label: '时序', ops: [
{ name: 'ts_mean', desc: 'ts_mean(x,n) 滚动均值, n∈[2,512]' },
{ name: 'ts_sum', desc: 'ts_sum(x,n) 滚动求和, n∈[2,512]' },
{ name: 'ts_std', desc: 'ts_std(x,n) 滚动标准差, n∈[2,512]' },
{ name: 'ts_max', desc: 'ts_max(x,n) 滚动最大值, n∈[2,512]' },
{ name: 'ts_min', desc: 'ts_min(x,n) 滚动最小值, n∈[2,512]' },
{ name: 'ts_delay', desc: 'ts_delay(x,n) n 期前的值, n≤512 (禁止负数)' },
{ name: 'ts_delta', desc: 'ts_delta(x,n) n 期差分 (x n 期前的值)' },
{ name: 'ts_rank', desc: 'ts_rank(x,n) 滚动分位' },
{ name: 'ts_zscore', desc: 'ts_zscore(x,n) 滚动 z 分' },
{ name: 'ts_corr', desc: 'ts_corr(x,y,n) 滚动相关系数' },
{ name: 'ts_cov', desc: 'ts_cov(x,y,n) 滚动协方差' },
{ name: 'ts_quantile', desc: 'ts_quantile(x,n,q) 滚动分位, q∈(0,1)' },
{ name: 'decay_linear', desc: 'decay_linear(x,n) 线性衰减加权均值 (近端权重大)' },
{ name: 'ts_mean', sig: 'ts_mean(x,n)', desc: '滚动均值 · n∈[2,512]' },
{ name: 'ts_sum', sig: 'ts_sum(x,n)', desc: '滚动求和 · n∈[2,512]' },
{ name: 'ts_std', sig: 'ts_std(x,n)', desc: '滚动标准差 · n∈[2,512]' },
{ name: 'ts_max', sig: 'ts_max(x,n)', desc: '滚动最大值 · n∈[2,512]' },
{ name: 'ts_min', sig: 'ts_min(x,n)', desc: '滚动最小值 · n∈[2,512]' },
{ name: 'ts_delay', sig: 'ts_delay(x,n)', desc: 'n 期前的值 · n≤512' },
{ name: 'ts_delta', sig: 'ts_delta(x,n)', desc: 'n 期差分 · x n 期前' },
{ name: 'ts_rank', sig: 'ts_rank(x,n)', desc: '滚动分位' },
{ name: 'ts_zscore', sig: 'ts_zscore(x,n)', desc: '滚动 z 分' },
{ name: 'ts_corr', sig: 'ts_corr(x,y,n)', desc: '滚动相关系数' },
{ name: 'ts_cov', sig: 'ts_cov(x,y,n)', desc: '滚动协方差' },
{ name: 'ts_quantile', sig: 'ts_quantile(x,n,q)', desc: '滚动分位 · q∈(0,1)' },
{ name: 'decay_linear', sig: 'decay_linear(x,n)', desc: '线性衰减加权 · 近端权重大' },
],
},
{
label: '截面', ops: [
{ name: 'rank', desc: 'rank(x) 当日横截面百分位' },
{ name: 'zscore', desc: 'zscore(x) 当日横截面 z 分' },
{ name: 'winsorize', desc: 'winsorize(x,k) 截面截尾至 μ±kσ, k∈[1,6] 默认 3' },
{ name: 'rank', sig: 'rank(x)', desc: '当日横截面百分位' },
{ name: 'zscore', sig: 'zscore(x)', desc: '当日横截面 z 分' },
{ name: 'winsorize', sig: 'winsorize(x,k)', desc: '截面截尾至 μ±kσ · k∈[1,6] 默认 3' },
],
},
{
label: '工具', ops: [
{ name: 'if_else', desc: 'if_else(c,a,b) 条件选择' },
{ name: 'min', desc: 'min(a,b) 两值取小' },
{ name: 'max', desc: 'max(a,b) 两值取大' },
{ name: 'log', desc: 'log(x) 自然对数' },
{ name: 'abs', desc: 'abs(x) 绝对值' },
{ name: 'sign', desc: 'sign(x) 符号函数' },
{ name: 'sqrt', desc: 'sqrt(x) 平方根' },
{ name: 'power', desc: 'power(x,c) 幂运算, |c|≤4' },
{ name: 'clamp', desc: 'clamp(x,lo,hi) 截断到 [lo,hi]' },
{ name: 'if_else', sig: 'if_else(c,a,b)', desc: '条件选择' },
{ name: 'min', sig: 'min(a,b)', desc: '两值取小' },
{ name: 'max', sig: 'max(a,b)', desc: '两值取大' },
{ name: 'log', sig: 'log(x)', desc: '自然对数' },
{ name: 'abs', sig: 'abs(x)', desc: '绝对值' },
{ name: 'sign', sig: 'sign(x)', desc: '符号函数' },
{ name: 'sqrt', sig: 'sqrt(x)', desc: '平方根' },
{ name: 'power', sig: 'power(x,c)', desc: '幂运算 · |c|≤4' },
{ name: 'clamp', sig: 'clamp(x,lo,hi)', desc: '截断到 [lo,hi]' },
],
},
]
@@ -289,27 +297,47 @@ export function FactorEditor({ editId = '' }: { editId?: string }) {
{/* 算子/字段点选插入: 写公式不用背名字 */}
<div className="space-y-1.5">
<div>
<div className="mb-1 text-[10px] text-muted"> ({OPERATOR_COUNT}) · · </div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1" role="group" aria-label="插入算子">
<details
ref={el => { if (el) el.open = true }}
className="group rounded-btn border border-border/60 bg-base/40 px-2.5 py-1.5 text-[10px]"
>
<summary className="flex cursor-pointer select-none items-center gap-1 font-medium text-foreground transition-colors hover:text-accent [&::-webkit-details-marker]:hidden">
<ChevronRight className="h-3 w-3 shrink-0 text-accent transition-transform group-open:rotate-90" />
{OPERATOR_COUNT} · / /
<span className="font-normal text-muted">· </span>
</summary>
<div className="mt-1.5 grid grid-cols-1 gap-2 sm:grid-cols-2" role="group" aria-label="插入算子">
{OPERATOR_GROUPS.map(group => (
<div key={group.label} className="flex flex-wrap items-center gap-1">
<span className="shrink-0 text-[10px] text-muted">{group.label}</span>
{group.ops.map(op => (
<button key={op.name} type="button" title={op.desc} onClick={() => insertSnippet(`${op.name}(`)} className={CHIP_CLS}>
{op.name}
</button>
))}
<div key={group.label} className="rounded-btn border border-border/50 bg-base/30 px-2 py-1.5">
<div className="mb-1 font-medium text-secondary">{group.label} <span className="font-normal text-muted">({group.ops.length})</span></div>
<div className="space-y-0.5">
{group.ops.map(op => (
<button
key={op.name}
type="button"
title={`点击插入 ${op.sig}${op.desc}`}
onClick={() => insertSnippet(`${op.name}(`)}
className="flex w-full cursor-pointer items-baseline gap-2 rounded px-1 py-px text-left leading-4 transition-colors hover:bg-accent/10"
>
<code className="w-36 shrink-0 truncate font-mono text-accent">{op.sig}</code>
<span className="text-muted">{op.desc}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
<details className="rounded-btn border border-border bg-base/40 px-2.5 py-1.5 text-[10px] text-muted">
<summary className="cursor-pointer text-secondary"> ({columns.data?.columns.length ?? 0}) · </summary>
<div className="mt-1.5 max-h-40 space-y-1.5 overflow-y-auto">
<div className="mt-1.5 text-muted"> <code className="font-mono text-accent">+ - * /</code> · · </div>
</details>
<details className="group rounded-btn border border-border/60 bg-base/40 px-2.5 py-1.5 text-[10px]">
<summary className="flex cursor-pointer select-none items-center gap-1 font-medium text-foreground transition-colors hover:text-accent [&::-webkit-details-marker]:hidden">
<ChevronRight className="h-3 w-3 shrink-0 text-accent transition-transform group-open:rotate-90" />
({columns.data?.columns.length ?? 0}) ·
</summary>
<div className="mt-1.5 max-h-64 space-y-1.5 overflow-y-auto">
{fieldGroups.map(([group, cols]) => (
<div key={group}>
<div className="mb-0.5 text-[10px] text-muted">{group}</div>
<div className="mb-0.5 text-[10px] font-medium text-secondary">{group}</div>
<div className="flex flex-wrap gap-1">
{cols.map(col => (
<button
@@ -319,7 +347,7 @@ export function FactorEditor({ editId = '' }: { editId?: string }) {
onClick={() => insertSnippet(col.id)}
className={CHIP_CLS}
>
<span className="font-mono">{col.id}</span>
<span className="font-mono text-accent">{col.id}</span>
<span className="ml-1 font-sans text-muted">{col.label}</span>
</button>
))}
@@ -341,7 +369,7 @@ export function FactorEditor({ editId = '' }: { editId?: string }) {
type="button"
onClick={runBoth}
disabled={!formula.trim() || validate.isPending}
className="inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-btn border border-border bg-surface px-3 text-xs font-medium text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
className="ml-auto inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-btn border border-border bg-surface px-3 text-xs font-medium text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<ShieldQuestion className="h-3.5 w-3.5" />
{validate.isPending ? '校验中…' : '校验'}