mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(backtest): 风控新增固定止盈 + 优化回测范围
风控: MatcherConfig 增加 take_profit_pct, 单标的与组合引擎风险检查新增 涨破止盈线卖出逻辑 (退出原因 take_profit);strategy.py 透传 overrides, api/strategy.py 与前端类型暴露 take_profit;风控 tab 增加止盈输入框, 摘要/结果头/退出标签同步展示;修复未设止损时止盈检查被跳过的 bug。 回测范围: StockPoolPicker 输入框右侧增加「导入自选」「清空」按钮与 「当前范围」指示器, 移除冗余的「留空=全市场」提示。
This commit is contained in:
@@ -84,6 +84,7 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
|
||||
"entry_signals": s.entry_signals,
|
||||
"exit_signals": s.exit_signals,
|
||||
"stop_loss": overrides.get("stop_loss", s.stop_loss) if overrides else s.stop_loss,
|
||||
"take_profit": getattr(s, "take_profit", None),
|
||||
"trailing_stop": getattr(s, "trailing_stop", None),
|
||||
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None),
|
||||
"trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None),
|
||||
|
||||
@@ -37,6 +37,7 @@ class MatcherConfig:
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
stop_loss_pct: float | None = None
|
||||
take_profit_pct: float | None = None
|
||||
trailing_stop_pct: float | None = None
|
||||
trailing_take_profit_activate_pct: float | None = None
|
||||
trailing_take_profit_drawdown_pct: float | None = None
|
||||
@@ -65,7 +66,7 @@ class TradeRecord:
|
||||
exit_price: float
|
||||
pnl_pct: float
|
||||
duration: int
|
||||
exit_reason: str # "signal" | "stop_loss" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
|
||||
exit_reason: str # "signal" | "stop_loss" | "take_profit" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
|
||||
# 退出优先级 (高→低): pending_exit(历史挂单) > 风控(止损/移动止损/移动止盈) > signal(卖点) > max_hold(到期) > end
|
||||
name: str = ""
|
||||
shares: float = 0.0
|
||||
@@ -544,6 +545,7 @@ class BacktestEngine:
|
||||
return None, None
|
||||
open_price = float(open_prices[idx])
|
||||
low_price = float(low_prices[idx])
|
||||
high_price = float(high_prices[idx])
|
||||
peak_price = float(pos.get("max_high", entry_price))
|
||||
risk_lines: list[tuple[float, str]] = []
|
||||
|
||||
@@ -560,13 +562,24 @@ class BacktestEngine:
|
||||
risk_lines.append((entry_price * (1 + peak_profit - abs(float(drawdown_pct))), "trailing_take_profit"))
|
||||
|
||||
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
|
||||
if not risk_lines:
|
||||
return None, None
|
||||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||||
if _valid_price(open_price) and open_price <= stop_price:
|
||||
return reason, open_price
|
||||
if _valid_price(low_price) and low_price <= stop_price:
|
||||
return reason, stop_price
|
||||
# 止损/移损/回撤止盈: 价格跌破风控线触发 (取最高优先级线)
|
||||
if risk_lines:
|
||||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||||
if _valid_price(open_price) and open_price <= stop_price:
|
||||
return reason, open_price
|
||||
if _valid_price(low_price) and low_price <= stop_price:
|
||||
return reason, stop_price
|
||||
|
||||
# 固定止盈: 价格涨破止盈线触发
|
||||
tp_pct = getattr(config, "take_profit_pct", None)
|
||||
if tp_pct is not None:
|
||||
tp_line = entry_price * (1 + abs(float(tp_pct)))
|
||||
if _valid_price(tp_line):
|
||||
# 开盘即超过止盈线 → 以开盘价成交; 否则当日触及高点止盈
|
||||
if _valid_price(open_price) and open_price >= tp_line:
|
||||
return "take_profit", open_price
|
||||
if _valid_price(high_price) and high_price >= tp_line:
|
||||
return "take_profit", tp_line
|
||||
return None, None
|
||||
|
||||
def _try_close(pos: dict, idx: int, reason: str, signal_date: str, exit_price_override: float | None = None) -> bool:
|
||||
@@ -993,6 +1006,7 @@ class BacktestEngine:
|
||||
continue
|
||||
open_price = float(open_prices[idx])
|
||||
low_price = float(low_prices[idx])
|
||||
high_price = float(high_prices[idx])
|
||||
entry_price = float(pos["entry_price"])
|
||||
peak_price = float(pos.get("max_high", entry_price))
|
||||
risk_lines: list[tuple[float, str]] = []
|
||||
@@ -1011,17 +1025,28 @@ class BacktestEngine:
|
||||
take_profit_line = entry_price * (1 + peak_profit - abs(float(drawdown_pct)))
|
||||
risk_lines.append((take_profit_line, "trailing_take_profit"))
|
||||
|
||||
# 止损/移损/回撤止盈: 价格跌破风控线触发
|
||||
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
|
||||
if not risk_lines:
|
||||
continue
|
||||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||||
exit_price_override = None
|
||||
if _valid_price(open_price) and open_price <= stop_price:
|
||||
exit_price_override = open_price
|
||||
elif _valid_price(low_price) and low_price <= stop_price:
|
||||
exit_price_override = stop_price
|
||||
if exit_price_override is not None:
|
||||
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
|
||||
if risk_lines:
|
||||
stop_price, reason = max(risk_lines, key=lambda item: item[0])
|
||||
exit_price_override = None
|
||||
if _valid_price(open_price) and open_price <= stop_price:
|
||||
exit_price_override = open_price
|
||||
elif _valid_price(low_price) and low_price <= stop_price:
|
||||
exit_price_override = stop_price
|
||||
if exit_price_override is not None:
|
||||
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
|
||||
continue
|
||||
|
||||
# 固定止盈: 价格涨破止盈线触发
|
||||
tp_pct = getattr(config, "take_profit_pct", None)
|
||||
if tp_pct is not None:
|
||||
tp_line = entry_price * (1 + abs(float(tp_pct)))
|
||||
if _valid_price(tp_line):
|
||||
if _valid_price(open_price) and open_price >= tp_line:
|
||||
_try_sell(sym, idx, "take_profit", d_str, sold_today, open_price)
|
||||
elif _valid_price(high_price) and high_price >= tp_line:
|
||||
_try_sell(sym, idx, "take_profit", d_str, sold_today, tp_line)
|
||||
|
||||
def _process_entries(
|
||||
d_str: str,
|
||||
|
||||
@@ -103,6 +103,11 @@ class StrategyBacktestService:
|
||||
entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
|
||||
exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
|
||||
stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss)
|
||||
take_profit = self._normalize_pct(
|
||||
self._override_value(overrides, "take_profit", getattr(s, "take_profit", None)),
|
||||
0.01,
|
||||
5.0,
|
||||
)
|
||||
trailing_stop = self._normalize_pct(
|
||||
self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)),
|
||||
0.005,
|
||||
@@ -195,6 +200,7 @@ class StrategyBacktestService:
|
||||
fees_pct=config.fees_pct,
|
||||
slippage_bps=config.slippage_bps,
|
||||
stop_loss_pct=stop_loss,
|
||||
take_profit_pct=take_profit,
|
||||
trailing_stop_pct=trailing_stop,
|
||||
trailing_take_profit_activate_pct=trailing_take_profit_activate,
|
||||
trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown,
|
||||
@@ -246,6 +252,7 @@ class StrategyBacktestService:
|
||||
"entry_signals": entry_signals,
|
||||
"exit_signals": exit_signals,
|
||||
"stop_loss": stop_loss,
|
||||
"take_profit": take_profit,
|
||||
"trailing_stop": trailing_stop,
|
||||
"trailing_take_profit_activate": trailing_take_profit_activate,
|
||||
"trailing_take_profit_drawdown": trailing_take_profit_drawdown,
|
||||
|
||||
@@ -357,6 +357,7 @@ export interface StrategyDetail {
|
||||
entry_signals: string[]
|
||||
exit_signals: string[]
|
||||
stop_loss: number | null
|
||||
take_profit: number | null
|
||||
trailing_stop: number | null
|
||||
trailing_take_profit_activate: number | null
|
||||
trailing_take_profit_drawdown: number | null
|
||||
@@ -442,6 +443,8 @@ export interface AlertEvent {
|
||||
signals?: string[]
|
||||
severity?: string
|
||||
strategy_id?: string
|
||||
conditions?: MonitorCondition[]
|
||||
logic?: 'and' | 'or'
|
||||
}
|
||||
|
||||
/** 生成监控规则 id (时间戳 + 随机后缀), 用户无需手动填写。 */
|
||||
@@ -583,6 +586,7 @@ export interface StrategyBacktestResult {
|
||||
entry_signals: string[]
|
||||
exit_signals: string[]
|
||||
stop_loss: number | null
|
||||
take_profit: number | null
|
||||
trailing_stop: number | null
|
||||
trailing_take_profit_activate: number | null
|
||||
trailing_take_profit_drawdown: number | null
|
||||
@@ -682,6 +686,9 @@ export interface Preferences {
|
||||
strategy_monitor_enabled: boolean
|
||||
strategy_monitor_ids: string[]
|
||||
system_notify_enabled: boolean
|
||||
feishu_webhook_url?: string
|
||||
feishu_webhook_secret?: string
|
||||
webhook_enabled_default?: boolean
|
||||
sidebar_index_symbols: string[]
|
||||
nav_order: string[]
|
||||
nav_hidden: string[]
|
||||
@@ -838,6 +845,16 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
updateFeishuWebhook: (url: string, secret: string = '') =>
|
||||
request<{ feishu_webhook_url: string; feishu_webhook_secret: string }>('/api/settings/preferences/feishu-webhook', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ url, secret }),
|
||||
}),
|
||||
updateWebhookDefault: (enabled: boolean) =>
|
||||
request<{ webhook_enabled_default: boolean }>('/api/settings/preferences/webhook-enabled-default', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
updatePipelineSchedule: (hour: number, minute: number) =>
|
||||
request<{ hour: number; minute: number }>('/api/settings/preferences/pipeline-schedule', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap } from 'lucide-react'
|
||||
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus } from 'lucide-react'
|
||||
import {
|
||||
api,
|
||||
type StrategyBacktestResult,
|
||||
@@ -154,6 +154,7 @@ const buildDefaultOverrides = (detail: StrategyDetail) => ({
|
||||
exit_signals: detail.exit_signals.map(toSignalId),
|
||||
scoring: { ...detail.scoring },
|
||||
stop_loss: detail.stop_loss,
|
||||
take_profit: detail.take_profit,
|
||||
trailing_stop: detail.trailing_stop,
|
||||
trailing_take_profit_activate: detail.trailing_take_profit_activate,
|
||||
trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown,
|
||||
@@ -192,6 +193,7 @@ function ExitReasonBadge({ reason }: { reason: string }) {
|
||||
const config: Record<string, { label: string; cls: string }> = {
|
||||
signal: { label: '信号', cls: 'bg-accent/10 text-accent border-accent/30' },
|
||||
stop_loss: { label: '止损', cls: 'bg-red-500/10 text-red-400 border-red-500/30' },
|
||||
take_profit: { label: '止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||
trailing_stop: { label: '移损', cls: 'bg-orange-500/10 text-orange-400 border-orange-500/30' },
|
||||
trailing_take_profit: { label: '回撤止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||
max_hold: { label: '超期', cls: 'bg-amber-400/10 text-amber-400 border-amber-400/30' },
|
||||
@@ -517,6 +519,12 @@ function StockPoolPicker({ value, onChange }: { value: string; onChange: (value:
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const results = search.data?.results ?? []
|
||||
// 自选列表 — 供「从自选导入」一键填入回测范围
|
||||
const watchlist = useQuery({
|
||||
queryKey: QK.watchlist,
|
||||
queryFn: () => api.watchlistList(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (results.length === 0) return
|
||||
@@ -545,43 +553,84 @@ function StockPoolPicker({ value, onChange }: { value: string; onChange: (value:
|
||||
setOpen(false)
|
||||
}
|
||||
const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol))
|
||||
// 一键导入自选: 合并去重, 顺带回填股票名
|
||||
const importFromWatchlist = () => {
|
||||
const entries = watchlist.data?.symbols ?? []
|
||||
if (entries.length === 0) return
|
||||
setSymbolNames(prev => {
|
||||
const next = { ...prev }
|
||||
entries.forEach(e => { if (e.name) next[e.symbol] = e.name })
|
||||
return next
|
||||
})
|
||||
setSymbols([...symbols, ...entries.map(e => e.symbol)])
|
||||
}
|
||||
const watchlistCount = watchlist.data?.symbols?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2" ref={ref}>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
||||
onFocus={() => { if (query.trim()) setOpen(true) }}
|
||||
placeholder="搜索股票名称/代码添加股票池"
|
||||
className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
|
||||
{results.map(r => {
|
||||
const added = symbols.includes(r.symbol)
|
||||
return (
|
||||
<button
|
||||
key={r.symbol}
|
||||
type="button"
|
||||
disabled={added}
|
||||
onClick={() => addSymbol(r.symbol, r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
|
||||
>
|
||||
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
|
||||
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
||||
onFocus={() => { if (query.trim()) setOpen(true) }}
|
||||
placeholder="搜索股票名称/代码添加股票池"
|
||||
className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
|
||||
{results.map(r => {
|
||||
const added = symbols.includes(r.symbol)
|
||||
return (
|
||||
<button
|
||||
key={r.symbol}
|
||||
type="button"
|
||||
disabled={added}
|
||||
onClick={() => addSymbol(r.symbol, r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
|
||||
>
|
||||
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
|
||||
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 操作按钮 — 紧贴输入框右侧 */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{/* 当前范围 — 有范围显示个数, 无范围显示全市场 */}
|
||||
<span className={`whitespace-nowrap text-[11px] font-medium ${symbols.length === 0 ? 'text-amber-400' : 'text-accent'}`}>
|
||||
{symbols.length === 0 ? '全市场' : `共 ${symbols.length} 只`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={importFromWatchlist}
|
||||
disabled={watchlist.isLoading || watchlistCount === 0}
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="把自选列表的个股加入回测范围"
|
||||
>
|
||||
<ListPlus className="h-3 w-3" />
|
||||
{watchlist.isLoading ? '加载…' : watchlistCount === 0 ? '自选空' : `导入自选(${watchlistCount})`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSymbols([])}
|
||||
disabled={symbols.length === 0}
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-danger/50 hover:text-danger disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="清空回测范围"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{symbols.length === 0 ? (
|
||||
<span className="text-[11px] font-medium text-amber-400">留空 = 全市场,由基础过滤和策略条件筛选。</span>
|
||||
<span className="text-[11px] text-muted">默认全市场回测,由基础过滤和策略条件筛选。</span>
|
||||
) : symbols.map(symbol => {
|
||||
const name = symbolNames[symbol]
|
||||
return (
|
||||
@@ -899,6 +948,7 @@ export function StrategyBacktest() {
|
||||
const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min)
|
||||
const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
|
||||
const stopLossPct = overrides.stop_loss == null ? '' : String(Math.abs(Number(overrides.stop_loss)) * 100)
|
||||
const takeProfitPct = overrides.take_profit == null ? '' : String(Math.abs(Number(overrides.take_profit)) * 100)
|
||||
const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100)
|
||||
const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100)
|
||||
const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100)
|
||||
@@ -942,6 +992,7 @@ export function StrategyBacktest() {
|
||||
`卖点 ${exitSignals.length}`,
|
||||
scoreFilterSummary,
|
||||
stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设',
|
||||
takeProfitPct !== '' ? `止盈 ${takeProfitPct}%` : '止盈未设',
|
||||
trailingStopPct !== '' ? `移损 ${trailingStopPct}%` : '移损未设',
|
||||
trailingTakeProfitActivatePct !== '' && trailingTakeProfitDrawdownPct !== '' ? `回撤 ${trailingTakeProfitActivatePct}-${trailingTakeProfitDrawdownPct}点` : '回撤未设',
|
||||
maxHoldDaysValue !== '' ? `最长 ${maxHoldDaysValue}天` : '不限持仓',
|
||||
@@ -1481,6 +1532,9 @@ export function StrategyBacktest() {
|
||||
{result.strategy_info.stop_loss != null && (
|
||||
<span className="text-[10px] text-secondary">止损 {fmtPct(result.strategy_info.stop_loss)}</span>
|
||||
)}
|
||||
{result.strategy_info.take_profit != null && (
|
||||
<span className="text-[10px] text-secondary">止盈 {fmtPct(result.strategy_info.take_profit)}</span>
|
||||
)}
|
||||
{result.strategy_info.trailing_stop != null && (
|
||||
<span className="text-[10px] text-secondary">移损 {fmtPct(result.strategy_info.trailing_stop)}</span>
|
||||
)}
|
||||
@@ -1869,7 +1923,7 @@ export function StrategyBacktest() {
|
||||
</div>
|
||||
|
||||
{settingsTab === 'range' && (
|
||||
<ConfigSection title="回测范围" hint={<span className="font-medium text-amber-400">留空 = 全市场</span>}>
|
||||
<ConfigSection title="回测范围">
|
||||
<StockPoolPicker value={symbols} onChange={setSymbols} />
|
||||
<div className="text-[11px] leading-5 text-muted">默认全市场回测,由基础过滤、策略条件和买卖触发器筛选;需要单票调试或自选池回测时再限定股票池。</div>
|
||||
</ConfigSection>
|
||||
@@ -2091,6 +2145,21 @@ export function StrategyBacktest() {
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] text-secondary">止盈(%)</span>
|
||||
<input
|
||||
type="number"
|
||||
value={takeProfitPct}
|
||||
min={1}
|
||||
max={500}
|
||||
step={0.5}
|
||||
onChange={e => {
|
||||
const n = numOrNull(e.target.value)
|
||||
updateOverride('take_profit', n == null ? null : clamp(Math.abs(n), 1, 500) / 100)
|
||||
}}
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] text-secondary">移动止损(%)</span>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user