feat(backtest): 风控新增固定止盈 + 优化回测范围

风控: MatcherConfig 增加 take_profit_pct, 单标的与组合引擎风险检查新增
涨破止盈线卖出逻辑 (退出原因 take_profit);strategy.py 透传 overrides,
api/strategy.py 与前端类型暴露 take_profit;风控 tab 增加止盈输入框,
摘要/结果头/退出标签同步展示;修复未设止损时止盈检查被跳过的 bug。

回测范围: StockPoolPicker 输入框右侧增加「导入自选」「清空」按钮与
「当前范围」指示器, 移除冗余的「留空=全市场」提示。
This commit is contained in:
shy3130
2026-06-29 17:17:20 +08:00
parent cd76796a5c
commit 9dbd32246c
5 changed files with 170 additions and 51 deletions
+1
View File
@@ -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),
+43 -18
View File
@@ -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,
+7
View File
@@ -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,
+17
View File
@@ -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',
+102 -33
View File
@@ -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