mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(signals): 统一信号库与自定义信号弹窗,三处复用同一组件
- 设置页「自定义信号」升级为「信号库」:内置信号(只读) + 自定义信号分 Tab 展示 - 内置信号补充类型/分类/说明元数据 - 自定义信号配置改为通用弹窗组件 CustomSignalDialog - 抽出 SignalTriggerActions:新增信号 / 去信号库按钮 + 保存后自动加入触发器 - 回测高级设置、策略卡片配置弹窗均复用上述组件,去除重复逻辑 - 自定义信号删除改为二次确认(闪烁提示,3 秒自动复位) - 版本号 0.1.30 -> 0.1.31
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""TickFlow Stock Panel backend."""
|
||||
|
||||
__version__ = "0.1.28"
|
||||
__version__ = "0.1.31"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.30"
|
||||
version = "0.1.31"
|
||||
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.31",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
|
||||
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
||||
import { color } from '@/lib/colors'
|
||||
import { SignalPicker } from './SignalPicker'
|
||||
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
||||
|
||||
// 内置列名 → 中文标签
|
||||
const FIELD_LABEL: Record<string, string> = {}
|
||||
@@ -41,15 +42,17 @@ function Section({ icon: Icon, title, accent, defaultOpen = true, children, extr
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className="rounded-xl border border-border/15 bg-surface/20 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center gap-2 px-3.5 py-2 hover:bg-surface/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronDown className={`h-3 w-3 text-muted/40 transition-transform duration-200 ${open ? '' : '-rotate-90'}`} />
|
||||
{Icon && <Icon className={`h-3.5 w-3.5 ${accent ?? 'text-muted'}`} />}
|
||||
<span className="text-[11px] font-medium text-foreground/70">{title}</span>
|
||||
<div className="ml-auto">{extra}</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-3.5 py-2 hover:bg-surface/30 transition-colors">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left cursor-pointer"
|
||||
>
|
||||
<ChevronDown className={`h-3 w-3 text-muted/40 transition-transform duration-200 ${open ? '' : '-rotate-90'}`} />
|
||||
{Icon && <Icon className={`h-3.5 w-3.5 ${accent ?? 'text-muted'}`} />}
|
||||
<span className="text-[11px] font-medium text-foreground/70">{title}</span>
|
||||
</button>
|
||||
{extra && <div className="ml-auto flex items-center gap-1">{extra}</div>}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
@@ -69,6 +72,7 @@ function Section({ icon: Icon, title, accent, defaultOpen = true, children, extr
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ===== 区间字段(最小 ~ 最大) =====
|
||||
function RangeField({ label, minVal, maxVal, onMinChange, onMaxChange, unit, step }: {
|
||||
label: string
|
||||
@@ -491,12 +495,24 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section icon={TrendingUp} title="买入触发器" accent="text-accent" defaultOpen={false}>
|
||||
<Section
|
||||
icon={TrendingUp}
|
||||
title="买入触发器"
|
||||
accent="text-accent"
|
||||
defaultOpen={false}
|
||||
extra={<SignalTriggerActions kind="entry" signals={entrySignals} onChange={setEntrySignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
|
||||
>
|
||||
<SignalPicker signals={entrySignals} onChange={setEntrySignals} kind="entry" variant="dialog" />
|
||||
<div className="text-[10px] leading-4 text-muted/70">任一买点满足即进入候选。</div>
|
||||
</Section>
|
||||
|
||||
<Section icon={TrendingUp} title="卖出触发器" accent="text-warning" defaultOpen={false}>
|
||||
<Section
|
||||
icon={TrendingUp}
|
||||
title="卖出触发器"
|
||||
accent="text-warning"
|
||||
defaultOpen={false}
|
||||
extra={<SignalTriggerActions kind="exit" signals={exitSignals} onChange={setExitSignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
|
||||
>
|
||||
<SignalPicker signals={exitSignals} onChange={setExitSignals} kind="exit" variant="dialog" />
|
||||
<div className="text-[10px] leading-4 text-muted/70">任一卖点满足即触发卖出。</div>
|
||||
</Section>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowRight, Plus, Save, X } from 'lucide-react'
|
||||
import { api, type CustomSignal, type CustomSignalCondition } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
signal?: CustomSignal | null
|
||||
defaultKind?: CustomSignal['kind']
|
||||
onClose: () => void
|
||||
onSaved?: (signal: CustomSignal) => void
|
||||
}
|
||||
|
||||
const emptySignal = (kind: CustomSignal['kind'] = 'exit'): CustomSignal => ({
|
||||
id: '', name: '', kind, enabled: true,
|
||||
conditions: [{ left: 'close', op: '>', right: 'field:ma20' }],
|
||||
})
|
||||
|
||||
export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose, onSaved }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, enabled: open })
|
||||
|
||||
const [draft, setDraft] = useState<CustomSignal>(() => emptySignal(defaultKind))
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fields = options.data?.fields ?? []
|
||||
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
|
||||
const editing = !!signal
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDraft(signal ? { ...signal, conditions: signal.conditions.map(c => ({ ...c })) } : emptySignal(defaultKind))
|
||||
setError('')
|
||||
}, [open, signal, defaultKind])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const d = draft
|
||||
if (!d.id.trim()) throw new Error('请输入信号标识')
|
||||
if (!/^[a-z0-9_]{1,40}$/.test(d.id)) throw new Error('标识仅允许小写字母、数字、下划线(1-40字符)')
|
||||
if (!d.name.trim()) throw new Error('请输入信号名称')
|
||||
if (d.conditions.length === 0) throw new Error('至少需要一个条件')
|
||||
for (const c of d.conditions) {
|
||||
if (!c.left || !c.op || c.right === '') throw new Error('条件填写不完整')
|
||||
}
|
||||
return api.customSignalSave(d)
|
||||
},
|
||||
onSuccess: res => {
|
||||
qc.invalidateQueries({ queryKey: QK.customSignals })
|
||||
onSaved?.(res.signal)
|
||||
onClose()
|
||||
},
|
||||
onError: err => setError(String((err as any)?.message ?? err)),
|
||||
})
|
||||
|
||||
const updateCond = (idx: number, patch: Partial<CustomSignalCondition>) => {
|
||||
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
|
||||
}
|
||||
const addCond = () => setDraft(d => ({ ...d, conditions: [...d.conditions, { left: 'close', op: '>', right: '0' }] }))
|
||||
const removeCond = (idx: number) => setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">{editing ? '编辑自定义信号' : '新建自定义信号'}</h3>
|
||||
<p className="mt-1 text-[11px] text-muted">标识保存后不可修改,如需更换请新建。自定义信号保存为 csg_* 列。</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-muted transition-colors hover:bg-elevated hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">信号标识</span>
|
||||
<input
|
||||
value={draft.id}
|
||||
disabled={editing}
|
||||
onChange={e => setDraft(d => ({ ...d, id: e.target.value.replace(/[^a-z0-9_]/g, '') }))}
|
||||
placeholder="如 low_touches_ma5"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">信号名称</span>
|
||||
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如 跌至MA5" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">类型</span>
|
||||
<select value={draft.kind} onChange={e => setDraft(d => ({ ...d, kind: e.target.value as CustomSignal['kind'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
|
||||
<option value="entry">买入</option>
|
||||
<option value="exit">卖出</option>
|
||||
<option value="both">买卖通用</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted">条件(多条件为「且」关系)</span>
|
||||
<button onClick={addCond} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
|
||||
<Plus className="h-3 w-3" />添加条件
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-card border border-border/70 bg-base/50 p-3">
|
||||
{draft.conditions.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 ? '当' : '且'}</span>
|
||||
<select value={c.left} onChange={e => updateCond(i, { left: e.target.value })} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
|
||||
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
|
||||
</select>
|
||||
<select value={c.op} onChange={e => updateCond(i, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
|
||||
{operators.map(op => <option key={op} value={op}>{op}</option>)}
|
||||
</select>
|
||||
<RightValueInput cond={c} fields={fields} onChange={v => updateCond(i, { right: v })} />
|
||||
{draft.conditions.length > 1 && (
|
||||
<button onClick={() => removeCond(i)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border/50 px-5 py-4">
|
||||
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs">取消</button>
|
||||
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-amber-500/90 text-base text-xs font-medium disabled:opacity-50">
|
||||
<Save className="h-3.5 w-3.5" />保存
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function RightValueInput({ cond, fields, onChange }: { cond: CustomSignalCondition; fields: { key: string; label: string }[]; onChange: (v: string) => void }) {
|
||||
const isField = cond.right.startsWith('field:')
|
||||
const fieldValue = isField ? cond.right.slice(6) : ''
|
||||
const numValue = isField ? '' : cond.right
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{isField ? (
|
||||
<>
|
||||
<select value={fieldValue} onChange={e => onChange(`field:${e.target.value}`)} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
|
||||
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => onChange('0')} title="切换为数字" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
|
||||
<ArrowRight className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input type="number" value={numValue} onChange={e => onChange(e.target.value)} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
|
||||
<button onClick={() => onChange('field:close')} title="切换为字段" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
|
||||
<ArrowRight className="h-3 w-3 -rotate-90" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Settings2 } from 'lucide-react'
|
||||
import type { CustomSignal } from '@/lib/api'
|
||||
import { CustomSignalDialog } from './CustomSignalDialog'
|
||||
|
||||
interface Props {
|
||||
kind: 'entry' | 'exit'
|
||||
signals: string[]
|
||||
onChange: (next: string[]) => void
|
||||
buttonClassName?: string
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
export function SignalTriggerActions({ kind, signals, onChange, buttonClassName, iconClassName }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const accent = kind === 'entry' ? 'hover:text-accent hover:border-accent/40' : 'hover:text-warning hover:border-warning/40'
|
||||
const btnCls = buttonClassName ?? 'rounded-btn border border-border bg-base p-1 text-muted transition-colors cursor-pointer'
|
||||
const iconCls = iconClassName ?? 'h-3.5 w-3.5'
|
||||
|
||||
const handleSaved = (signal: CustomSignal) => {
|
||||
if (signal.kind !== kind && signal.kind !== 'both') return
|
||||
const signalId = `csg_${signal.id}`
|
||||
onChange(signals.includes(signalId) ? signals : [...signals, signalId])
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title="新增自定义信号"
|
||||
className={`${btnCls} ${accent}`}
|
||||
>
|
||||
<Plus className={iconCls} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings?tab=signals')}
|
||||
title="去信号库"
|
||||
className={`${btnCls} hover:border-amber-400/40 hover:text-amber-400`}
|
||||
>
|
||||
<Settings2 className={iconCls} />
|
||||
</button>
|
||||
|
||||
<CustomSignalDialog
|
||||
open={open}
|
||||
defaultKind={kind}
|
||||
onClose={() => setOpen(false)}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+131
-19
@@ -5,28 +5,140 @@
|
||||
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
|
||||
*/
|
||||
|
||||
/** 内置原子信号 → 中文标签 (权威来源, 两页统一) */
|
||||
export const SIGNAL_LABELS: Record<string, string> = {
|
||||
signal_ma_golden_5_20: 'MA5上穿MA20',
|
||||
signal_ma_dead_5_20: 'MA5下穿MA20',
|
||||
signal_ma_golden_20_60: 'MA20上穿MA60',
|
||||
signal_macd_golden: 'MACD金叉',
|
||||
signal_macd_dead: 'MACD死叉',
|
||||
signal_ma20_breakout: '突破MA20',
|
||||
signal_ma20_breakdown: '跌破MA20',
|
||||
signal_n_day_high: '60日新高',
|
||||
signal_n_day_low: '60日新低',
|
||||
signal_boll_breakout_upper: '突破布林上轨',
|
||||
signal_boll_breakdown_lower: '跌破布林下轨',
|
||||
signal_volume_surge: '放量',
|
||||
signal_limit_up: '涨停',
|
||||
signal_limit_down: '跌停',
|
||||
signal_limit_down_recovery: '跌停翘板',
|
||||
signal_broken_limit_up: '炸板',
|
||||
export type SignalKind = 'entry' | 'exit' | 'both'
|
||||
|
||||
export interface BuiltinSignalDefinition {
|
||||
id: string
|
||||
name: string
|
||||
kind: SignalKind
|
||||
category: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 内置原子信号清单 (权威展示来源, 两页统一) */
|
||||
export const BUILTIN_SIGNAL_DEFINITIONS: BuiltinSignalDefinition[] = [
|
||||
{
|
||||
id: 'signal_ma_golden_5_20',
|
||||
name: 'MA5上穿MA20',
|
||||
kind: 'entry',
|
||||
category: '均线',
|
||||
description: '短期均线 MA5 上穿中期均线 MA20,常用于趋势转强确认。',
|
||||
},
|
||||
{
|
||||
id: 'signal_ma_dead_5_20',
|
||||
name: 'MA5下穿MA20',
|
||||
kind: 'exit',
|
||||
category: '均线',
|
||||
description: '短期均线 MA5 下穿中期均线 MA20,常用于趋势转弱或止盈止损。',
|
||||
},
|
||||
{
|
||||
id: 'signal_ma_golden_20_60',
|
||||
name: 'MA20上穿MA60',
|
||||
kind: 'entry',
|
||||
category: '均线',
|
||||
description: '中期均线 MA20 上穿长期均线 MA60,偏中线趋势信号。',
|
||||
},
|
||||
{
|
||||
id: 'signal_macd_golden',
|
||||
name: 'MACD金叉',
|
||||
kind: 'entry',
|
||||
category: 'MACD',
|
||||
description: 'MACD DIF 上穿 DEA,表示动能可能由弱转强。',
|
||||
},
|
||||
{
|
||||
id: 'signal_macd_dead',
|
||||
name: 'MACD死叉',
|
||||
kind: 'exit',
|
||||
category: 'MACD',
|
||||
description: 'MACD DIF 下穿 DEA,表示动能可能由强转弱。',
|
||||
},
|
||||
{
|
||||
id: 'signal_ma20_breakout',
|
||||
name: '突破MA20',
|
||||
kind: 'entry',
|
||||
category: '趋势',
|
||||
description: '收盘价向上突破 MA20,常用于趋势突破买点。',
|
||||
},
|
||||
{
|
||||
id: 'signal_ma20_breakdown',
|
||||
name: '跌破MA20',
|
||||
kind: 'exit',
|
||||
category: '趋势',
|
||||
description: '收盘价向下跌破 MA20,常用于趋势破位卖点。',
|
||||
},
|
||||
{
|
||||
id: 'signal_n_day_high',
|
||||
name: '60日新高',
|
||||
kind: 'entry',
|
||||
category: '趋势',
|
||||
description: '收盘价创近 60 日新高,表示阶段强势或突破。',
|
||||
},
|
||||
{
|
||||
id: 'signal_n_day_low',
|
||||
name: '60日新低',
|
||||
kind: 'exit',
|
||||
category: '趋势',
|
||||
description: '收盘价创近 60 日新低,表示阶段弱势或风险释放。',
|
||||
},
|
||||
{
|
||||
id: 'signal_boll_breakout_upper',
|
||||
name: '突破布林上轨',
|
||||
kind: 'entry',
|
||||
category: 'BOLL',
|
||||
description: '价格突破布林上轨,偏强势突破或加速信号。',
|
||||
},
|
||||
{
|
||||
id: 'signal_boll_breakdown_lower',
|
||||
name: '跌破布林下轨',
|
||||
kind: 'exit',
|
||||
category: 'BOLL',
|
||||
description: '价格跌破布林下轨,偏弱势破位或超跌风险信号。',
|
||||
},
|
||||
{
|
||||
id: 'signal_volume_surge',
|
||||
name: '放量',
|
||||
kind: 'both',
|
||||
category: '量价',
|
||||
description: '成交量显著放大,可作为买入确认、卖出确认或告警条件。',
|
||||
},
|
||||
{
|
||||
id: 'signal_limit_up',
|
||||
name: '涨停',
|
||||
kind: 'entry',
|
||||
category: '涨跌停',
|
||||
description: '收盘封住涨停,用于强势股、连板与市场情绪监控。',
|
||||
},
|
||||
{
|
||||
id: 'signal_limit_down',
|
||||
name: '跌停',
|
||||
kind: 'exit',
|
||||
category: '涨跌停',
|
||||
description: '收盘触及跌停,用于风险控制与弱势监控。',
|
||||
},
|
||||
{
|
||||
id: 'signal_limit_down_recovery',
|
||||
name: '跌停翘板',
|
||||
kind: 'entry',
|
||||
category: '涨跌停',
|
||||
description: '盘中触及跌停后回升,常用于短线情绪修复观察。',
|
||||
},
|
||||
{
|
||||
id: 'signal_broken_limit_up',
|
||||
name: '炸板',
|
||||
kind: 'exit',
|
||||
category: '涨跌停',
|
||||
description: '盘中触及涨停但收盘未封住,用于强转弱或分歧监控。',
|
||||
},
|
||||
]
|
||||
|
||||
/** 内置原子信号 → 中文标签 */
|
||||
export const SIGNAL_LABELS: Record<string, string> = BUILTIN_SIGNAL_DEFINITIONS.reduce<Record<string, string>>((acc, sig) => {
|
||||
acc[sig.id] = sig.name
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/** 内置信号 ID 列表 */
|
||||
export const SIGNAL_OPTIONS = Object.keys(SIGNAL_LABELS)
|
||||
export const SIGNAL_OPTIONS = BUILTIN_SIGNAL_DEFINITIONS.map(sig => sig.id)
|
||||
|
||||
/** 常用技术指标/字段 → 中文 (阈值条件展示用, 与后端 ENRICHED_COLUMNS 对齐) */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -23,7 +23,7 @@ const TABS = [
|
||||
{ key: 'ai', label: 'AI 设置', icon: Sparkles, panel: SettingsAIPanel },
|
||||
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
|
||||
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
|
||||
{ key: 'signals', label: '自定义信号', icon: Zap, panel: SettingsCustomSignalsPanel },
|
||||
{ key: 'signals', label: '信号库', icon: Zap, panel: SettingsCustomSignalsPanel },
|
||||
{ key: 'menus', label: '菜单设置', icon: SlidersHorizontal, panel: SettingsMenuSettingsPanel },
|
||||
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
|
||||
] as const
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DatePicker } from '@/components/DatePicker'
|
||||
import { StrategyNavChart } from './charts/StrategyNavChart'
|
||||
import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
|
||||
import { TradeKlineModal } from './components/TradeKlineModal'
|
||||
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
||||
const monthsAgo = (months: number) => {
|
||||
@@ -387,18 +388,22 @@ function Stat({ label, value, color }: { label: ReactNode; value: string; color?
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigSection({ title, hint, children }: { title: string; hint?: ReactNode; children: ReactNode }) {
|
||||
function ConfigSection({ title, hint, actions, children }: { title: string; hint?: ReactNode; actions?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-btn border border-border bg-surface/70 p-3">
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
{title}
|
||||
{hint && <span className="ml-1 text-[10px] font-normal text-muted">{hint}</span>}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
{title}
|
||||
{hint && <span className="ml-1 text-[10px] font-normal text-muted">{hint}</span>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const scoringToPct = (values: Record<string, number>) => {
|
||||
const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0)
|
||||
if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record<string, number>
|
||||
@@ -1948,7 +1953,11 @@ export function StrategyBacktest() {
|
||||
)}
|
||||
|
||||
{settingsTab === 'entry' && (
|
||||
<ConfigSection title="买入触发器" hint="任一买点满足即可进入候选">
|
||||
<ConfigSection
|
||||
title="买入触发器"
|
||||
hint="任一买点满足即可进入候选"
|
||||
actions={<SignalTriggerActions kind="entry" signals={entrySignals} onChange={next => updateOverride('entry_signals', next)} />}
|
||||
>
|
||||
<SignalPicker
|
||||
signals={entrySignals}
|
||||
onChange={next => updateOverride('entry_signals', next)}
|
||||
@@ -1958,7 +1967,11 @@ export function StrategyBacktest() {
|
||||
)}
|
||||
|
||||
{settingsTab === 'exit' && (
|
||||
<ConfigSection title="卖出触发器" hint="任一卖点满足即触发卖出">
|
||||
<ConfigSection
|
||||
title="卖出触发器"
|
||||
hint="任一卖点满足即触发卖出"
|
||||
actions={<SignalTriggerActions kind="exit" signals={exitSignals} onChange={next => updateOverride('exit_signals', next)} />}
|
||||
>
|
||||
<SignalPicker
|
||||
signals={exitSignals}
|
||||
onChange={next => updateOverride('exit_signals', next)}
|
||||
|
||||
@@ -1,90 +1,98 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Save, Trash2, X, Zap, ArrowRight, Settings2 } from 'lucide-react'
|
||||
import { api, type CustomSignal, type CustomSignalCondition } from '@/lib/api'
|
||||
import { Plus, Trash2, Zap, Settings2, Lock } from 'lucide-react'
|
||||
import { api, type CustomSignal } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { BUILTIN_SIGNAL_DEFINITIONS, type SignalKind } from '@/lib/signals'
|
||||
import { CustomSignalDialog } from '@/components/signals/CustomSignalDialog'
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { entry: '买入', exit: '卖出', both: '买卖通用' }
|
||||
type SignalSection = 'builtin' | 'custom'
|
||||
|
||||
const emptySignal = (): CustomSignal => ({
|
||||
id: '', name: '', kind: 'exit', enabled: true,
|
||||
conditions: [{ left: 'close', op: '>', right: 'ma20' }],
|
||||
})
|
||||
const KIND_LABEL: Record<SignalKind, string> = { entry: '买入', exit: '卖出', both: '买卖通用' }
|
||||
const KIND_CLASS: Record<SignalKind, string> = {
|
||||
entry: 'bg-accent/10 text-accent',
|
||||
exit: 'bg-warning/10 text-warning',
|
||||
both: 'bg-muted/10 text-muted',
|
||||
}
|
||||
|
||||
export function SettingsCustomSignalsPanel() {
|
||||
const qc = useQueryClient()
|
||||
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
|
||||
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions })
|
||||
|
||||
const [activeSection, setActiveSection] = useState<SignalSection>('builtin')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState<CustomSignal | null>(null)
|
||||
const [draft, setDraft] = useState<CustomSignal>(emptySignal())
|
||||
const [error, setError] = useState('')
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
||||
const resetDeleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const fields = options.data?.fields ?? []
|
||||
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
|
||||
const signals = list.data?.signals ?? []
|
||||
const enabledCustomSignals = signals.filter(sig => sig.enabled).length
|
||||
const tabs = [
|
||||
{ key: 'builtin' as const, label: '内置信号', count: BUILTIN_SIGNAL_DEFINITIONS.length, hint: '系统提供,只读' },
|
||||
{ key: 'custom' as const, label: '自定义信号', count: signals.length, hint: `${enabledCustomSignals} 个已启用` },
|
||||
]
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null)
|
||||
setDraft(emptySignal())
|
||||
setError('')
|
||||
useEffect(() => () => {
|
||||
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
|
||||
}, [])
|
||||
|
||||
const clearDeleteConfirm = () => {
|
||||
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
|
||||
resetDeleteTimer.current = null
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
|
||||
const openNew = () => { resetForm(); setShowForm(true) }
|
||||
const openEdit = (sig: CustomSignal) => {
|
||||
setEditing(sig)
|
||||
setDraft({ ...sig, conditions: sig.conditions.map(c => ({ ...c })) })
|
||||
setError('')
|
||||
const openNew = () => {
|
||||
setEditing(null)
|
||||
clearDeleteConfirm()
|
||||
setActiveSection('custom')
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const d = draft
|
||||
if (!d.id.trim()) throw new Error('请输入信号标识')
|
||||
if (!/^[a-z0-9_]{1,40}$/.test(d.id)) throw new Error('标识仅允许小写字母、数字、下划线(1-40字符)')
|
||||
if (!d.name.trim()) throw new Error('请输入信号名称')
|
||||
if (d.conditions.length === 0) throw new Error('至少需要一个条件')
|
||||
for (const c of d.conditions) {
|
||||
if (!c.left || !c.op || c.right === '') throw new Error('条件填写不完整')
|
||||
}
|
||||
return api.customSignalSave(d)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.customSignals })
|
||||
setShowForm(false)
|
||||
resetForm()
|
||||
},
|
||||
onError: err => setError(String((err as any)?.message ?? err)),
|
||||
})
|
||||
const openEdit = (sig: CustomSignal) => {
|
||||
setEditing(sig)
|
||||
clearDeleteConfirm()
|
||||
setActiveSection('custom')
|
||||
setShowForm(true)
|
||||
}
|
||||
const closeForm = () => {
|
||||
setShowForm(false)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: api.customSignalDelete,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: QK.customSignals }),
|
||||
onSuccess: () => {
|
||||
clearDeleteConfirm()
|
||||
qc.invalidateQueries({ queryKey: QK.customSignals })
|
||||
},
|
||||
})
|
||||
|
||||
// 条件编辑辅助
|
||||
const updateCond = (idx: number, patch: Partial<CustomSignalCondition>) => {
|
||||
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
|
||||
}
|
||||
const addCond = () => setDraft(d => ({ ...d, conditions: [...d.conditions, { left: 'close', op: '>', right: '0' }] }))
|
||||
const removeCond = (idx: number) => setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
|
||||
|
||||
const toggleEnabled = (sig: CustomSignal) => {
|
||||
api.customSignalSave({ ...sig, enabled: !sig.enabled }).then(() => qc.invalidateQueries({ queryKey: QK.customSignals }))
|
||||
}
|
||||
|
||||
const signals = list.data?.signals ?? []
|
||||
const handleDeleteClick = (sig: CustomSignal) => {
|
||||
if (confirmingDeleteId === sig.id) {
|
||||
clearDeleteConfirm()
|
||||
del.mutate(sig.id)
|
||||
return
|
||||
}
|
||||
setConfirmingDeleteId(sig.id)
|
||||
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
|
||||
resetDeleteTimer.current = setTimeout(() => setConfirmingDeleteId(null), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(234,179,8,0.12),transparent_38%)]">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.2em] text-amber-400/80">自定义信号</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">用「字段 + 运算符 + 值」组合买卖信号</h2>
|
||||
<div className="text-[11px] uppercase tracking-[0.2em] text-amber-400/80">信号库</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">统一查看策略、回测与监控可用信号</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
|
||||
无需写代码,挑选已有指标字段组合条件(如 <span className="font-mono text-foreground/80">最低价 ≤ MA5</span>),即可在回测与监控中作为买卖信号使用。多条件间为「且」关系。
|
||||
内置信号由系统预计算,作为只读信号库展示;自定义信号可用「字段 + 运算符 + 值」组合条件创建,保存后可在策略、回测与监控中选择使用。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -92,159 +100,168 @@ export function SettingsCustomSignalsPanel() {
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-btn bg-amber-500/90 px-3 py-1.5 text-xs font-medium text-base hover:bg-amber-500 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建信号
|
||||
新建自定义信号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<StatCard label="内置信号" value={BUILTIN_SIGNAL_DEFINITIONS.length} hint="系统提供,只读" />
|
||||
<StatCard label="自定义信号" value={signals.length} hint="用户创建,可编辑" />
|
||||
<StatCard label="已启用自定义" value={enabledCustomSignals} hint="会注入 csg_* 列" />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-card border border-border bg-base/60 p-1.5">
|
||||
<div className="grid grid-cols-1 gap-1.5 md:grid-cols-2">
|
||||
{tabs.map(tab => {
|
||||
const active = activeSection === tab.key
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(tab.key)}
|
||||
className={`rounded-btn px-4 py-3 text-left transition-colors ${active ? 'bg-amber-500/15 text-amber-300 shadow-sm' : 'text-secondary hover:bg-elevated hover:text-foreground'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
<span className={`rounded px-2 py-0.5 text-[11px] ${active ? 'bg-amber-400/15 text-amber-300' : 'bg-elevated text-muted'}`}>{tab.count}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted">{tab.hint}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showForm && (
|
||||
{activeSection === 'builtin' && (
|
||||
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑信号' : '新建信号'}</h3>
|
||||
<p className="mt-1 text-[11px] text-muted">标识保存后不可修改,如需更换请新建。</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-3.5 w-3.5 text-muted" />
|
||||
<h3 className="text-sm font-medium text-foreground">内置信号</h3>
|
||||
<span className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-muted">只读</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">这些信号由系统在 enriched 数据中预计算,策略选择器会直接展示。</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowForm(false); setError('') }} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="text-[11px] text-muted">ID 前缀:<span className="font-mono text-foreground/70">signal_</span></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">信号标识</span>
|
||||
<input
|
||||
value={draft.id}
|
||||
disabled={!!editing}
|
||||
onChange={e => setDraft(d => ({ ...d, id: e.target.value.replace(/[^a-z0-9_]/g, '') }))}
|
||||
placeholder="如 low_touches_ma5"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">信号名称</span>
|
||||
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如 跌至MA5" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">类型</span>
|
||||
<select value={draft.kind} onChange={e => setDraft(d => ({ ...d, kind: e.target.value as CustomSignal['kind'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
|
||||
<option value="entry">买入</option>
|
||||
<option value="exit">卖出</option>
|
||||
<option value="both">买卖通用</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 条件组 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted">条件(多条件为「且」关系)</span>
|
||||
<button onClick={addCond} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
|
||||
<Plus className="h-3 w-3" />添加条件
|
||||
</button>
|
||||
</div>
|
||||
{draft.conditions.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 ? '当' : '且'}</span>
|
||||
<select value={c.left} onChange={e => updateCond(i, { left: e.target.value })} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
|
||||
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
|
||||
</select>
|
||||
<select value={c.op} onChange={e => updateCond(i, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
|
||||
{operators.map(op => <option key={op} value={op}>{op}</option>)}
|
||||
</select>
|
||||
<RightValueInput cond={c} fields={fields} onChange={v => updateCond(i, { right: v })} />
|
||||
{draft.conditions.length > 1 && (
|
||||
<button onClick={() => removeCond(i)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{BUILTIN_SIGNAL_DEFINITIONS.map(sig => (
|
||||
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-foreground truncate">{sig.name}</h4>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
|
||||
{KIND_LABEL[sig.kind]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted font-mono truncate">{sig.id}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded border border-border bg-elevated px-1.5 py-0.5 text-[10px] text-muted">{sig.category}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-secondary">{sig.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => { setShowForm(false); setError('') }} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs">取消</button>
|
||||
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-amber-500/90 text-base text-xs font-medium disabled:opacity-50">
|
||||
<Save className="h-3.5 w-3.5" />保存
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{signals.map(sig => (
|
||||
<div key={sig.id} className="rounded-card border border-border bg-surface p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground truncate">{sig.name}</h3>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${sig.kind === 'entry' ? 'bg-accent/10 text-accent' : sig.kind === 'exit' ? 'bg-warning/10 text-warning' : 'bg-muted/10 text-muted'}`}>
|
||||
{KIND_LABEL[sig.kind]}
|
||||
</span>
|
||||
{!sig.enabled && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted">已停用</span>}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted font-mono truncate">{sig.id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`p-1 rounded cursor-pointer ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => openEdit(sig)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 cursor-pointer" title="编辑">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => del.mutate(sig.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer" title="删除">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{activeSection === 'custom' && (
|
||||
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-3.5 w-3.5 text-amber-400" />
|
||||
<h3 className="text-sm font-medium text-foreground">自定义信号</h3>
|
||||
<span className="rounded bg-amber-400/10 px-1.5 py-0.5 text-[10px] text-amber-400">可配置</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">这些信号由你定义,可启用/停用,并在策略、回测与监控中作为 csg_* 信号使用。</p>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
{sig.conditions.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-[11px] text-secondary">
|
||||
<span className="text-muted/50 w-6 text-right">{i === 0 ? '当' : '且'}</span>
|
||||
<span className="font-mono text-foreground/80">{fieldLabel(c.left, fields)}</span>
|
||||
<span className="font-mono text-muted">{c.op}</span>
|
||||
<span className="font-mono text-foreground/80">{rightDisplay(c.right, fields)}</span>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-400/10 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建自定义信号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{signals.map(sig => (
|
||||
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground truncate">{sig.name}</h3>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
|
||||
{KIND_LABEL[sig.kind]}
|
||||
</span>
|
||||
{!sig.enabled && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted">已停用</span>}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted font-mono truncate">csg_{sig.id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`p-1 rounded cursor-pointer ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => openEdit(sig)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 cursor-pointer" title="编辑">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{confirmingDeleteId === sig.id ? (
|
||||
<button
|
||||
onClick={() => handleDeleteClick(sig)}
|
||||
disabled={del.isPending}
|
||||
title="再次点击确认删除"
|
||||
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />确认
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDeleteClick(sig)}
|
||||
disabled={del.isPending}
|
||||
className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer disabled:opacity-50"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
{sig.conditions.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-[11px] text-secondary">
|
||||
<span className="text-muted/50 w-6 text-right">{i === 0 ? '当' : '且'}</span>
|
||||
<span className="font-mono text-foreground/80">{fieldLabel(c.left, fields)}</span>
|
||||
<span className="font-mono text-muted">{c.op}</span>
|
||||
<span className="font-mono text-foreground/80">{rightDisplay(c.right, fields)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{signals.length === 0 && (
|
||||
<div className="rounded-card border border-border bg-base px-5 py-10 text-center text-sm text-muted md:col-span-2">
|
||||
暂无自定义信号,点击右上角「新建自定义信号」。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{signals.length === 0 && (
|
||||
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted md:col-span-2">
|
||||
暂无自定义信号,点击右上角「新建信号」。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CustomSignalDialog open={showForm} signal={editing} onClose={closeForm} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 右值输入:可填数字,也可选「字段引用」────────────────
|
||||
function RightValueInput({ cond, fields, onChange }: { cond: CustomSignalCondition; fields: { key: string; label: string }[]; onChange: (v: string) => void }) {
|
||||
const isField = cond.right.startsWith('field:')
|
||||
const fieldValue = isField ? cond.right.slice(6) : ''
|
||||
const numValue = isField ? '' : cond.right
|
||||
|
||||
function StatCard({ label, value, hint }: { label: string; value: number; hint: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{isField ? (
|
||||
<>
|
||||
<select value={fieldValue} onChange={e => onChange(`field:${e.target.value}`)} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
|
||||
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => onChange('0')} title="切换为数字" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
|
||||
<ArrowRight className="h-3 w-3 rotate-90" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input type="number" value={numValue} onChange={e => onChange(e.target.value)} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
|
||||
<button onClick={() => onChange('field:close')} title="切换为字段" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
|
||||
<ArrowRight className="h-3 w-3 -rotate-90" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="rounded-card border border-border/80 bg-base/70 px-4 py-3">
|
||||
<div className="text-[11px] text-muted">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-foreground">{value}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted">{hint}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许局域网访问
|
||||
port: 3011,
|
||||
proxy: {
|
||||
// dev 时 /api 转发到 FastAPI
|
||||
|
||||
Reference in New Issue
Block a user