Files
stock/stock-html/backtest_recommend.py
T
freedakgmail 9c7d7abdd4 Initial commit
2026-07-17 18:49:35 +08:00

1291 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()