1291 lines
63 KiB
Python
1291 lines
63 KiB
Python
#!/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()
|