""" 智能交易引擎 v7.1 — 将回测验证的最优算法应用到实盘模拟交易 核心功能: 1. 读取用户的算法配置 (sim_algo_config) 2. 基于全景扫描信号生成买入决策 3. 基于持仓元数据 + 当前价格生成卖出/部分止盈决策 4. 动态仓位计算(信号加权) 5. 记录所有决策过程到 sim_trade_signals 6. 手续费模拟(佣金万2.5 + 印花税千1卖出) 算法来源: docs/algorithm_recommendation.md 回测验证: backtest_v6_analysis_report.md + backtest_v7_timing_comparison.md """ from datetime import date, datetime, time as dt_time from decimal import Decimal import traceback # ═══════════════════════════════════════════════════════ # 0. 手续费计算 # ═══════════════════════════════════════════════════════ # 佣金费率: 万分之2.5 (双向收取, 最低5元) COMMISSION_RATE = 0.00025 COMMISSION_MIN = 5.0 # 印花税费率: 千分之1 (仅卖出收取) STAMP_TAX_RATE = 0.001 # 滑点费率: 买入+0.3%, 卖出-0.3% SLIPPAGE_RATE = 0.003 # 涨跌停阈值 (实际10%/20%, 留0.2%缓冲) PRICE_LIMIT_NORMAL = 0.098 # 主板 10% (实际检测9.8%) PRICE_LIMIT_STAR_GEM = 0.198 # 科创板/创业板 20% (实际检测19.8%) def apply_slippage(price, trade_type): """ 应用滑点: 买入时价格上浮, 卖出时价格下浮 参数: price: 信号价格 trade_type: 'buy' 或 'sell' 返回: float: 滑点调整后的实际成交价 """ if trade_type == 'buy': return round(price * (1 + SLIPPAGE_RATE), 4) else: # sell return round(price * (1 - SLIPPAGE_RATE), 4) def get_price_limit(stock_code): """ 获取股票的涨跌停幅度 参数: stock_code: 股票代码 返回: float: 涨跌停比例 (0.098 或 0.198) """ # 科创板 (688xxx) 和 创业板 (300xxx/301xxx) 涨跌停20% if stock_code.startswith('688') or stock_code.startswith('300') or stock_code.startswith('301'): return PRICE_LIMIT_STAR_GEM # ST股涨跌停5% (简化: 不特别处理, 用主板标准) return PRICE_LIMIT_NORMAL def check_price_limit(stock_code, current_price, prev_close): """ 检查股票是否涨跌停 参数: stock_code: 股票代码 current_price: 当前价格 prev_close: 昨日收盘价 返回: dict: { 'at_up_limit': bool, # 是否涨停 'at_down_limit': bool, # 是否跌停 'change_pct': float, # 涨跌幅% } """ if not prev_close or prev_close <= 0: return {'at_up_limit': False, 'at_down_limit': False, 'change_pct': 0} limit = get_price_limit(stock_code) change_pct = (current_price - prev_close) / prev_close return { 'at_up_limit': change_pct >= limit, 'at_down_limit': change_pct <= -limit, 'change_pct': round(change_pct * 100, 2), } def calc_trade_fees(price, quantity, trade_type): """ 计算交易手续费 参数: price: 成交价格 quantity: 成交数量 trade_type: 'buy' 或 'sell' 返回: dict: { 'commission': float, # 佣金 'stamp_tax': float, # 印花税 'total_fee': float, # 总手续费 } """ amount = price * quantity # 佣金 (买卖双向, 最低5元) commission = max(amount * COMMISSION_RATE, COMMISSION_MIN) # 印花税 (仅卖出) stamp_tax = amount * STAMP_TAX_RATE if trade_type == 'sell' else 0.0 return { 'commission': round(commission, 2), 'stamp_tax': round(stamp_tax, 2), 'total_fee': round(commission + stamp_tax, 2), } # ═══════════════════════════════════════════════════════ # 1. 算法配置管理 # ═══════════════════════════════════════════════════════ DEFAULT_CONFIG = { 'algo_name': 'PE50G3+BE8', 'take_profit_pct': 12.0, 'stop_loss_pct': 8.0, 'ignore_sell_signal': False, 'sell_confirm_days': 3, 'max_hold_days': 60, 'no_timeout_if_rising': True, 'total_capital': 200000.0, 'position_pct': 8.0, 'signal_weight': True, 'partial_exit_pct': 50, 'momentum_trail_gap': 3.0, 'breakeven_at': 8.0, 'momentum_tp': False, 'momentum_days': 3, 'buy_time': '09:35', 'sell_time': '13:40', } def get_user_algo_config(conn, user_id): """获取用户的算法配置,不存在则返回默认配置""" from psycopg2.extras import RealDictCursor with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT * FROM sim_algo_config WHERE user_id = %s AND is_active = TRUE """, (user_id,)) row = cur.fetchone() if row: return dict(row) return dict(DEFAULT_CONFIG) def save_user_algo_config(conn, user_id, config): """保存/更新用户的算法配置""" with conn.cursor() as cur: cur.execute(""" INSERT INTO sim_algo_config (user_id, algo_name, take_profit_pct, stop_loss_pct, ignore_sell_signal, sell_confirm_days, max_hold_days, no_timeout_if_rising, total_capital, position_pct, signal_weight, partial_exit_pct, momentum_trail_gap, breakeven_at, momentum_tp, momentum_days, buy_time, sell_time, is_active) VALUES (%(user_id)s, %(algo_name)s, %(take_profit_pct)s, %(stop_loss_pct)s, %(ignore_sell_signal)s, %(sell_confirm_days)s, %(max_hold_days)s, %(no_timeout_if_rising)s, %(total_capital)s, %(position_pct)s, %(signal_weight)s, %(partial_exit_pct)s, %(momentum_trail_gap)s, %(breakeven_at)s, %(momentum_tp)s, %(momentum_days)s, %(buy_time)s, %(sell_time)s, TRUE) ON CONFLICT (user_id) DO UPDATE SET algo_name = EXCLUDED.algo_name, take_profit_pct = EXCLUDED.take_profit_pct, stop_loss_pct = EXCLUDED.stop_loss_pct, ignore_sell_signal = EXCLUDED.ignore_sell_signal, sell_confirm_days = EXCLUDED.sell_confirm_days, max_hold_days = EXCLUDED.max_hold_days, no_timeout_if_rising = EXCLUDED.no_timeout_if_rising, total_capital = EXCLUDED.total_capital, position_pct = EXCLUDED.position_pct, signal_weight = EXCLUDED.signal_weight, partial_exit_pct = EXCLUDED.partial_exit_pct, momentum_trail_gap = EXCLUDED.momentum_trail_gap, breakeven_at = EXCLUDED.breakeven_at, momentum_tp = EXCLUDED.momentum_tp, momentum_days = EXCLUDED.momentum_days, buy_time = EXCLUDED.buy_time, sell_time = EXCLUDED.sell_time, is_active = TRUE, updated_at = NOW() """, {**config, 'user_id': user_id}) conn.commit() def apply_template(conn, user_id, template_name): """从算法模板创建用户配置""" from psycopg2.extras import RealDictCursor with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT * FROM algo_templates WHERE name = %s", (template_name,)) tpl = cur.fetchone() if not tpl: return False config = { 'algo_name': tpl['display_name'], 'take_profit_pct': float(tpl['take_profit_pct']), 'stop_loss_pct': float(tpl['stop_loss_pct']), 'ignore_sell_signal': tpl['ignore_sell_signal'], 'sell_confirm_days': tpl['sell_confirm_days'], 'max_hold_days': tpl['max_hold_days'], 'no_timeout_if_rising': tpl['no_timeout_if_rising'], 'total_capital': 200000.0, # 用户需自行设置 'position_pct': float(tpl['position_pct']), 'signal_weight': tpl['signal_weight'], 'partial_exit_pct': tpl['partial_exit_pct'], 'momentum_trail_gap': float(tpl['momentum_trail_gap']), 'breakeven_at': float(tpl['breakeven_at']), 'momentum_tp': tpl['momentum_tp'], 'momentum_days': tpl['momentum_days'], 'buy_time': tpl.get('buy_time', '09:35'), 'sell_time': tpl.get('sell_time', '13:40'), } save_user_algo_config(conn, user_id, config) return True # ═══════════════════════════════════════════════════════ # 2. 仓位计算 # ═══════════════════════════════════════════════════════ def calc_dynamic_shares(available_cash, stock_price, config, recommend_rate=80, triggered_count=1): """ 计算动态仓位股数(与回测引擎 backtest_recommend.py 一致的逻辑) 参数: available_cash: 可用资金 stock_price: 当前股价 config: 算法配置 recommend_rate: 信号推荐率 (0-100) triggered_count: 触发信号数 返回: int: 建议买入股数(100的整数倍) """ total_capital = float(config.get('total_capital', 200000)) position_pct = float(config.get('position_pct', 8)) use_signal_weight = config.get('signal_weight', True) if stock_price <= 0 or available_cash <= 0: return 0 # 基础仓位金额 = 总资金 * 仓位百分比 base_amount = total_capital * position_pct / 100.0 # 信号加权: 强信号加大仓位 if use_signal_weight: weight = 1.0 if recommend_rate >= 90: weight = 1.5 # 强信号: 150%仓位 elif recommend_rate >= 80: weight = 1.2 # 中强信号: 120%仓位 elif recommend_rate >= 70: weight = 1.0 # 标准信号: 100% else: weight = 0.7 # 弱信号: 70% # 多信号触发加成 if triggered_count >= 3: weight *= 1.2 elif triggered_count >= 2: weight *= 1.1 base_amount *= weight # 不超过可用现金 base_amount = min(base_amount, available_cash * 0.95) # 留5%缓冲 # 计算股数 (100的整数倍) shares = int(base_amount / stock_price / 100) * 100 return max(shares, 0) # ═══════════════════════════════════════════════════════ # 3. 持仓元数据管理 # ═══════════════════════════════════════════════════════ def get_position_meta(conn, user_id, stock_code): """获取单只股票的持仓元数据""" from psycopg2.extras import RealDictCursor with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT * FROM sim_position_meta WHERE user_id = %s AND stock_code = %s """, (user_id, stock_code)) return cur.fetchone() def get_all_position_meta(conn, user_id): """获取用户所有持仓的元数据""" from psycopg2.extras import RealDictCursor with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT m.*, p.quantity, p.avg_cost::float, p.current_price::float FROM sim_position_meta m JOIN sim_positions p ON m.user_id = p.user_id AND m.stock_code = p.stock_code WHERE m.user_id = %s AND p.quantity > 0 """, (user_id,)) return cur.fetchall() def create_position_meta(conn, user_id, stock_code, buy_price, buy_date, shares, reason='', signal_rate=0, triggered_count=0): """创建新的持仓元数据""" with conn.cursor() as cur: cur.execute(""" INSERT INTO sim_position_meta (user_id, stock_code, buy_date, buy_price, buy_reason, buy_signal_rate, buy_triggered_count, max_price_since_buy, current_shares, original_shares, last_update_date) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (user_id, stock_code) DO UPDATE SET buy_date = EXCLUDED.buy_date, buy_price = EXCLUDED.buy_price, buy_reason = EXCLUDED.buy_reason, buy_signal_rate = EXCLUDED.buy_signal_rate, buy_triggered_count = EXCLUDED.buy_triggered_count, max_price_since_buy = EXCLUDED.max_price_since_buy, days_held = 0, consecutive_up_days = 0, consecutive_sell_signals = 0, partial_exit_done = FALSE, breakeven_active = FALSE, momentum_trailing_active = FALSE, momentum_high_price = 0, current_shares = EXCLUDED.current_shares, original_shares = EXCLUDED.original_shares, last_update_date = EXCLUDED.last_update_date, updated_at = NOW() """, (user_id, stock_code, buy_date, buy_price, reason, signal_rate, triggered_count, buy_price, shares, shares, buy_date)) def update_position_meta(conn, user_id, stock_code, updates): """更新持仓元数据""" set_clauses = [] values = [] for key, val in updates.items(): set_clauses.append(f"{key} = %s") values.append(val) set_clauses.append("updated_at = NOW()") values.extend([user_id, stock_code]) with conn.cursor() as cur: cur.execute(f""" UPDATE sim_position_meta SET {', '.join(set_clauses)} WHERE user_id = %s AND stock_code = %s """, values) def delete_position_meta(conn, user_id, stock_code): """删除持仓元数据(清仓时调用)""" with conn.cursor() as cur: cur.execute(""" DELETE FROM sim_position_meta WHERE user_id = %s AND stock_code = %s """, (user_id, stock_code)) # ═══════════════════════════════════════════════════════ # 4. 信号日志 # ═══════════════════════════════════════════════════════ def _fix_all_sequences(conn): """修复所有模拟交易相关表的序列号,确保不会产生主键冲突""" seq_table_map = [ ('sim_trade_signals_id_seq', 'sim_trade_signals'), ('sim_positions_id_seq', 'sim_positions'), ('sim_trades_id_seq', 'sim_trades'), ('sim_position_meta_id_seq', 'sim_position_meta'), ('sim_algo_config_id_seq', 'sim_algo_config'), ('sim_daily_stats_id_seq', 'sim_daily_stats'), ] try: with conn.cursor() as cur: for seq_name, table_name in seq_table_map: try: cur.execute(f""" SELECT setval('{seq_name}', COALESCE((SELECT MAX(id) FROM {table_name}), 0) + 1, false ) """) except Exception: pass # 某些表可能不存在,跳过 except Exception as e: print(f"⚠️ 修复序列失败: {e}", flush=True) def _fix_trade_signals_sequence(conn): """修复 sim_trade_signals 序列号(向后兼容)""" _fix_all_sequences(conn) def log_signal(conn, user_id, signal_date, stock_code, stock_name, action, reason, algo_rule, signal_price=None, buy_price=None, profit_pct=None, executed=False, execute_price=None, execute_shares=None): """记录交易信号到日志""" params = (user_id, signal_date, datetime.now().time(), stock_code, stock_name, action, reason, algo_rule, signal_price, buy_price, profit_pct, executed, execute_price, execute_shares) insert_sql = """ INSERT INTO sim_trade_signals (user_id, signal_date, signal_time, stock_code, stock_name, action, reason, algo_rule, signal_price, buy_price, profit_pct, executed, execute_price, execute_shares) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ try: with conn.cursor() as cur: cur.execute("SAVEPOINT sp_log_signal") cur.execute(insert_sql, params) except Exception as e: if 'duplicate key' in str(e): with conn.cursor() as cur: cur.execute("ROLLBACK TO SAVEPOINT sp_log_signal") print(f"⚠️ 信号日志主键冲突,正在修复序列...", flush=True) _fix_trade_signals_sequence(conn) # 修复后重试一次 with conn.cursor() as cur: cur.execute(insert_sql, params) print(f"✅ 序列修复成功,信号已记录", flush=True) else: raise # ═══════════════════════════════════════════════════════ # 5. 核心交易决策引擎 # ═══════════════════════════════════════════════════════ def generate_sell_decisions(conn, user_id, config, current_prices, scan_map=None): """ 生成卖出/部分止盈决策 参数: conn: 数据库连接 user_id: 用户ID config: 算法配置 (from get_user_algo_config) current_prices: {stock_code: current_price} 当前价格 scan_map: {stock_code: scan_data} 今日扫描结果 (可选) 返回: list[dict]: 卖出决策列表 [{'code': str, 'action': 'sell'|'partial_sell', 'shares': int, 'reason': str, 'rule': str, 'price': float}] """ tp_pct = float(config.get('take_profit_pct', 12)) sl_pct = float(config.get('stop_loss_pct', 8)) ign_sell = config.get('ignore_sell_signal', False) confirm_days = int(config.get('sell_confirm_days', 3)) max_hold = int(config.get('max_hold_days', 60)) no_timeout_rising = config.get('no_timeout_if_rising', True) pe_pct = int(config.get('partial_exit_pct', 0)) mt_gap = float(config.get('momentum_trail_gap', 3)) be_at = float(config.get('breakeven_at', 0)) mt_active = config.get('momentum_tp', False) mt_days = int(config.get('momentum_days', 3)) today = date.today() decisions = [] # 获取所有持仓及其元数据 positions = get_all_position_meta(conn, user_id) print(f"[智能引擎] 卖出分析: {len(positions)}只持仓 (TP={tp_pct}%/SL={sl_pct}%/PE={pe_pct}%/BE@{be_at}%/MaxHold={max_hold}天)") for pos in positions: code = pos['stock_code'] price = current_prices.get(code) if not price or price <= 0: print(f" {code} 无价格,跳过") continue buy_price = float(pos['buy_price']) current_shares = pos.get('current_shares', 0) or pos.get('quantity', 0) if current_shares <= 0: continue profit_pct_now = (price - buy_price) / buy_price * 100 days_held = pos.get('days_held', 0) or 0 print(f" {code} 成本{buy_price:.2f} 现价{price:.2f} 盈亏{profit_pct_now:+.1f}% 持仓{days_held}天") consec_up = pos.get('consecutive_up_days', 0) or 0 pe_done = pos.get('partial_exit_done', False) be_active_now = pos.get('breakeven_active', False) mt_trailing = pos.get('momentum_trailing_active', False) mt_high = float(pos.get('momentum_high_price', 0) or 0) # ── 规则1: 止损 ── effective_sl = -sl_pct if be_active_now: effective_sl = 0 # 保本止损: 止损线在成本价 if profit_pct_now <= effective_sl: rule = 'BE_SL' if be_active_now else 'SL' reason = f"{'保本止损' if be_active_now else '止损'}: 浮盈{profit_pct_now:.1f}% ≤ {effective_sl:.1f}%" decisions.append({ 'code': code, 'action': 'sell', 'shares': current_shares, 'reason': reason, 'rule': rule, 'price': price }) continue # ── 规则2: 动量跟踪止盈 ── if mt_trailing and mt_high > 0: drop_from_high = (mt_high - price) / mt_high * 100 if drop_from_high >= mt_gap: reason = f"动量跟踪止盈: 从最高{mt_high:.2f}回落{drop_from_high:.1f}%≥{mt_gap}%" decisions.append({ 'code': code, 'action': 'sell', 'shares': current_shares, 'reason': reason, 'rule': 'MT_TP', 'price': price }) continue # 更新最高价 if price > mt_high: update_position_meta(conn, user_id, code, {'momentum_high_price': price}) # ── 规则3: 止盈 / 部分止盈 ── if profit_pct_now >= tp_pct: # 检查是否应启动动量跟踪(连涨中不卖) if mt_active and consec_up >= mt_days: if not mt_trailing: update_position_meta(conn, user_id, code, { 'momentum_trailing_active': True, 'momentum_high_price': price, }) log_signal(conn, user_id, today, code, '', 'hold', f"连涨{consec_up}天+盈利{profit_pct_now:.1f}%≥TP,启动动量跟踪", 'MT_START', price, buy_price, profit_pct_now) continue # 连涨中不触发止盈 if pe_pct > 0 and not pe_done: # 部分止盈 sell_shares = int(current_shares * pe_pct / 100 / 100) * 100 sell_shares = max(sell_shares, 100) # 至少100股 sell_shares = min(sell_shares, current_shares) reason = f"部分止盈{pe_pct}%: 盈利{profit_pct_now:.1f}%≥TP{tp_pct}%, 卖出{sell_shares}股" decisions.append({ 'code': code, 'action': 'partial_sell', 'shares': sell_shares, 'reason': reason, 'rule': 'PE', 'price': price }) # 剩余部分启动跟踪止盈 update_position_meta(conn, user_id, code, { 'partial_exit_done': True, 'momentum_trailing_active': True, 'momentum_high_price': price, }) else: # 全部止盈 reason = f"止盈: 盈利{profit_pct_now:.1f}%≥TP{tp_pct}%" decisions.append({ 'code': code, 'action': 'sell', 'shares': current_shares, 'reason': reason, 'rule': 'TP', 'price': price }) continue # ── 规则4: 激活保本止损 ── if be_at > 0 and not be_active_now and profit_pct_now >= be_at: update_position_meta(conn, user_id, code, {'breakeven_active': True}) log_signal(conn, user_id, today, code, '', 'hold', f"盈利{profit_pct_now:.1f}%≥{be_at}%,保本止损已激活", 'BE_ACTIVATE', price, buy_price, profit_pct_now) # ── 规则5: 超时平仓 ── if max_hold > 0 and days_held >= max_hold: # 连涨且盈利时不超时 if no_timeout_rising and consec_up >= 2 and profit_pct_now > 0: log_signal(conn, user_id, today, code, '', 'hold', f"持仓{days_held}天≥{max_hold}天,但连涨{consec_up}天+盈利中,不平仓", 'NTO', price, buy_price, profit_pct_now) else: reason = f"超时平仓: 持仓{days_held}天≥{max_hold}天" decisions.append({ 'code': code, 'action': 'sell', 'shares': current_shares, 'reason': reason, 'rule': 'TIMEOUT', 'price': price }) continue # ── 规则6: 扫描卖出信号 ── if not ign_sell and scan_map: scan = scan_map.get(code) if scan: from services.stock_algorithms import compute_recommend sig_type, display, reason_txt, rate = compute_recommend( scan.get('signal_status'), scan.get('indicators'), scan.get('triggered_count'), is_holding=True ) if sig_type == 'sell': consec_sell = pos.get('consecutive_sell_signals', 0) or 0 new_consec = consec_sell + 1 update_position_meta(conn, user_id, code, { 'consecutive_sell_signals': new_consec }) if confirm_days <= 0 or new_consec >= confirm_days: reason = f"卖出信号确认: {reason_txt} (连续{new_consec}天)" decisions.append({ 'code': code, 'action': 'sell', 'shares': current_shares, 'reason': reason, 'rule': 'SCAN_SELL', 'price': price }) else: log_signal(conn, user_id, today, code, '', 'hold', f"卖出信号{new_consec}/{confirm_days}天: {reason_txt}", 'SELL_WAIT', price, buy_price, profit_pct_now) else: # 非卖出信号,重置连续卖出计数 if pos.get('consecutive_sell_signals', 0): update_position_meta(conn, user_id, code, { 'consecutive_sell_signals': 0 }) return decisions def generate_buy_decisions(conn, user_id, config, scan_map, current_prices, holding_codes): """ 生成买入决策 参数: conn: 数据库连接 user_id: 用户ID config: 算法配置 scan_map: {stock_code: scan_data} 今日扫描结果 current_prices: {stock_code: price} 当前价格 holding_codes: set 当前持仓股票代码 返回: list[dict]: 买入决策列表 [{'code': str, 'name': str, 'shares': int, 'reason': str, 'price': float, 'rate': int}] """ from services.stock_algorithms import compute_recommend total_capital = float(config.get('total_capital', 200000)) # 计算可用现金 from psycopg2.extras import RealDictCursor with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT COALESCE(SUM(quantity * avg_cost), 0)::float as total_invested FROM sim_positions WHERE user_id = %s AND quantity > 0 """, (user_id,)) row = cur.fetchone() total_invested = row['total_invested'] if row else 0.0 available_cash = total_capital - total_invested if available_cash <= 0: print(f"[智能引擎] 可用现金不足: ¥{available_cash:,.0f} (总本金¥{total_capital:,.0f} - 已投¥{total_invested:,.0f})") return [] print(f"[智能引擎] 可用现金: ¥{available_cash:,.0f} (总本金¥{total_capital:,.0f} - 已投¥{total_invested:,.0f})") # 候选买入列表 buy_candidates = [] for code, scan in scan_map.items(): if code in holding_codes: continue # 过滤退市、ST、*ST股票 — 不参与智能交易 stock_name = scan.get('name', '') if any(tag in stock_name for tag in ('退', 'ST', '*ST', '退市')): continue sig_type, display, reason, rate = compute_recommend( scan.get('signal_status'), scan.get('indicators'), scan.get('triggered_count'), is_holding=False ) if sig_type != 'buy': continue price = current_prices.get(code) if not price or price <= 0: continue triggered = scan.get('triggered_count', 0) or 0 buy_candidates.append({ 'code': code, 'name': scan.get('name', ''), 'rate': rate, 'triggered': triggered, 'reason': reason, 'price': price, }) # 按推荐率 + 触发信号数排序 buy_candidates.sort(key=lambda x: (x['rate'], x['triggered']), reverse=True) print(f"[智能引擎] 买入候选: {len(buy_candidates)}只 (从{len(scan_map)}只扫描结果中筛选)") for c in buy_candidates[:5]: print(f" 候选: {c['code']} {c['name']} rate={c['rate']} triggered={c['triggered']} price={c['price']:.2f}") # 生成买入决策(按可用资金约束) decisions = [] remaining_cash = available_cash for cand in buy_candidates: if remaining_cash <= 0: break shares = calc_dynamic_shares( remaining_cash, cand['price'], config, recommend_rate=cand['rate'], triggered_count=cand['triggered'] ) if shares <= 0: continue cost = shares * cand['price'] if cost > remaining_cash: shares = int(remaining_cash / cand['price'] / 100) * 100 if shares <= 0: continue cost = shares * cand['price'] decisions.append({ 'code': cand['code'], 'name': cand['name'], 'shares': shares, 'reason': cand['reason'], 'price': cand['price'], 'rate': cand['rate'], 'triggered': cand['triggered'], }) remaining_cash -= cost return decisions # ═══════════════════════════════════════════════════════ # 6. 每日持仓状态更新 # ═══════════════════════════════════════════════════════ def update_daily_position_status(conn, user_id, current_prices): """ 每日更新持仓元数据(在生成卖出决策前调用) - 更新 days_held (持仓天数) - 更新 max_price_since_buy (最高价) - 更新 consecutive_up_days (连涨天数) """ today = date.today() positions = get_all_position_meta(conn, user_id) for pos in positions: code = pos['stock_code'] price = current_prices.get(code) if not price or price <= 0: continue buy_date = pos['buy_date'] if isinstance(buy_date, str): buy_date = datetime.strptime(buy_date, '%Y-%m-%d').date() days_held = (today - buy_date).days max_price = max(float(pos.get('max_price_since_buy', 0) or 0), price) # 判断是否连涨(当前价 > 昨天的最高价估计 - 简化处理) prev_price = float(pos.get('current_price', 0) or pos.get('buy_price', 0)) consec_up = pos.get('consecutive_up_days', 0) or 0 if price > prev_price: consec_up += 1 else: consec_up = 0 # 更新部分止盈后的当前股数 current_shares = pos.get('quantity', 0) or pos.get('current_shares', 0) update_position_meta(conn, user_id, code, { 'days_held': days_held, 'max_price_since_buy': max_price, 'consecutive_up_days': consec_up, 'current_shares': current_shares, 'last_update_date': today, }) conn.commit() # ═══════════════════════════════════════════════════════ # 7. 主执行函数 # ═══════════════════════════════════════════════════════ def execute_smart_trade(conn, user_id, scan_date=None): """ 智能交易主执行函数 — 替代旧的 execute_auto_trade_for_user 流程: 1. 读取算法配置 2. 获取当前持仓和价格 3. 更新持仓状态 4. 生成卖出决策并执行 5. 生成买入决策并执行 6. 记录所有信号 返回: dict: {'success': bool, 'results': list, 'signals': int} """ from psycopg2.extras import RealDictCursor today = date.today() now = datetime.now().time() print(f"[智能引擎] 开始为用户{user_id}执行智能交易...") # 预防性修复序列号,避免主键冲突 _fix_trade_signals_sequence(conn) try: # 1. 读取算法配置 config = get_user_algo_config(conn, user_id) algo_name = config.get('algo_name', 'unknown') print(f"[智能引擎] 算法: {algo_name}") cur = conn.cursor(cursor_factory=RealDictCursor) # 2. 获取持仓 cur.execute(""" SELECT stock_code, stock_name, quantity, avg_cost::float, current_price::float FROM sim_positions WHERE user_id = %s AND quantity > 0 """, (user_id,)) positions = cur.fetchall() holding_codes = {p['stock_code'] for p in positions} # 3. 读取扫描结果 if scan_date: cur.execute(""" SELECT code, name, triggered_count, signal_status, indicators FROM stock_signal_scan WHERE scan_date = %s """, (scan_date,)) else: cur.execute(""" SELECT code, name, triggered_count, signal_status, indicators FROM stock_signal_scan WHERE scan_date = ( SELECT MAX(scan_date) FROM stock_signal_scan WHERE scan_date <= %s ) """, (today,)) scan_rows = cur.fetchall() scan_map = {r['code']: r for r in scan_rows} if not scan_map: print(f"[智能引擎] 无可用扫描数据,跳过") return {'success': True, 'results': [], 'signals': 0} print(f"[智能引擎] 扫描数据: {len(scan_map)}只, 持仓: {len(holding_codes)}只") # 4. 批量获取所有相关股票的当前价格(单次查询,避免逐个连接) all_codes = holding_codes | set(scan_map.keys()) current_prices = {} if all_codes: cur.execute(""" SELECT code, price::float FROM stock_realtime_price WHERE code = ANY(%s) AND price > 0 """, (list(all_codes),)) for row in cur.fetchall(): current_prices[row['code']] = row['price'] # 对于持仓股票,优先使用 sim_positions.current_price(由持仓更新服务刷新,通常更新) # stock_realtime_price 可能滞后(仅在全景扫描时更新) for pos in positions: code = pos['stock_code'] cp = float(pos.get('current_price', 0) or 0) if cp > 0: old_price = current_prices.get(code, 0) current_prices[code] = cp if old_price > 0 and abs(cp - old_price) / old_price > 0.001: print(f" [价格修正] {code} realtime={old_price:.2f} → position={cp:.2f}") price_hit = sum(1 for c in holding_codes if c in current_prices) print(f"[智能引擎] 实时价格: {len(current_prices)}/{len(all_codes)}只, " f"持仓覆盖: {price_hit}/{len(holding_codes)}只") # 4b. 批量获取昨日收盘价 (用于涨跌停检测) prev_close_prices = {} if all_codes: cur.execute(""" SELECT code, close::float as prev_close FROM stock_kline_daily WHERE code = ANY(%s) AND trade_date = ( SELECT MAX(trade_date) FROM stock_kline_daily WHERE trade_date < %s ) """, (list(all_codes), today)) for row in cur.fetchall(): prev_close_prices[row['code']] = row['prev_close'] print(f"[智能引擎] 昨收价: {len(prev_close_prices)}只 (涨跌停检测)") # 4c. 获取今日买入的股票 (T+1规则: 当日买入不可当日卖出) cur.execute(""" SELECT DISTINCT stock_code FROM sim_trades WHERE user_id = %s AND trade_date = %s AND trade_type = 'buy' """, (user_id, today)) today_bought_codes = {r['stock_code'] for r in cur.fetchall()} if today_bought_codes: print(f"[智能引擎] T+1限制: {len(today_bought_codes)}只今日已买入, 不可卖出") # 5. 更新每日持仓状态 update_daily_position_status(conn, user_id, current_prices) results = [] skipped_limit = [] # 因涨跌停跳过的交易 skipped_t1 = [] # 因T+1跳过的交易 # 6. 生成并执行卖出决策 sell_decisions = generate_sell_decisions(conn, user_id, config, current_prices, scan_map) print(f"[智能引擎] 卖出决策: {len(sell_decisions)}笔") for dec in sell_decisions: code = dec['code'] price = dec['price'] shares = dec['shares'] action = dec['action'] pos = next((p for p in positions if p['stock_code'] == code), None) if not pos: continue # T+1规则: 当日买入的股票不可当日卖出 if code in today_bought_codes: skipped_t1.append(code) print(f"[智能引擎] ⏳ T+1限制 {code} 今日买入,不可卖出") log_signal(conn, user_id, today, code, pos.get('stock_name', ''), 'hold', f"T+1限制: 今日买入不可卖出 ({dec['reason']})", 'T+1', price, pos['avg_cost'], (price - pos['avg_cost']) / pos['avg_cost'] * 100 if pos['avg_cost'] else 0, False, None, None) continue # 涨跌停检查: 跌停时无法卖出 prev_close = prev_close_prices.get(code) if prev_close: limit_info = check_price_limit(code, price, prev_close) if limit_info['at_down_limit']: skipped_limit.append(f"{code}(跌停{limit_info['change_pct']}%)") print(f"[智能引擎] 🚫 跌停限制 {code} 涨跌幅{limit_info['change_pct']}%,无法卖出") log_signal(conn, user_id, today, code, pos.get('stock_name', ''), 'hold', f"跌停无法卖出 ({dec['reason']})", 'LIMIT', price, pos['avg_cost'], (price - pos['avg_cost']) / pos['avg_cost'] * 100 if pos['avg_cost'] else 0, False, None, None) continue # 应用滑点: 卖出价格下浮 price = apply_slippage(price, 'sell') if action == 'partial_sell': # 部分止盈 shares = min(shares, pos['quantity']) if shares <= 0: continue remaining = pos['quantity'] - shares sell_fees = calc_trade_fees(price, shares, 'sell') realized_pnl = (price - pos['avg_cost']) * shares - sell_fees['total_fee'] cur.execute(""" INSERT INTO sim_trades (user_id, stock_code, stock_name, trade_type, price, quantity, trade_date, trade_time, recommend_rate, signal_reason, commission, stamp_tax, total_fee) VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s, %s, %s, %s) """, (user_id, code, pos['stock_name'], price, shares, today, now, 0, f"[PE] {dec['reason']}", sell_fees['commission'], sell_fees['stamp_tax'], sell_fees['total_fee'])) cur.execute(""" UPDATE sim_positions SET quantity = %s, total_cost = avg_cost * %s, current_price = %s, updated_at = NOW() WHERE user_id = %s AND stock_code = %s """, (remaining, remaining, price, user_id, code)) update_position_meta(conn, user_id, code, { 'current_shares': remaining, 'partial_exit_done': True, }) log_signal(conn, user_id, today, code, pos['stock_name'], 'partial_sell', dec['reason'], dec['rule'], price, pos['avg_cost'], (price - pos['avg_cost']) / pos['avg_cost'] * 100, True, price, shares) results.append({ 'type': 'partial_sell', 'code': code, 'name': pos['stock_name'], 'price': price, 'quantity': shares, 'pnl': realized_pnl, 'reason': dec['reason'], 'rule': dec['rule'], 'fee': sell_fees['total_fee'] }) print(f"[智能引擎] 部分止盈 {code} {pos['stock_name']} {shares}股@{price:.2f} " f"手续费¥{sell_fees['total_fee']:.2f} | {dec['reason']}") else: # 全部卖出 qty = min(shares, pos['quantity']) sell_fees = calc_trade_fees(price, qty, 'sell') realized_pnl = (price - pos['avg_cost']) * qty - sell_fees['total_fee'] cur.execute(""" INSERT INTO sim_trades (user_id, stock_code, stock_name, trade_type, price, quantity, trade_date, trade_time, recommend_rate, signal_reason, commission, stamp_tax, total_fee) VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s, %s, %s, %s) """, (user_id, code, pos['stock_name'], price, qty, today, now, 0, f"[{dec['rule']}] {dec['reason']}", sell_fees['commission'], sell_fees['stamp_tax'], sell_fees['total_fee'])) cur.execute(""" UPDATE sim_positions SET quantity = 0, total_cost = 0, current_price = %s, updated_at = NOW() WHERE user_id = %s AND stock_code = %s """, (price, user_id, code)) # 记录已实现盈亏 cur.execute(""" INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count) VALUES (%s, %s, %s, 1) ON CONFLICT (user_id, stat_date) DO UPDATE SET realized_profit = sim_daily_stats.realized_profit + %s, trade_count = sim_daily_stats.trade_count + 1 """, (user_id, today, realized_pnl, realized_pnl)) # 清除持仓元数据 delete_position_meta(conn, user_id, code) log_signal(conn, user_id, today, code, pos['stock_name'], 'sell', dec['reason'], dec['rule'], price, pos['avg_cost'], (price - pos['avg_cost']) / pos['avg_cost'] * 100, True, price, qty) results.append({ 'type': 'sell', 'code': code, 'name': pos['stock_name'], 'price': price, 'quantity': qty, 'pnl': realized_pnl, 'reason': dec['reason'], 'rule': dec['rule'], 'fee': sell_fees['total_fee'] }) print(f"[智能引擎] 卖出 {code} {pos['stock_name']} {qty}股@{price:.2f} " f"盈亏¥{realized_pnl:+,.0f} 手续费¥{sell_fees['total_fee']:.2f} | [{dec['rule']}] {dec['reason']}") # 更新卖出后的持仓列表 cur.execute(""" SELECT stock_code FROM sim_positions WHERE user_id = %s AND quantity > 0 """, (user_id,)) holding_codes = {r['stock_code'] for r in cur.fetchall()} # 7. 生成并执行买入决策 buy_decisions = generate_buy_decisions(conn, user_id, config, scan_map, current_prices, holding_codes) print(f"[智能引擎] 买入决策: {len(buy_decisions)}笔 (持仓{len(holding_codes)}只)") for dec in buy_decisions: code = dec['code'] price = dec['price'] shares = dec['shares'] # 防重复买入 cur.execute(""" SELECT COUNT(*) as cnt FROM sim_trades WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy' """, (user_id, code, today)) if cur.fetchone()['cnt'] > 0: continue # 涨跌停检查: 涨停时无法买入 prev_close = prev_close_prices.get(code) if prev_close: limit_info = check_price_limit(code, price, prev_close) if limit_info['at_up_limit']: skipped_limit.append(f"{code}(涨停{limit_info['change_pct']}%)") print(f"[智能引擎] 🚫 涨停限制 {code} 涨跌幅{limit_info['change_pct']}%,无法买入") log_signal(conn, user_id, today, code, dec.get('name', ''), 'skip', f"涨停无法买入 ({dec['reason']})", 'LIMIT', price, None, None, False, None, None) continue # 应用滑点: 买入价格上浮 price = apply_slippage(price, 'buy') shares = dec['shares'] # 不改变shares cost = price * shares fees = calc_trade_fees(price, shares, 'buy') cur.execute(""" INSERT INTO sim_trades (user_id, stock_code, stock_name, trade_type, price, quantity, trade_date, trade_time, recommend_rate, signal_reason, commission, stamp_tax, total_fee) VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s, %s, %s, %s) """, (user_id, code, dec['name'], price, shares, today, now, dec['rate'], dec['reason'], fees['commission'], fees['stamp_tax'], fees['total_fee'])) # total_cost 包含手续费,更接近真实成本 actual_cost = cost + fees['total_fee'] # avg_cost = 含手续费的每股成本,确保 avg_cost * quantity == total_cost avg_cost_per_share = actual_cost / shares if shares > 0 else price cur.execute(""" INSERT INTO sim_positions (user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price) VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (user_id, stock_code) DO UPDATE SET quantity = sim_positions.quantity + EXCLUDED.quantity, total_cost = sim_positions.total_cost + EXCLUDED.total_cost, avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) / (sim_positions.quantity + EXCLUDED.quantity), current_price = EXCLUDED.current_price, stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name), updated_at = NOW() """, (user_id, code, dec['name'], shares, avg_cost_per_share, actual_cost, price)) # 创建持仓元数据 create_position_meta(conn, user_id, code, price, today, shares, reason=dec['reason'], signal_rate=dec['rate'], triggered_count=dec.get('triggered', 0)) log_signal(conn, user_id, today, code, dec['name'], 'buy', dec['reason'], 'BUY', price, price, 0.0, True, price, shares) results.append({ 'type': 'buy', 'code': code, 'name': dec['name'], 'price': price, 'quantity': shares, 'reason': dec['reason'], 'fee': fees['total_fee'] }) print(f"[智能引擎] 买入 {code} {dec['name']} {shares}股@{price:.2f} " f"金额¥{cost:,.0f} 手续费¥{fees['total_fee']:.2f} | {dec['reason']}") conn.commit() buy_count = len([r for r in results if r['type'] == 'buy']) sell_count = len([r for r in results if r['type'] in ('sell', 'partial_sell')]) total_fees = sum(r.get('fee', 0) for r in results) print(f"[智能引擎] 用户{user_id}完成: 买入{buy_count}笔, 卖出{sell_count}笔, " f"总手续费¥{total_fees:.2f}, 算法: {algo_name}") if skipped_limit: print(f"[智能引擎] 涨跌停跳过: {', '.join(skipped_limit)}") if skipped_t1: print(f"[智能引擎] T+1跳过: {', '.join(skipped_t1)}") # 构建详细原因摘要 reasons = [] if sell_count > 0: reasons.append(f"卖出{sell_count}笔") if buy_count > 0: reasons.append(f"买入{buy_count}笔") if skipped_t1: t1_details = [] for code in skipped_t1: pos = next((p for p in positions if p['stock_code'] == code), None) if pos: cp = current_prices.get(code, 0) bp = float(pos.get('avg_cost', 0) or 0) pct = ((cp - bp) / bp * 100) if bp > 0 else 0 t1_details.append(f"{code}({pct:+.1f}%)") else: t1_details.append(code) reasons.append(f"T+1限制: {', '.join(t1_details)}") if skipped_limit: reasons.append(f"涨跌停: {', '.join(skipped_limit)}") # 计算可用现金信息 total_capital = float(config.get('total_capital', 200000)) cur.execute(""" SELECT COALESCE(SUM(total_cost), 0)::float as total_invested FROM sim_positions WHERE user_id = %s AND quantity > 0 """, (user_id,)) total_invested = cur.fetchone()['total_invested'] available_cash = total_capital - total_invested if buy_count == 0 and available_cash < total_capital * 0.05: cash_pct = total_invested / total_capital * 100 reasons.append(f"可用资金¥{available_cash:,.0f}({cash_pct:.0f}%已投)") # 卖出决策但被跳过的情况 — 提供详细原因 if len(sell_decisions) > 0 and sell_count == 0: for dec in sell_decisions: code = dec['code'] if code in skipped_t1: pass # 已记录 elif any(code in s for s in skipped_limit): pass # 已记录 return { 'success': True, 'results': results, 'signals': len(results), 'algo': algo_name, 'total_fees': total_fees, 'skipped_limit': skipped_limit, 'skipped_t1': skipped_t1, 'detail_reasons': reasons, 'available_cash': available_cash, 'total_invested': total_invested, } except Exception as e: conn.rollback() traceback.print_exc() return {'success': False, 'error': str(e)} # ═══════════════════════════════════════════════════════ # 8. 获取交易引擎状态(供前端展示) # ═══════════════════════════════════════════════════════ def get_engine_status(conn, user_id): """获取智能交易引擎的当前状态,供前端Dashboard展示""" from psycopg2.extras import RealDictCursor config = get_user_algo_config(conn, user_id) positions = get_all_position_meta(conn, user_id) # 最近信号 with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT * FROM sim_trade_signals WHERE user_id = %s ORDER BY signal_date DESC, id DESC LIMIT 20 """, (user_id,)) recent_signals = cur.fetchall() # 统计 total_capital = float(config.get('total_capital', 200000)) total_invested = sum( float(p.get('buy_price', 0)) * (p.get('current_shares', 0) or p.get('quantity', 0)) for p in positions ) available_cash = total_capital - total_invested # 持仓详情 position_details = [] for pos in positions: buy_price = float(pos.get('buy_price', 0)) current_price = float(pos.get('current_price', 0) or buy_price) shares = pos.get('current_shares', 0) or pos.get('quantity', 0) pnl_pct = (current_price - buy_price) / buy_price * 100 if buy_price > 0 else 0 # 当前生效的规则 active_rules = [] if pos.get('breakeven_active'): active_rules.append('🛡️ 保本止损') if pos.get('momentum_trailing_active'): active_rules.append('📈 动量跟踪') if pos.get('partial_exit_done'): active_rules.append('✂️ 已部分止盈') tp = float(config.get('take_profit_pct', 12)) sl = float(config.get('stop_loss_pct', 8)) be_at_val = float(config.get('breakeven_at', 0)) position_details.append({ 'code': pos['stock_code'], 'buy_price': buy_price, 'current_price': current_price, 'shares': shares, 'days_held': pos.get('days_held', 0), 'pnl_pct': round(pnl_pct, 2), 'pnl_amount': round((current_price - buy_price) * shares, 2), 'active_rules': active_rules, 'tp_target': round(buy_price * (1 + tp / 100), 2), 'sl_target': round(buy_price * (1 - (0 if pos.get('breakeven_active') else sl) / 100), 2), 'be_trigger': round(buy_price * (1 + be_at_val / 100), 2) if be_at_val > 0 else None, 'consec_up': pos.get('consecutive_up_days', 0), 'consec_sell': pos.get('consecutive_sell_signals', 0), }) return { 'algo_config': config, 'positions': position_details, 'total_capital': total_capital, 'total_invested': round(total_invested, 2), 'available_cash': round(available_cash, 2), 'utilization_pct': round(total_invested / total_capital * 100, 1) if total_capital > 0 else 0, 'recent_signals': [dict(s) for s in recent_signals], }