#!/usr/bin/env python3 """ 推荐算法回测脚本 v4.1 — 基于预扫描结果表 + 智能过滤优化 + 纯技术止盈止损 v4 新增: - 买入过滤: min_buy_rate / min_buy_triggered - 盈利保护: 浮盈超过阈值时不被弱卖出信号清仓 - 跟踪止盈: 利润达到阈值后激活,回撤固定幅度才卖 v4.1 新增: - ignore_sell_signal: 完全忽略推荐卖出(MACD死叉),用纯技术止损 - max_hold_days: 最大持仓天数(强制平仓) - 可配置仓位参数: shares_per_trade / position_amount / max_concurrent - 回测时自动检测扫描数据覆盖范围 依赖: 需先运行 scan_history.py 将扫描结果入库。 用法: # v3 兼容模式 ./venv/bin/python backtest_recommend.py # v4.1 纯技术模式(忽略推荐卖出信号,改用跟踪止盈+止损) ./venv/bin/python backtest_recommend.py --ignore-sell --trailing-start 8 --trailing-gap 3 --stop-loss 5 ./venv/bin/python backtest_recommend.py --ignore-sell --trailing-start 6 --trailing-gap 3 --stop-loss 8 --max-hold 30 ./venv/bin/python backtest_recommend.py --ignore-sell --min-triggered 2 --trailing-start 8 --trailing-gap 3 --stop-loss 5 """ import sys import os import json import argparse from datetime import datetime, date, timedelta from collections import defaultdict sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import psycopg2 from config import Config # ─── 核心参数(默认值,可通过 run_backtest kwargs 覆盖)───── SHARES_PER_TRADE = 1000 MAX_BUYS_PER_DAY = 2 MAX_POSITION_AMOUNT = 30000 MAX_CONCURRENT_POSITIONS = 8 SELL_COOLDOWN_DAYS = 3 PRICE_MIN = 2.0 PRICE_MAX = 100.0 START_DATE = date(2026, 1, 2) def get_db_conn(): return psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) def get_trading_days(conn, start: date, end: date): with conn.cursor() as cur: cur.execute(""" SELECT DISTINCT trade_date::date FROM stock_kline_daily WHERE trade_date >= %s AND trade_date <= %s ORDER BY trade_date """, (start, end)) return [r[0] for r in cur.fetchall()] def get_day_ohlc(conn, trade_date: date): """返回 code -> (open, high, low, close) 的字典""" with conn.cursor() as cur: cur.execute("SELECT code, open, high, low, close FROM stock_kline_daily WHERE trade_date = %s", (trade_date,)) return {r[0]: (float(r[1]), float(r[2]), float(r[3]), float(r[4])) for r in cur.fetchall()} def get_5min_prices(conn, trade_date: date): """获取指定交易日 09:35 和 13:40 的5分钟K线收盘价 返回: {code: {'buy': price_at_09:35, 'sell': price_at_13:40}} (向后兼容接口,但新版使用 get_5min_price_at 直接按时间点查询) """ from datetime import time as dt_time result = {} dt_0935 = datetime.combine(trade_date, dt_time(9, 35)) dt_1340 = datetime.combine(trade_date, dt_time(13, 40)) with conn.cursor() as cur: cur.execute(""" SELECT code, dt, close FROM stock_kline_5min WHERE dt IN (%s, %s) """, (dt_0935, dt_1340)) for row in cur.fetchall(): code, dt, price = row[0], row[1], float(row[2]) if code not in result: result[code] = {} if dt.hour == 9 and dt.minute == 35: result[code]['buy'] = price elif dt.hour == 13 and dt.minute == 40: result[code]['sell'] = price return result def get_5min_price_at(conn, trade_date: date, time_str: str): """获取指定交易日指定时间点的5分钟K线收盘价 time_str: 如 '09:35', '10:00', '14:30', '15:00' 返回: {code: price} """ from datetime import time as dt_time h, m = int(time_str.split(':')[0]), int(time_str.split(':')[1]) dt_target = datetime.combine(trade_date, dt_time(h, m)) with conn.cursor() as cur: cur.execute("SELECT code, close FROM stock_kline_5min WHERE dt = %s", (dt_target,)) return {r[0]: float(r[1]) for r in cur.fetchall()} def load_scan_results(conn, scan_date: date, scan_time: str): """从 stock_scan_history 加载某日某时段的扫描结果""" with conn.cursor() as cur: cur.execute(""" SELECT code, recommend_display, recommend_type, recommend_reason, recommend_rate, triggered_count, indicators FROM stock_scan_history WHERE scan_date = %s AND scan_time = %s """, (scan_date, scan_time)) result = {} for r in cur.fetchall(): indicators = r[6] if r[6] else {} holding_info = indicators.get('_holding', {}) result[r[0]] = { 'display': r[1], 'type': r[2], 'reason': r[3], 'rate': r[4] or 0, 'triggered': r[5] or 0, 'holding_display': holding_info.get('display', '观望'), 'holding_reason': holding_info.get('reason', ''), 'holding_rate': holding_info.get('rate', 0), } return result # ─── 数据预加载(一次性加载全部数据到内存,避免反复查询DB)───── def preload_all_data(conn, start: date, end: date, use_5min=False, full_5min=False): """ 预加载回测所需的全部数据到内存。 参数: use_5min: 是否加载5分钟K线数据 full_5min: 是否加载全部48个时间点(True) 还是仅10:00/15:00(False) 返回 dict: 'trading_days': [date, ...] 'ohlc': {date: {code: (o,h,l,c)}} '5min': {(date, time_str): {code: price}} -- 新结构! 'scan': {(date, time_str): {code: info_dict}} """ import time as _t t0 = _t.time() # 1) 交易日 trading_days = get_trading_days(conn, start, end) print(f" [preload] 交易日: {len(trading_days)} 天", flush=True) # 2) 日线 OHLC — 批量加载 ohlc_all = {} with conn.cursor() as cur: cur.execute(""" SELECT trade_date::date, code, open, high, low, close FROM stock_kline_daily WHERE trade_date >= %s AND trade_date <= %s """, (start, end)) for r in cur.fetchall(): d = r[0] if d not in ohlc_all: ohlc_all[d] = {} ohlc_all[d][r[1]] = (float(r[2]), float(r[3]), float(r[4]), float(r[5])) print(f" [preload] 日线OHLC: {sum(len(v) for v in ohlc_all.values()):,} 条", flush=True) # 3) 5分钟K线 — 新结构: {(date, time_str): {code: price}} fivemin_all = {} if use_5min: with conn.cursor() as cur: if full_5min: # 加载全部48个时间点 cur.execute(""" SELECT dt, code, close FROM stock_kline_5min WHERE dt::date >= %s AND dt::date <= %s """, (start, end)) else: # 加载常用时间点: 09:35(最优买入), 10:00(旧默认), 13:40(最优卖出), 15:00(旧默认) cur.execute(""" SELECT dt, code, close FROM stock_kline_5min WHERE dt::date >= %s AND dt::date <= %s AND ( (EXTRACT(hour FROM dt) = 9 AND EXTRACT(minute FROM dt) = 35) OR (EXTRACT(hour FROM dt) = 10 AND EXTRACT(minute FROM dt) = 0) OR (EXTRACT(hour FROM dt) = 13 AND EXTRACT(minute FROM dt) = 40) OR (EXTRACT(hour FROM dt) = 15 AND EXTRACT(minute FROM dt) = 0) ) """, (start, end)) for row in cur.fetchall(): dt_val, code, price = row[0], row[1], float(row[2]) d = dt_val.date() if hasattr(dt_val, 'date') else dt_val t_str = f"{dt_val.hour:02d}:{dt_val.minute:02d}" key = (d, t_str) if key not in fivemin_all: fivemin_all[key] = {} fivemin_all[key][code] = price total_5m = sum(len(v) for v in fivemin_all.values()) n_slots = len(set(k[1] for k in fivemin_all.keys())) print(f" [preload] 5分钟K线: {total_5m:,} 条 ({n_slots} 个时间点)", flush=True) # 4) 扫描结果 — 批量加载 scan_all = {} with conn.cursor() as cur: cur.execute(""" SELECT scan_date, scan_time, code, recommend_display, recommend_type, recommend_reason, recommend_rate, triggered_count, indicators FROM stock_scan_history WHERE scan_date >= %s AND scan_date <= %s """, (start, end)) for r in cur.fetchall(): key = (r[0], r[1]) if key not in scan_all: scan_all[key] = {} indicators = r[8] if r[8] else {} holding_info = indicators.get('_holding', {}) scan_all[key][r[2]] = { 'display': r[3], 'type': r[4], 'reason': r[5], 'rate': r[6] or 0, 'triggered': r[7] or 0, 'holding_display': holding_info.get('display', '观望'), 'holding_reason': holding_info.get('reason', ''), 'holding_rate': holding_info.get('rate', 0), } total_scan = sum(len(v) for v in scan_all.values()) elapsed = _t.time() - t0 print(f" [preload] 扫描结果: {total_scan:,} 条 ({len(scan_all)} 个时段)", flush=True) print(f" [preload] 完成! 耗时 {elapsed:.1f}s", flush=True) return { 'trading_days': trading_days, 'ohlc': ohlc_all, '5min': fivemin_all, 'scan': scan_all, } # ─── 统计指标计算 ───────────────────────────────────── def calc_stats(trades, start_date, end_date, equity_series=None, max_capital_deployed=0): total_in = 0.0 total_out = 0.0 closed_trades = [] open_buys = {} wins = losses = flat = 0 for t in trades: act = t['action'] code = t['code'] if act in ('买入', '加仓'): total_in += t['amount'] if code not in open_buys: open_buys[code] = {'cost': 0, 'shares': 0, 'first_buy': t['date']} open_buys[code]['cost'] += t['amount'] open_buys[code]['shares'] += t['shares'] elif act == '清仓': total_out += t.get('amount', 0) profit = t.get('profit', 0) buy_info = open_buys.pop(code, None) first_buy = buy_info['first_buy'] if buy_info else t['date'] sell_date = t['date'] if isinstance(first_buy, str): first_buy = datetime.strptime(first_buy, '%Y-%m-%d').date() if isinstance(sell_date, str): sell_date = datetime.strptime(sell_date, '%Y-%m-%d').date() hold_days = (sell_date - first_buy).days closed_trades.append({ 'code': code, 'buy_date': first_buy, 'sell_date': sell_date, 'cost': buy_info['cost'] if buy_info else 0, 'revenue': t.get('amount', 0), 'profit': profit, 'hold_days': hold_days, 'reason': t.get('reason', ''), }) if profit > 0: wins += 1 elif profit < 0: losses += 1 else: flat += 1 total_closed = wins + losses + flat win_rate = (wins / total_closed * 100) if total_closed > 0 else 0 avg_hold = (sum(ct['hold_days'] for ct in closed_trades) / len(closed_trades)) if closed_trades else 0 profit = total_out - total_in pct = (profit / total_in * 100) if total_in > 0 else 0 days = (end_date - start_date).days # v4.2: 真实资金收益率(基于最大同时占用资金) capital_pct = (profit / max_capital_deployed * 100) if max_capital_deployed > 0 else 0 # 年化: 短期(<90天)用简单年化, 长期用复利年化(CAGR) if days >= 90 and max_capital_deployed > 0 and (max_capital_deployed + profit) > 0: capital_ann = (pow(1 + profit / max_capital_deployed, 365 / days) - 1) * 100 capital_ann_method = 'compound' elif days > 0 and max_capital_deployed > 0: capital_ann = capital_pct * (365 / days) # 简单年化 capital_ann_method = 'simple' else: capital_ann = 0.0 capital_ann_method = 'N/A' # 周转收益率(向后兼容) if days >= 90 and total_in > 0 and (total_in + profit) > 0: turnover_ann = (pow((total_in + profit) / total_in, 365 / days) - 1) * 100 turnover_ann_method = 'compound' elif days > 0 and total_in > 0: turnover_ann = pct * (365 / days) # 简单年化 turnover_ann_method = 'simple' else: turnover_ann = 0.0 turnover_ann_method = 'N/A' # 最大回撤 max_drawdown = 0.0 max_drawdown_pct = 0.0 if equity_series: peak = equity_series[0] for eq in equity_series: if eq > peak: peak = eq dd = peak - eq if dd > max_drawdown: max_drawdown = dd max_drawdown_pct = round((max_drawdown / max_capital_deployed * 100), 2) \ if max_capital_deployed > 0 else 0.0 avg_win = (sum(ct['profit'] for ct in closed_trades if ct['profit'] > 0) / wins) if wins > 0 else 0 avg_loss = (sum(ct['profit'] for ct in closed_trades if ct['profit'] < 0) / losses) if losses > 0 else 0 total_loss = abs(sum(ct['profit'] for ct in closed_trades if ct['profit'] < 0)) total_gain = abs(sum(ct['profit'] for ct in closed_trades if ct['profit'] > 0)) profit_factor = (total_gain / total_loss) if total_loss > 0 else 999.99 stock_pnl = {} for ct in closed_trades: c = ct['code'] if c not in stock_pnl: stock_pnl[c] = {'profit': 0, 'trades': 0, 'wins': 0} stock_pnl[c]['profit'] += ct['profit'] stock_pnl[c]['trades'] += 1 if ct['profit'] > 0: stock_pnl[c]['wins'] += 1 return { 'total_in': total_in, 'total_out': total_out, 'profit': profit, 'profit_pct': round(pct, 2), 'annualized_pct': round(turnover_ann, 2), 'annualized_method': turnover_ann_method, # v5: 年化方法 'max_capital': round(max_capital_deployed, 2), # v4.2: 最大占用资金 'capital_pct': round(capital_pct, 2), # v4.2: 真实资金收益率 'capital_ann_pct': round(capital_ann, 2), # v4.2: 真实年化 'capital_ann_method': capital_ann_method, # v5: 年化方法 'trade_count': len(trades), 'closed_count': total_closed, 'wins': wins, 'losses': losses, 'flat': flat, 'win_rate': round(win_rate, 2), 'avg_hold_days': round(avg_hold, 1), 'max_drawdown': round(max_drawdown, 2), 'max_drawdown_pct': max_drawdown_pct, 'avg_win': round(avg_win, 2), 'avg_loss': round(avg_loss, 2), 'profit_factor': round(profit_factor, 2), 'days': days, 'closed_trades': closed_trades, 'stock_pnl': stock_pnl, } # ─── 核心回测引擎 v6.0 ───────────────────────────────── def run_backtest(conn, start_date=None, end_date=None, preloaded=None, # 预加载数据 (from preload_all_data) take_profit_pct=None, stop_loss_pct=None, # ── v4 参数 ── min_buy_rate=0, min_buy_triggered=0, profit_protect_pct=0, sell_confirm_rate=0, trailing_start_pct=0, trailing_gap_pct=0, # ── v4.1 新增参数 ── ignore_sell_signal=False, # 完全忽略推荐卖出信号 max_hold_days=0, # 最大持仓天数 (0=不限) shares_per_trade=None, # 每笔股数 (None=用默认值) position_amount=None, # 单只上限 (None=用默认值) max_concurrent=None, # 最大并发持仓 (None=用默认值) # ── v4.2 新增参数 ── sell_confirm_days=0, # 连续N天卖出信号才执行 (0=立即) # ── v5 新增参数 ── use_5min_prices=False, # 使用5分钟K线实时价格 # ── v5.1 新增:总资金约束模式 ── total_capital=0, # 总本金 (0=不限,>0 启用现金追踪) max_buys_per_day=None, # 每日最多买入 (None=用默认值) price_min=None, # 股价下限 (None=用默认值) price_max=None, # 股价上限 (None=用默认值) # ── v5.2 新增:动态仓位管理 ── position_pct=0, # 单笔仓位占总资金百分比 (0=用固定股数, >0=动态仓位) signal_weight=False, # 是否根据信号强度调整仓位 (True=强信号加仓) # ── v6.0 新增:连涨保护 & 高级止盈止损 ── momentum_tp=False, # 连涨保护: 当连涨≥N天且浮盈≥TP时转跟踪止盈(不立即卖) momentum_days=3, # 判定连涨的天数 (≥N天收盘连涨) momentum_trail_start=0, # 连涨时跟踪止盈启动线(0=用TP作为启动线) momentum_trail_gap=3, # 连涨时跟踪止盈回撤幅度(%) breakeven_at=0, # 移动止损: 浮盈≥N%后止损线提升到保本(0=关闭) profit_lock_pct=0, # 利润锁定: 浮盈≥N%后止损线提升到N/2%(0=关闭) partial_exit_pct=0, # 部分止盈: 到达TP时卖出该比例(0=全卖, 50=卖一半) no_timeout_if_rising=False, # 超时保护: 如果股票在涨(浮盈>0且连涨)则不超时平仓 # ── v7.0 新增:交易时点优化 (网格搜索最优) ── buy_time='09:35', # 买入时间点 (最优: 09:35 开盘第一根5minK线) sell_time='13:40', # 卖出/估值时间点 (最优: 13:40 午后开盘35分钟) verbose=False): if start_date is None: start_date = START_DATE if end_date is None: end_date = date.today() # 可配参数回退到全局默认值 _shares = shares_per_trade or SHARES_PER_TRADE _pos_amt = position_amount or MAX_POSITION_AMOUNT _max_con = max_concurrent or MAX_CONCURRENT_POSITIONS _max_buys = max_buys_per_day if max_buys_per_day is not None else MAX_BUYS_PER_DAY _price_min = price_min if price_min is not None else PRICE_MIN _price_max = price_max if price_max is not None else PRICE_MAX # v5.2: 动态仓位管理模式 _dynamic_pos = False # 是否使用动态仓位 if total_capital > 0 and position_pct > 0: _dynamic_pos = True _shares = 0 # 标记为动态,不用固定值 # v5.1: 总资金约束模式 — 去掉人为限制 if total_capital > 0: if position_amount is None: _pos_amt = total_capital # 单只上限 = 总资金(无限) if max_concurrent is None: _max_con = 9999 # 持仓数无限 if max_buys_per_day is None: _max_buys = 9999 # 每日买入无限 if price_min is None: _price_min = 0 # 股价无下限 if price_max is None: _price_max = 999999 # 股价无上限 cash_balance = float(total_capital) if total_capital > 0 else None # None=不跟踪 # v5.3: 支持预加载数据(内存回测,无DB查询) _preloaded = preloaded is not None if _preloaded: # 从预加载数据中筛选指定日期范围 all_days = preloaded['trading_days'] trading_days = [d for d in all_days if start_date <= d <= end_date] _ohlc_cache = preloaded['ohlc'] _5min_cache = preloaded.get('5min', {}) _scan_cache = preloaded.get('scan', {}) else: trading_days = get_trading_days(conn, start_date, end_date) _ohlc_cache = None _5min_cache = None _scan_cache = None if not trading_days: if verbose: print("错误: 无交易日数据") return None if _preloaded: scan_days = len(set(d for (d, t) in _scan_cache.keys() if start_date <= d <= end_date)) else: with conn.cursor() as cur: cur.execute("SELECT count(DISTINCT scan_date) FROM stock_scan_history WHERE scan_date >= %s AND scan_date <= %s", (start_date, end_date)) scan_days = cur.fetchone()[0] if scan_days == 0: if verbose: print("错误: stock_scan_history 表无数据,请先运行 scan_history.py") return None if verbose: print(f" 扫描数据: {scan_days} 天可用", flush=True) position = {} # code -> (shares, total_cost, first_buy_date) trades = [] cooldown = {} daily_logs = [] net_cash = 0.0 equity_series = [] peak_profit = {} # code -> 历史最高浮盈百分比 sell_streak = {} # v4.2: code -> 连续卖出信号天数 max_capital_deployed = 0.0 # v4.2: 最大同时占用资金 # v6.0: 连涨跟踪 prev_close = {} # code -> 前一日收盘价 rising_days = {} # code -> 连续上涨天数 momentum_active = {} # code -> True/False, 连涨模式是否激活(转跟踪止盈) partial_sold = {} # code -> True/False, 是否已部分止盈 # v6.0: 移动止损 dynamic_sl = {} # code -> 动态止损线(浮盈%, 负数=亏损) # v5.2: 动态仓位计算函数 def calc_dynamic_shares(price, rate=80, triggered=1): """根据价格和信号强度计算买入股数(A股最小100股)""" if price <= 0 or not _dynamic_pos: return _shares # 回退到固定股数 # 基础仓位 = 总资金 × position_pct% base_amount = total_capital * position_pct / 100.0 # 信号强度加权 if signal_weight: if triggered >= 3 or rate >= 90: weight = 1.5 # 强信号: 1.5倍仓位 elif triggered >= 2 or rate >= 85: weight = 1.2 # 较强信号: 1.2倍仓位 else: weight = 1.0 # 普通信号: 标准仓位 base_amount *= weight # 不超过可用现金 if cash_balance is not None: base_amount = min(base_amount, cash_balance * 0.95) # 保留5%缓冲 # 计算股数(向下取整到100股) shares_raw = int(base_amount / price / 100) * 100 return max(shares_raw, 100) if shares_raw > 0 else 0 # v5: 统计5分钟数据覆盖情况 _5min_hit = 0 _5min_miss = 0 for i, t in enumerate(trading_days): ohlc_t = _ohlc_cache.get(t, {}) if _preloaded else get_day_ohlc(conn, t) if not ohlc_t: continue # v5/v7: 加载当天5分钟K线价格(支持任意时间点) if use_5min_prices: if _preloaded: # 新结构: {(date, time_str): {code: price}} fivemin_buy_t = _5min_cache.get((t, buy_time), {}) fivemin_sell_t = _5min_cache.get((t, sell_time), {}) else: fivemin_buy_t = get_5min_price_at(conn, t, buy_time) fivemin_sell_t = get_5min_price_at(conn, t, sell_time) else: fivemin_buy_t = {} fivemin_sell_t = {} day_log = {'date': t, 'buys': [], 'sells': [], 'holds': []} # ── 10:00 买入:用 T-1 的 16:30 扫描结果 ── prev = trading_days[i - 1] if i > 0 else None if prev is not None and len(position) < _max_con: scan_1630 = _scan_cache.get((prev, '16:30'), {}) if _preloaded else load_scan_results(conn, prev, '16:30') candidates = [] for code, info in scan_1630.items(): if info['display'] != '买入': continue if code in cooldown and t < cooldown[code]: continue if code in position: continue if code not in ohlc_t: continue if min_buy_rate > 0 and info['rate'] < min_buy_rate: continue if min_buy_triggered > 0 and info['triggered'] < min_buy_triggered: continue # v5/v7: 买入价 = 5分钟指定时点实时价 > mid=(开盘+收盘)/2 if use_5min_prices and code in fivemin_buy_t: buy_price = fivemin_buy_t[code] _5min_hit += 1 else: o, _, _, c = ohlc_t[code] buy_price = (o + c) / 2 # mid价 if use_5min_prices: _5min_miss += 1 if buy_price < _price_min or buy_price > _price_max: continue # v5.2: 动态仓位 — 在筛选阶段只做基本检查 if _dynamic_pos: est_shares = calc_dynamic_shares(buy_price, info['rate'], info['triggered']) if est_shares <= 0: continue est_cost = buy_price * est_shares else: est_cost = buy_price * _shares if est_cost > _pos_amt: continue # v5.1: 现金约束 if cash_balance is not None and est_cost > cash_balance: continue candidates.append((code, info['rate'], info['triggered'], info['reason'], buy_price)) candidates.sort(key=lambda x: (-x[1], -x[2])) bought = 0 for code, rate, tc, reason, buy_price in candidates: if bought >= _max_buys or len(position) >= _max_con: break # v5.2: 动态仓位 — 根据信号强度计算实际股数 if _dynamic_pos: buy_shares = calc_dynamic_shares(buy_price, rate, tc) if buy_shares <= 0: continue else: buy_shares = _shares cost = buy_price * buy_shares # v5.1: 再次检查现金(因为已经买了 bought 只) if cash_balance is not None and cost > cash_balance: # v5.2: 动态仓位模式下,尝试减少股数以适配现金 if _dynamic_pos and cash_balance > buy_price * 100: buy_shares = int(cash_balance / buy_price / 100) * 100 cost = buy_price * buy_shares if buy_shares <= 0: continue else: continue position[code] = (buy_shares, cost, t) peak_profit[code] = 0.0 trades.append({ 'date': t, 'time': buy_time, 'action': '买入', 'code': code, 'price': buy_price, 'shares': buy_shares, 'amount': cost, 'reason': reason, }) net_cash -= cost if cash_balance is not None: cash_balance -= cost day_log['buys'].append({'code': code, 'price': buy_price, 'amount': cost, 'reason': reason}) bought += 1 # ── 15:00 持仓管理 ── if position: scan_1130 = _scan_cache.get((t, '11:30'), {}) if _preloaded else load_scan_results(conn, t, '11:30') for code in list(position.keys()): if code not in ohlc_t: continue # v5/v7: 卖出价 = 5分钟指定时点实时价 > mid=(开盘+收盘)/2 if use_5min_prices and code in fivemin_sell_t: current_price = fivemin_sell_t[code] else: o, _, _, c = ohlc_t[code] current_price = (o + c) / 2 if use_5min_prices else c # 非5min模式保持原close close_p = current_price shares, total_cost, first_buy = position[code] profit_pct = (close_p * shares - total_cost) / total_cost * 100 if total_cost > 0 else 0 hold_days = (t - first_buy).days if isinstance(first_buy, date) else 0 # 更新峰值浮盈 if code in peak_profit: if profit_pct > peak_profit[code]: peak_profit[code] = profit_pct else: peak_profit[code] = max(0, profit_pct) info = scan_1130.get(code, {}) holding_disp = info.get('holding_display', '观望') holding_reason = info.get('holding_reason', '') h_rate = info.get('holding_rate', 0) action_taken = None # v6.0: 更新连涨天数 pc = prev_close.get(code, 0) if pc > 0 and close_p > pc: rising_days[code] = rising_days.get(code, 0) + 1 else: rising_days[code] = 0 is_rising = rising_days.get(code, 0) >= momentum_days # v6.0: 更新动态止损线 _effective_sl = stop_loss_pct # 默认止损线 if code in dynamic_sl: _effective_sl = dynamic_sl[code] # 移动止损/保本止损 if breakeven_at > 0 and profit_pct >= breakeven_at: new_sl = 0 # 保本 if profit_lock_pct > 0 and profit_pct >= profit_lock_pct: new_sl = -(profit_lock_pct / 2) # 锁定一半利润(负值=允许的最大亏损线提高到浮盈/2) if code not in dynamic_sl or new_sl > dynamic_sl.get(code, -999): dynamic_sl[code] = new_sl # v6.0: 检查连涨保护是否激活 if momentum_tp and take_profit_pct is not None and profit_pct >= take_profit_pct and is_rising: momentum_active[code] = True # 连涨中达到TP,激活跟踪模式 # 辅助: 清仓并记录 def _do_sell(reason_text, sell_shares=None): nonlocal net_cash, cash_balance s = sell_shares or shares sell_amount = close_p * s pft = sell_amount - (total_cost * s / shares if shares > 0 else total_cost) trades.append({ 'date': t, 'time': sell_time, 'action': '清仓', 'code': code, 'price': close_p, 'shares': s, 'amount': sell_amount, 'reason': reason_text, 'profit': pft, }) net_cash += sell_amount if cash_balance is not None: cash_balance += sell_amount return pft # ── 第1优先: 止损 (含v6.0移动止损) ── actual_sl = _effective_sl if code in dynamic_sl and profit_pct >= 0: # 动态止损: 如果当前浮盈从峰值回撤超过动态止损线 peak = peak_profit.get(code, 0) if peak > 0 and profit_pct < dynamic_sl[code]: actual_sl = dynamic_sl[code] # 用动态止损线 if stop_loss_pct is not None and profit_pct <= -stop_loss_pct: _do_sell(f'止损(浮亏{profit_pct:.1f}%≥{stop_loss_pct}%)') del position[code] peak_profit.pop(code, None) for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): _d.pop(code, None) cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) action_taken = '止损清仓' # v6.0: 移动止损触发 (保本止损 / 利润锁定) elif code in dynamic_sl and profit_pct <= dynamic_sl[code] and profit_pct > -(stop_loss_pct or 999): sl_line = dynamic_sl[code] _do_sell(f'移动止损(线{sl_line:.1f}%,现{profit_pct:.1f}%)') del position[code] peak_profit.pop(code, None) for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): _d.pop(code, None) cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) action_taken = '移动止损' # ── 第2优先: 固定止盈 (含v6.0连涨保护 & 部分止盈) ── elif take_profit_pct is not None and profit_pct >= take_profit_pct: # v6.0: 连涨保护 — 连涨中不固定止盈,转跟踪 if momentum_tp and is_rising: momentum_active[code] = True action_taken = f'连涨保护(连涨{rising_days.get(code,0)}天,转跟踪止盈)' # v6.0: 部分止盈 elif partial_exit_pct > 0 and not partial_sold.get(code, False): sell_shares = max(100, int(shares * partial_exit_pct / 100 / 100) * 100) if sell_shares >= shares: sell_shares = shares # 不够分就全卖 partial_cost = total_cost * sell_shares / shares if shares > 0 else 0 partial_revenue = close_p * sell_shares partial_profit = partial_revenue - partial_cost trades.append({ 'date': t, 'time': sell_time, 'action': '清仓', 'code': code, 'price': close_p, 'shares': sell_shares, 'amount': partial_revenue, 'reason': f'部分止盈{partial_exit_pct}%(浮盈{profit_pct:.1f}%≥{take_profit_pct}%)', 'profit': partial_profit, }) remaining_shares = shares - sell_shares remaining_cost = total_cost - partial_cost if remaining_shares <= 0: del position[code] peak_profit.pop(code, None) for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): _d.pop(code, None) cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) else: position[code] = (remaining_shares, remaining_cost, first_buy) partial_sold[code] = True # 部分止盈后启动跟踪止盈模式 momentum_active[code] = True net_cash += partial_revenue if cash_balance is not None: cash_balance += partial_revenue action_taken = f'部分止盈{partial_exit_pct}%' else: _do_sell(f'止盈(浮盈{profit_pct:.1f}%≥{take_profit_pct}%)') del position[code] peak_profit.pop(code, None) for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): _d.pop(code, None) cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) action_taken = '止盈清仓' # ── 第3优先: 跟踪止盈 (原有 + v6.0连涨跟踪) ── elif (trailing_start_pct > 0 and trailing_gap_pct > 0 and peak_profit.get(code, 0) >= trailing_start_pct and profit_pct <= peak_profit.get(code, 0) - trailing_gap_pct): pk = peak_profit.get(code, 0) _do_sell(f'跟踪止盈(峰{pk:.1f}%→现{profit_pct:.1f}%,回撤{pk-profit_pct:.1f}%≥{trailing_gap_pct}%)') del position[code] peak_profit.pop(code, None) for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): _d.pop(code, None) cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) action_taken = '跟踪止盈' # v6.0: 连涨模式激活后的跟踪止盈 elif momentum_active.get(code, False): m_trail_start = momentum_trail_start or (take_profit_pct or 10) m_trail_gap = momentum_trail_gap pk = peak_profit.get(code, 0) if pk >= m_trail_start and profit_pct <= pk - m_trail_gap: _do_sell(f'连涨跟踪止盈(峰{pk:.1f}%→现{profit_pct:.1f}%,回撤{pk-profit_pct:.1f}%≥{m_trail_gap}%)') del position[code] peak_profit.pop(code, None) for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): _d.pop(code, None) cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) action_taken = '连涨跟踪止盈' else: action_taken = f'连涨跟踪中(峰{pk:.1f}%,现{profit_pct:.1f}%,连涨{rising_days.get(code,0)}天)' # ── 第4优先: v4.1 最大持仓天数强制平仓 (v6.0:连涨保护) ── elif max_hold_days > 0 and hold_days >= max_hold_days: # v6.0: 如果连涨且盈利,不强制平仓 if no_timeout_if_rising and is_rising and profit_pct > 0: action_taken = f'超时但连涨保护(持仓{hold_days}天,连涨{rising_days.get(code,0)}天,浮盈{profit_pct:.1f}%)' else: _do_sell(f'超时平仓(持仓{hold_days}天≥{max_hold_days}天)') del position[code] peak_profit.pop(code, None) for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): _d.pop(code, None) cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) action_taken = '超时平仓' # ── 第5优先: 推荐卖出(v4.1: 可忽略, v4.2: 延迟确认) ── elif holding_disp == '卖出' and not ignore_sell_signal: # v4.2: 延迟卖出确认 sell_streak[code] = sell_streak.get(code, 0) + 1 streak = sell_streak[code] # v4 盈利保护逻辑 should_sell = True protect_msg = '' # v4.2: 连续卖出天数不足 if sell_confirm_days > 0 and streak < sell_confirm_days: should_sell = False protect_msg = f'延迟确认(连续{streak}/{sell_confirm_days}天)' if should_sell and profit_protect_pct > 0 and profit_pct >= profit_protect_pct: if sell_confirm_rate > 0 and h_rate < sell_confirm_rate: should_sell = False protect_msg = f'盈利保护(浮盈{profit_pct:.1f}%,卖出评分{h_rate}<{sell_confirm_rate})' elif sell_confirm_rate == 0: should_sell = False protect_msg = f'盈利保护(浮盈{profit_pct:.1f}%≥{profit_protect_pct}%)' if should_sell and sell_confirm_rate > 0 and h_rate < sell_confirm_rate: should_sell = False protect_msg = f'卖出评分不足({h_rate}<{sell_confirm_rate})' if should_sell: total_sell = close_p * shares profit = total_sell - total_cost trades.append({ 'date': t, 'time': sell_time, 'action': '清仓', 'code': code, 'price': close_p, 'shares': shares, 'amount': total_sell, 'reason': f'推荐卖出: {holding_reason}', 'profit': profit, }) del position[code] peak_profit.pop(code, None) sell_streak.pop(code, None) net_cash += total_sell if cash_balance is not None: cash_balance += total_sell cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) action_taken = '推荐卖出' else: action_taken = f'忽略卖出({protect_msg})' # ── 第6: 推荐卖出但被 ignore_sell_signal 跳过 ── elif holding_disp == '卖出' and ignore_sell_signal: action_taken = f'跳过推荐卖出(纯技术模式)' # ── 第7: 加仓 ── elif holding_disp == '加仓': sell_streak.pop(code, None) # 非卖出信号重置连续天数 # v5.2: 动态仓位 — 加仓也用动态计算 if _dynamic_pos: add_shares = calc_dynamic_shares(close_p, h_rate, 1) if add_shares <= 0: add_shares = 100 # 最低加100股 else: add_shares = _shares add_cost = close_p * add_shares new_total = total_cost + add_cost can_add = new_total <= _pos_amt if cash_balance is not None and add_cost > cash_balance: # v5.2: 动态模式下尝试减少加仓量 if _dynamic_pos and cash_balance > close_p * 100: add_shares = int(cash_balance / close_p / 100) * 100 add_cost = close_p * add_shares new_total = total_cost + add_cost can_add = new_total <= _pos_amt and add_shares > 0 else: can_add = False if can_add: position[code] = (shares + add_shares, new_total, first_buy) peak_profit[code] = 0.0 # 加仓后重置峰值 trades.append({ 'date': t, 'time': sell_time, 'action': '加仓', 'code': code, 'price': close_p, 'shares': add_shares, 'amount': add_cost, 'reason': f'推荐加仓: {holding_reason}', }) net_cash -= add_cost if cash_balance is not None: cash_balance -= add_cost action_taken = '推荐加仓' else: action_taken = f'推荐加仓(超限不执行)' else: sell_streak.pop(code, None) # 非卖出信号重置连续天数 action_taken = f'推荐{holding_disp}' day_log['holds'].append({ 'code': code, 'close': close_p, 'pct': round(profit_pct, 2), 'recommend': holding_disp, 'reason': holding_reason, 'action': action_taken, }) if action_taken and ('卖出' in action_taken or '清仓' in action_taken or '止盈' in action_taken or '平仓' in action_taken): if not action_taken.startswith('跳过') and not action_taken.startswith('忽略'): p = trades[-1].get('profit', 0) if trades else 0 day_log['sells'].append({ 'code': code, 'price': close_p, 'profit': p, 'reason': trades[-1].get('reason', ''), }) # v6.0: 更新所有持仓股票的前一日收盘价(用于次日连涨判断) for code in position: if code in ohlc_t: prev_close[code] = ohlc_t[code][3] # close # 当日收盘权益 + 最大占用资金 position_value = 0.0 position_cost_sum = 0.0 for code, (shares, total_cost, first_buy) in position.items(): position_cost_sum += total_cost # 累计成本 if code in ohlc_t: # v5/v7: 权益计算用5分钟卖出时点价,否则用日线收盘价 if use_5min_prices and code in fivemin_sell_t: eq_price = fivemin_sell_t[code] else: eq_price = ohlc_t[code][3] # daily close position_value += shares * eq_price equity_series.append(net_cash + position_value) if position_cost_sum > max_capital_deployed: max_capital_deployed = position_cost_sum daily_logs.append(day_log) # 回测结束仍有持仓 if position and trading_days: last_day = trading_days[-1] ohlc_last = _ohlc_cache.get(last_day, {}) if _preloaded else get_day_ohlc(conn, last_day) fivemin_last = _5min_cache.get(last_day, {}) if (_preloaded and use_5min_prices) else (get_5min_prices(conn, last_day) if use_5min_prices else {}) for code, (shares, total_cost, first_buy) in list(position.items()): if code in ohlc_last: # v5: 用5分钟价或mid if use_5min_prices and code in fivemin_last and 'sell' in fivemin_last[code]: close_p = fivemin_last[code]['sell'] elif use_5min_prices: o, _, _, c = ohlc_last[code] close_p = (o + c) / 2 else: close_p = ohlc_last[code][3] # daily close total_sell = close_p * shares trades.append({ 'date': last_day, 'time': '回测结束', 'action': '清仓', 'code': code, 'price': close_p, 'shares': shares, 'amount': total_sell, 'reason': '回测截止', 'profit': total_sell - total_cost, }) net_cash += total_sell if cash_balance is not None: cash_balance += total_sell stats = calc_stats(trades, start_date, end_date, equity_series=equity_series, max_capital_deployed=max_capital_deployed) # v5: 5分钟数据覆盖率 if use_5min_prices: total_5min = _5min_hit + _5min_miss coverage = (_5min_hit / total_5min * 100) if total_5min > 0 else 0 stats['5min_hit'] = _5min_hit stats['5min_miss'] = _5min_miss stats['5min_coverage'] = round(coverage, 1) if verbose: print(f" 5分钟数据: 命中{_5min_hit} 缺失{_5min_miss} 覆盖率{coverage:.1f}%", flush=True) # v5.1: 如果有总资金,将其加入stats if total_capital > 0: stats['total_capital'] = total_capital stats['final_cash'] = cash_balance stats['capital_utilization'] = round(stats['max_capital'] / total_capital * 100, 1) if total_capital > 0 else 0 # v5.2: 动态仓位信息 stats['dynamic_position'] = _dynamic_pos stats['position_pct'] = position_pct stats['signal_weight'] = signal_weight # v7.0: 交易时点信息 stats['buy_time'] = buy_time stats['sell_time'] = sell_time return { 'start_date': start_date, 'end_date': end_date, 'trading_days': trading_days, 'trades': trades, 'daily_logs': daily_logs, 'stats': stats, } def get_codes_with_data(conn, trade_date: date, min_days=30): """兼容旧接口""" with conn.cursor() as cur: cur.execute(""" SELECT code FROM stock_kline_daily WHERE trade_date <= %s GROUP BY code HAVING count(*) >= %s """, (trade_date, min_days)) return [r[0] for r in cur.fetchall()] # ─── 输出与主函数 ───────────────────────────────────── def main(): parser = argparse.ArgumentParser(description='推荐算法回测 v4.1(智能过滤 + 纯技术止盈止损)') parser.add_argument('--take-profit', type=float, default=None, metavar='PCT', help='止盈比例(如 10 表示 10%%)') parser.add_argument('--stop-loss', type=float, default=None, metavar='PCT', help='止损比例(如 5 表示 5%%)') parser.add_argument('--min-rate', type=int, default=0, metavar='N', help='v4: 买入最低评分 (如 85,默认0=不过滤)') parser.add_argument('--min-triggered', type=int, default=0, metavar='N', help='v4: 买入最低信号触发数 (如 2,默认0=不过滤)') parser.add_argument('--profit-protect', type=float, default=0, metavar='PCT', help='v4: 盈利保护线 (浮盈≥N%%时忽略弱卖出信号,默认0=关闭)') parser.add_argument('--sell-confirm', type=int, default=0, metavar='N', help='v4: 卖出确认评分 (holding_rate≥N才执行推荐卖出,默认0=不过滤)') parser.add_argument('--trailing-start', type=float, default=0, metavar='PCT', help='v4: 跟踪止盈激活线 (浮盈≥N%%后开始跟踪,默认0=关闭)') parser.add_argument('--trailing-gap', type=float, default=0, metavar='PCT', help='v4: 跟踪止盈回撤幅度 (从峰值回落N%%触发卖出,默认0=关闭)') parser.add_argument('--ignore-sell', action='store_true', help='v4.1: 忽略推荐卖出信号(纯技术模式)') parser.add_argument('--max-hold', type=int, default=0, metavar='DAYS', help='v4.1: 最大持仓天数 (超过则强制平仓,默认0=不限)') parser.add_argument('--shares', type=int, default=None, metavar='N', help='v4.1: 每笔股数 (默认1000)') parser.add_argument('--pos-amount', type=float, default=None, metavar='AMT', help='v4.1: 单只上限金额 (默认30000)') parser.add_argument('--max-concurrent', type=int, default=None, metavar='N', help='v4.1: 最大并发持仓数 (默认8)') parser.add_argument('--sell-confirm-days', type=int, default=0, metavar='N', help='v4.2: 连续N天卖出信号才执行 (默认0=立即)') parser.add_argument('--use-5min', action='store_true', help='v5/v7: 使用5分钟K线实时价格(买入用09:35,卖出用13:40,无则用mid)') parser.add_argument('--buy-time', type=str, default='09:35', metavar='HH:MM', help='v7: 买入时间点 (默认09:35, 如 10:00)') parser.add_argument('--sell-time', type=str, default='13:40', metavar='HH:MM', help='v7: 卖出/估值时间点 (默认13:40, 如 15:00)') parser.add_argument('--total-capital', type=float, default=0, metavar='AMT', help='v5.1: 总本金 (>0启用现金追踪)') parser.add_argument('--position-pct', type=float, default=0, metavar='PCT', help='v5.2: 单笔仓位占总资金百分比 (>0启用动态仓位, 如5=5%%)') parser.add_argument('--signal-weight', action='store_true', help='v5.2: 根据信号强度加权仓位(强信号1.5倍,较强1.2倍)') parser.add_argument('--start', type=str, default=None, metavar='YYYY-MM-DD') parser.add_argument('-v', '--verbose', action='store_true', help='输出每日详细操作') args = parser.parse_args() start_date = START_DATE if args.start: try: start_date = datetime.strptime(args.start, '%Y-%m-%d').date() except ValueError: print("错误: --start 格式应为 YYYY-MM-DD") return conn = get_db_conn() end = date.today() _shares = args.shares or SHARES_PER_TRADE _pos = args.pos_amount or MAX_POSITION_AMOUNT _con = args.max_concurrent or MAX_CONCURRENT_POSITIONS print("=" * 90) print(" 推荐算法回测 v7.0(智能过滤 + 真实资金收益率 + 最优交易时点)") print("=" * 90) print(f" 回测区间 : {start_date} ~ {end}") print(f" 规则 : {args.buy_time} 用 T-1 16:30扫描买入最多{MAX_BUYS_PER_DAY}只") print(f" {args.sell_time} 用 T 日 11:30扫描做加仓/清仓推荐") print(f" 股价区间 : {PRICE_MIN}~{PRICE_MAX} 元 单只上限 ¥{_pos:,.0f}") print(f" 每笔股数 : {_shares} 最大持仓 : {_con} 只 冷却期 {SELL_COOLDOWN_DAYS} 天") if args.take_profit is not None: print(f" 止盈 : ≥{args.take_profit}%") if args.stop_loss is not None: print(f" 止损 : ≥{args.stop_loss}%") if args.ignore_sell: print(f" v4.1 : 🚫 忽略推荐卖出信号(纯技术模式)") if args.max_hold > 0: print(f" v4.1 : ⏰ 最大持仓 {args.max_hold} 天") if args.sell_confirm_days > 0: print(f" v4.2 : 📅 连续{args.sell_confirm_days}天卖出信号才执行") if args.use_5min: print(f" v7 : 📊 使用5分钟K线实时价格(买入@{args.buy_time},卖出@{args.sell_time},无则用mid)") if args.min_rate > 0: print(f" v4 买入门槛 : 评分≥{args.min_rate}") if args.min_triggered > 0: print(f" v4 最低信号 : 触发数≥{args.min_triggered}") if args.profit_protect > 0: print(f" v4 盈利保护 : 浮盈≥{args.profit_protect}%时忽略弱卖出") if args.trailing_start > 0 and args.trailing_gap > 0: print(f" v4 跟踪止盈 : 激活线{args.trailing_start}%, 回撤{args.trailing_gap}%触发") print("-" * 90) import time t0 = time.time() result = run_backtest(conn, start_date=start_date, end_date=end, take_profit_pct=args.take_profit, stop_loss_pct=args.stop_loss, min_buy_rate=args.min_rate, min_buy_triggered=args.min_triggered, profit_protect_pct=args.profit_protect, sell_confirm_rate=args.sell_confirm, trailing_start_pct=args.trailing_start, trailing_gap_pct=args.trailing_gap, ignore_sell_signal=args.ignore_sell, max_hold_days=args.max_hold, shares_per_trade=args.shares, position_amount=args.pos_amount, max_concurrent=args.max_concurrent, sell_confirm_days=args.sell_confirm_days, use_5min_prices=args.use_5min, total_capital=args.total_capital, position_pct=args.position_pct, signal_weight=args.signal_weight, buy_time=args.buy_time, sell_time=args.sell_time, verbose=args.verbose) elapsed = time.time() - t0 if not result: conn.close() return trades = result['trades'] stats = result['stats'] daily_logs = result['daily_logs'] print(f"\n 回测完成! 耗时 {elapsed:.1f}s") # ── 每日交易流水 ── print("\n" + "=" * 90) print(" 每日交易流水") print("=" * 90) for log in daily_logs: if not log['buys'] and not log['sells'] and (not log['holds'] or not args.verbose): continue print(f"\n ─── {log['date']} ───") if log['buys']: print(f" {args.buy_time} 买入:") for b in log['buys']: print(f" 🟢 {b['code']} ¥{b['price']:.2f} × {_shares}股 = ¥{b['amount']:,.0f} ({b['reason']})") if log['holds']: print(f" {args.sell_time} 持仓推荐:") for h in log['holds']: act = h['action'] if '清仓' in act or '卖出' in act or '止盈' in act or '平仓' in act: if '跳过' in act or '忽略' in act: icon = '🛡️' else: icon = '🔴' elif '加仓' in act: icon = '🔵' else: icon = '⚪' print(f" {icon} {h['code']} 现价¥{h['close']:.2f} 浮盈{h['pct']:+.1f}% → {act} ({h['reason']})") if log['sells']: print(f" 15:00 执行卖出:") for s in log['sells']: p = s.get('profit', 0) icon = '✅' if p >= 0 else '❌' print(f" {icon} {s['code']} ¥{s['price']:.2f} 盈亏 ¥{p:+,.0f} ({s['reason']})") # 回测截止 end_holdings = [t for t in trades if t.get('reason') == '回测截止'] if end_holdings: print(f"\n ─── 回测截止 ───") for t in end_holdings: p = t.get('profit', 0) icon = '📈' if p >= 0 else '📉' print(f" {icon} {t['code']} ¥{t['price']:.2f} × {t['shares']}股 浮盈亏 ¥{p:+,.0f}") # ── 完整交易明细 ── if stats.get('closed_trades'): print("\n" + "=" * 90) print(" 完整交易明细(买入 → 卖出)") print("=" * 90) print(f" {'#':>3} {'代码':<8} {'买入日':>12} {'买入价':>8} {'卖出日':>12} " f"{'卖出价':>8} {'盈亏':>10} {'天数':>5} {'原因'}") print(" " + "-" * 85) for idx, ct in enumerate(stats['closed_trades'], 1): buy_price = sell_price = 0 for tr in trades: if tr['code'] == ct['code'] and tr['action'] == '买入': d = tr['date'] if isinstance(tr['date'], date) else datetime.strptime(str(tr['date']), '%Y-%m-%d').date() if d == ct['buy_date']: buy_price = tr['price'] break for tr in trades: if tr['code'] == ct['code'] and tr['action'] == '清仓': d = tr['date'] if isinstance(tr['date'], date) else datetime.strptime(str(tr['date']), '%Y-%m-%d').date() if d == ct['sell_date']: sell_price = tr['price'] break icon = '✅' if ct['profit'] > 0 else ('❌' if ct['profit'] < 0 else '➖') reason_short = ct['reason'][:24] print(f" {icon}{idx:>2} {ct['code']:<8} {ct['buy_date']} ¥{buy_price:>6.2f} " f"{ct['sell_date']} ¥{sell_price:>6.2f} ¥{ct['profit']:>+9,.0f} " f"{ct['hold_days']:>4}天 {reason_short}") # ── 核心统计 ── print("\n" + "=" * 90) print(" 回测统计") print("=" * 90) print(f" 总投入(周转) : ¥{stats['total_in']:>12,.2f}") print(f" 总收回 : ¥{stats['total_out']:>12,.2f}") print(f" 净盈亏 : ¥{stats['profit']:>12,.2f}") print("-" * 50) mc = stats.get('max_capital', 0) cp = stats.get('capital_pct', 0) ca = stats.get('capital_ann_pct', 0) print(f" 💰 最大占用资金 : ¥{mc:>10,.0f}") print(f" 💰 真实收益率 : {cp:>+8.2f}% (盈亏/最大占用资金)") ann_method = "简单年化" if stats['days'] < 90 else "复利年化(CAGR)" print(f" 💰 真实年化 : {ca:>+8.2f}% ★★★ 核心指标 ({ann_method}, {stats['days']}天)") print(f" 📊 周转收益率 : {stats['profit_pct']:>+8.2f}% (盈亏/总周转,参考)") print(f" 📊 周转年化 : {stats['annualized_pct']:>+8.2f}% (参考)") print(f" 最大回撤 : ¥{stats['max_drawdown']:>12,.2f} ({stats.get('max_drawdown_pct', 0):.2f}%)") print("-" * 50) print(f" 总交易笔数 : {stats['trade_count']}") print(f" 已平仓笔数 : {stats['closed_count']}") print(f" 胜 / 负 / 平 : {stats['wins']} / {stats['losses']} / {stats['flat']}") print(f" 胜率 : {stats['win_rate']:.1f}%") print(f" 平均持仓天数 : {stats['avg_hold_days']:.1f} 天") print(f" 平均盈利 : ¥{stats['avg_win']:>10,.2f}") print(f" 平均亏损 : ¥{stats['avg_loss']:>10,.2f}") print(f" 盈亏比 : {stats['profit_factor']:.2f}") print(f" 回测天数 : {stats['days']} 天") if '5min_coverage' in stats: print(f" 5分钟数据 : 命中{stats['5min_hit']} 缺失{stats['5min_miss']} 覆盖率{stats['5min_coverage']:.1f}%") # ── 个股盈亏 ── if stats['stock_pnl']: print("\n" + "=" * 70) print(" 个股盈亏明细") print("=" * 70) sorted_pnl = sorted(stats['stock_pnl'].items(), key=lambda x: x[1]['profit'], reverse=True) for code, info in sorted_pnl: wr = (info['wins'] / info['trades'] * 100) if info['trades'] > 0 else 0 icon = '✅' if info['profit'] > 0 else ('❌' if info['profit'] < 0 else '➖') print(f" {icon} {code:<8} ¥{info['profit']:>+9,.0f} {info['trades']:>3}笔 胜率{wr:>4.0f}%") # ── 保存 JSON ── out_dir = os.path.join(os.path.dirname(__file__), 'docs') os.makedirs(out_dir, exist_ok=True) out_file = os.path.join(out_dir, 'backtest_result.json') with open(out_file, 'w', encoding='utf-8') as f: json.dump({ 'start': str(result['start_date']), 'end': str(result['end_date']), 'version': 'v5' if args.use_5min else 'v4.2', 'params': { 'take_profit_pct': args.take_profit, 'stop_loss_pct': args.stop_loss, 'min_buy_rate': args.min_rate, 'min_buy_triggered': args.min_triggered, 'profit_protect_pct': args.profit_protect, 'sell_confirm_rate': args.sell_confirm, 'trailing_start_pct': args.trailing_start, 'trailing_gap_pct': args.trailing_gap, 'ignore_sell_signal': args.ignore_sell, 'max_hold_days': args.max_hold, 'sell_confirm_days': args.sell_confirm_days, 'use_5min_prices': args.use_5min, 'shares_per_trade': _shares, 'position_amount': _pos, 'max_concurrent': _con, 'price_range': [PRICE_MIN, PRICE_MAX], 'cooldown_days': SELL_COOLDOWN_DAYS, }, 'elapsed_seconds': round(elapsed, 2), 'stats': {k: v for k, v in stats.items() if k not in ('closed_trades', 'stock_pnl')}, 'stock_pnl': stats['stock_pnl'], 'trades': [{k: (str(v) if isinstance(v, date) else v) for k, v in tr.items()} for tr in trades], }, f, ensure_ascii=False, indent=2) print(f"\n结果已写入 {out_file}") conn.close() if __name__ == '__main__': main()