From 9dbd32246c6e24b25ee2beb5ca66febdd2dc2f9e Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Mon, 29 Jun 2026 17:17:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20=E9=A3=8E=E6=8E=A7=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=9B=BA=E5=AE=9A=E6=AD=A2=E7=9B=88=20+=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=9B=9E=E6=B5=8B=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 风控: MatcherConfig 增加 take_profit_pct, 单标的与组合引擎风险检查新增 涨破止盈线卖出逻辑 (退出原因 take_profit);strategy.py 透传 overrides, api/strategy.py 与前端类型暴露 take_profit;风控 tab 增加止盈输入框, 摘要/结果头/退出标签同步展示;修复未设止损时止盈检查被跳过的 bug。 回测范围: StockPoolPicker 输入框右侧增加「导入自选」「清空」按钮与 「当前范围」指示器, 移除冗余的「留空=全市场」提示。 --- backend/app/api/strategy.py | 1 + backend/app/backtest/engine.py | 61 +++++--- backend/app/backtest/strategy.py | 7 + frontend/src/lib/api.ts | 17 +++ .../src/pages/backtest/StrategyBacktest.tsx | 135 +++++++++++++----- 5 files changed, 170 insertions(+), 51 deletions(-) diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 205784b..09f9f80 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -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), diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index 8d43975..943490c 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -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, diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 125169e..0518fb3 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -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, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d0feaf7..dc41613 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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', diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 2003d0a..7eca937 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -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 = { 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 (
-
- - { 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 && ( -
- {results.map(r => { - const added = symbols.includes(r.symbol) - return ( - - ) - })} -
- )} +
+
+ + { 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 && ( +
+ {results.map(r => { + const added = symbols.includes(r.symbol) + return ( + + ) + })} +
+ )} +
+ {/* 操作按钮 — 紧贴输入框右侧 */} +
+ {/* 当前范围 — 有范围显示个数, 无范围显示全市场 */} + + {symbols.length === 0 ? '全市场' : `共 ${symbols.length} 只`} + + + +
{symbols.length === 0 ? ( - 留空 = 全市场,由基础过滤和策略条件筛选。 + 默认全市场回测,由基础过滤和策略条件筛选。 ) : 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 && ( 止损 {fmtPct(result.strategy_info.stop_loss)} )} + {result.strategy_info.take_profit != null && ( + 止盈 {fmtPct(result.strategy_info.take_profit)} + )} {result.strategy_info.trailing_stop != null && ( 移损 {fmtPct(result.strategy_info.trailing_stop)} )} @@ -1869,7 +1923,7 @@ export function StrategyBacktest() {
{settingsTab === 'range' && ( - 留空 = 全市场}> +
默认全市场回测,由基础过滤、策略条件和买卖触发器筛选;需要单票调试或自选池回测时再限定股票池。
@@ -2091,6 +2145,21 @@ export function StrategyBacktest() { className={INPUT_CLS} /> +