""" 模拟交易定时任务调度器 - 交易日09:35自动执行买入(v7最优买入时点) - 交易日13:40自动执行卖出(v7最优卖出时点) - 交易日15:05更新持仓价格 """ import threading import time from datetime import datetime, date, timedelta from concurrent.futures import ThreadPoolExecutor, as_completed import schedule from services.stock_algorithms import compute_recommend, get_latest_price # 全局变量 _scheduler_thread = None _is_running = False # ═══════════════════════════════════════════════════════ # 动态交易日历缓存 # 通过 akshare 从新浪财经自动获取A股交易日历 # 包含所有历史及未来交易日,自动适配节假日 # ═══════════════════════════════════════════════════════ _trading_dates_cache = set() # 交易日集合 (date objects) _cache_loaded_date = None # 缓存加载日期,每天最多刷新1次 def _load_trading_calendar(): """从新浪财经加载A股交易日历到内存缓存""" global _trading_dates_cache, _cache_loaded_date try: import akshare as ak df = ak.tool_trade_date_hist_sina() if df is not None and not df.empty: new_cache = set() for val in df['trade_date']: if isinstance(val, date): new_cache.add(val) else: # 字符串格式 'YYYY-MM-DD' new_cache.add(date.fromisoformat(str(val))) _trading_dates_cache = new_cache _cache_loaded_date = date.today() print(f"[交易日历] 加载成功: {len(_trading_dates_cache)} 个交易日 " f"(范围: {min(_trading_dates_cache)} ~ {max(_trading_dates_cache)})") return True except Exception as e: print(f"[交易日历] 从新浪获取交易日历失败: {e}") return False def _ensure_calendar_loaded(): """确保交易日历已加载且是最新的(每天自动刷新一次)""" global _cache_loaded_date today = date.today() if _trading_dates_cache and _cache_loaded_date == today: return True # 缓存有效 # 需要加载/刷新 return _load_trading_calendar() def is_trading_day(check_date=None): """判断是否为A股交易日(基于新浪交易日历,自动适配全部节假日)""" if check_date is None: check_date = date.today() # 快速检查:周末一定不是交易日 if check_date.weekday() >= 5: return False # 尝试使用动态交易日历 if _ensure_calendar_loaded() and _trading_dates_cache: # 检查日期是否超出日历范围(日历通常只覆盖到当年年底) max_cal_date = max(_trading_dates_cache) if check_date > max_cal_date: print(f"[定时任务] ⚠️ {check_date} 超出日历范围({max_cal_date}),按工作日处理") return True # 超出范围的工作日默认视为交易日 if check_date in _trading_dates_cache: return True else: print(f"[定时任务] {check_date} 不在交易日历中,非交易日") return False # 降级:日历加载失败时,工作日默认视为交易日(避免误跳过) print(f"[定时任务] ⚠️ 交易日历不可用,{check_date} 按工作日处理") return True def is_trading_time(): """判断当前是否在交易时间内""" now = datetime.now() hour = now.hour minute = now.minute time_val = hour * 100 + minute # 交易时间:9:30-11:30, 13:00-15:00 if (930 <= time_val <= 1130) or (1300 <= time_val <= 1500): return True return False def get_all_users(): """获取所有启用自动交易的用户""" from db import get_db from psycopg2.extras import RealDictCursor conn = get_db() if not conn: return [] try: cur = conn.cursor(cursor_factory=RealDictCursor) cur.execute(""" SELECT u.id as user_id, u.username, c.trade_quantity FROM users u LEFT JOIN sim_config c ON u.id = c.user_id WHERE c.auto_trade_enabled = true OR c.auto_trade_enabled IS NULL """) return cur.fetchall() except Exception as e: print(f"[定时任务] 获取用户列表失败: {e}") return [] finally: conn.close() # 自定义股票列表(100只精选股票) CUSTOM_STOCKS = [ '000001', '000002', '000063', '000100', '000157', '000333', '000338', '000425', '000538', '000568', '000596', '000625', '000651', '000661', '000703', '000725', '000768', '000776', '000858', '000876', '002007', '002024', '002027', '002049', '002120', '002142', '002179', '002230', '002236', '002241', '002271', '002304', '002352', '002371', '002415', '002460', '002466', '002475', '002493', '002555', '002594', '002602', '002607', '002624', '002714', '002736', '002812', '002841', '002916', '002938', '300003', '300014', '300015', '300033', '300059', '300122', '300124', '300136', '300142', '300144', '300347', '300408', '300433', '300496', '300498', '300502', '300529', '300558', '300601', '300628', '300750', '300760', '300782', '300896', '300948', '600000', '600009', '600016', '600028', '600030', '600036', '600048', '600050', '600061', '600104', '600111', '600115', '600132', '600150', '600196', '600276', '600309', '600332', '600346', '600352', '600362', '600406', '600436', '600519', '600585' ] def get_hot_stocks(limit=50): """获取热门股票列表(自定义100只精选股票) 东方财富人气榜API已不可用(腾讯云封锁),仅使用自定义列表 """ stocks = [] seen_codes = set() for code in CUSTOM_STOCKS: if code not in seen_codes: stocks.append({'code': code, 'name': ''}) seen_codes.add(code) print(f"[定时任务] 自定义精选 {len(stocks)} 只股票待扫描") return stocks def _compute_recommend(signal_status, indicators, triggered_count, is_holding): """统一推荐逻辑 — 委托给 services.stock_algorithms.compute_recommend""" return compute_recommend(signal_status, indicators, triggered_count, is_holding) def execute_auto_trade_for_user(user_id, trade_quantity=1000, scan_date=None): """基于统一推荐算法的自动交易(与全景扫描推荐使用完全相同的逻辑) 买入: _compute_recommend 返回 '买入' 的股票(主升浪/底背离+龙抬头) 加仓: _compute_recommend 返回 '加仓' 的持仓股(主升浪信号) 卖出: _compute_recommend 返回 '卖出' 的持仓股(MACD死叉) scan_date: 使用哪天的扫描数据, None则自动选择最近可用的 """ from db import get_db from psycopg2.extras import RealDictCursor print(f"[定时任务] 开始为用户{user_id}执行策略交易(统一推荐算法)...") conn = get_db() if not conn: return {'error': '数据库连接失败'} try: cur = conn.cursor(cursor_factory=RealDictCursor) today = date.today() now = datetime.now().time() results = [] # 1. 获取用户持仓 cur.execute(""" SELECT stock_code, stock_name, quantity, avg_cost::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} # 2. 读取扫描结果(指定日期或自动查找最近可用的) 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} used_date = scan_date or '最近' if not scan_map: print(f"[定时任务] 无可用扫描数据(scan_date={used_date}),跳过交易") return {'success': True, 'results': [], 'message': '无可用扫描数据'} print(f"[定时任务] 使用扫描数据: {used_date}, 共{len(scan_map)}只股票") # ===== 卖出逻辑 ===== # 持仓股: 使用 _compute_recommend(is_holding=True) 判断卖出 for pos in positions: code = pos['stock_code'] scan = scan_map.get(code) if not scan: continue signal_type, display, reason, rate = _compute_recommend( scan['signal_status'], scan['indicators'], scan['triggered_count'], is_holding=True ) if signal_type == 'sell': price = _get_latest_price(code) if not price or price <= 0: continue qty = min(trade_quantity, pos['quantity']) realized_pnl = (price - pos['avg_cost']) * qty cur.execute(""" INSERT INTO sim_trades (user_id, stock_code, stock_name, trade_type, price, quantity, trade_date, trade_time, recommend_rate, signal_reason) VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s) """, (user_id, code, pos['stock_name'], price, qty, today, now, rate, reason)) new_qty = pos['quantity'] - qty if new_qty > 0: cur.execute(""" UPDATE sim_positions SET quantity=%s, total_cost=%s, current_price=%s, updated_at=NOW() WHERE user_id=%s AND stock_code=%s """, (new_qty, pos['avg_cost'] * new_qty, price, user_id, code)) else: 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)) results.append({ 'type': 'sell', 'code': code, 'name': pos['stock_name'], 'price': price, 'quantity': qty, 'pnl': realized_pnl, 'reason': reason }) print(f"[策略交易] 卖出 {code} {pos['stock_name']} {qty}股@{price} | {reason}") # ===== 买入逻辑 ===== # 非持仓股: 使用 _compute_recommend(is_holding=False) 判断买入 buy_candidates = [] for code, scan in scan_map.items(): if code in holding_codes: continue signal_type, display, reason, rate = _compute_recommend( scan['signal_status'], scan['indicators'], scan['triggered_count'], is_holding=False ) if signal_type == 'buy': buy_candidates.append({ 'code': code, 'name': scan['name'] or '', 'recommend_rate': rate, 'reason': reason, }) # 按推荐率降序排序,取top 3 buy_candidates.sort(key=lambda x: x['recommend_rate'], reverse=True) buy_candidates = buy_candidates[:3] for cand in buy_candidates: code = cand['code'] 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 price = _get_latest_price(code) if not price or price <= 0: continue cur.execute(""" INSERT INTO sim_trades (user_id, stock_code, stock_name, trade_type, price, quantity, trade_date, trade_time, recommend_rate, signal_reason) VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s) """, (user_id, code, cand['name'], price, trade_quantity, today, now, cand['recommend_rate'], cand['reason'])) 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, cand['name'], trade_quantity, price, price * trade_quantity, price)) results.append({ 'type': 'buy', 'code': code, 'name': cand['name'], 'price': price, 'quantity': trade_quantity, 'reason': cand['reason'] }) print(f"[策略交易] 买入 {code} {cand['name']} {trade_quantity}股@{price} | {cand['reason']}") # ===== 加仓逻辑 ===== # 持仓股: 使用 _compute_recommend(is_holding=True) 判断加仓 cur.execute(""" SELECT stock_code, stock_name, quantity, avg_cost::float FROM sim_positions WHERE user_id = %s AND quantity > 0 """, (user_id,)) current_positions = cur.fetchall() for pos in current_positions: code = pos['stock_code'] scan = scan_map.get(code) if not scan: continue signal_type, display, reason, rate = _compute_recommend( scan['signal_status'], scan['indicators'], scan['triggered_count'], is_holding=True ) if display != '加仓': continue 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 price = _get_latest_price(code) if not price or price <= 0: continue add_qty = trade_quantity // 2 cur.execute(""" INSERT INTO sim_trades (user_id, stock_code, stock_name, trade_type, price, quantity, trade_date, trade_time, recommend_rate, signal_reason) VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s) """, (user_id, code, pos['stock_name'], price, add_qty, today, now, rate, reason)) cur.execute(""" UPDATE sim_positions SET quantity = quantity + %s, total_cost = total_cost + %s, avg_cost = (total_cost + %s) / (quantity + %s), current_price = %s, updated_at = NOW() WHERE user_id = %s AND stock_code = %s """, (add_qty, price * add_qty, price * add_qty, add_qty, price, user_id, code)) results.append({ 'type': 'buy', 'code': code, 'name': pos['stock_name'], 'price': price, 'quantity': add_qty, 'reason': reason }) print(f"[策略交易] 加仓 {code} {pos['stock_name']} {add_qty}股@{price} | {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'] == 'sell']) print(f"[策略交易] 用户{user_id}完成: 买入{buy_count}笔, 卖出{sell_count}笔") return {'success': True, 'results': results} except Exception as e: conn.rollback() import traceback traceback.print_exc() return {'error': str(e)} finally: conn.close() def _get_latest_price(stock_code): """获取股票最新价格 — 委托给 services.stock_algorithms.get_latest_price""" return get_latest_price(stock_code) def update_positions_price_for_user(user_id): """更新用户持仓的当前价格(收盘时调用)— 使用腾讯财经API""" from db import get_db from psycopg2.extras import RealDictCursor conn = get_db() if not conn: return try: cur = conn.cursor(cursor_factory=RealDictCursor) # 获取持仓 cur.execute(""" SELECT stock_code FROM sim_positions WHERE user_id = %s AND quantity > 0 """, (user_id,)) positions = cur.fetchall() # 批量获取持仓股票的实时价格(使用腾讯财经API,兼容腾讯云) codes = [pos['stock_code'] for pos in positions] if codes: try: import requests as _req tencent_codes = [] for c in codes: if c.startswith('6'): tencent_codes.append(f'sh{c}') else: tencent_codes.append(f'sz{c}') _r = _req.get(f'http://qt.gtimg.cn/q={",".join(tencent_codes)}', timeout=10, headers={'Referer': 'https://finance.qq.com'}) if _r.status_code == 200: for line in _r.text.strip().split(';'): if '\"' not in line: continue fields = line.split('\"')[1].split('~') if len(fields) > 3 and fields[3]: stock_code = fields[2] price = float(fields[3]) if price > 0: cur.execute(""" UPDATE sim_positions SET current_price = %s, updated_at = NOW() WHERE user_id = %s AND stock_code = %s """, (price, user_id, stock_code)) except Exception as e: print(f"[定时任务] 腾讯API批量更新价格失败: {e}") # 更新每日统计 today = date.today() cur.execute(""" SELECT COALESCE(SUM(quantity * current_price), 0) as market_value, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(quantity * current_price - total_cost), 0) as unrealized FROM sim_positions WHERE user_id = %s AND quantity > 0 """, (user_id,)) stats = cur.fetchone() cur.execute(""" INSERT INTO sim_daily_stats (user_id, stat_date, total_market_value, total_cost, unrealized_profit) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (user_id, stat_date) DO UPDATE SET total_market_value = EXCLUDED.total_market_value, total_cost = EXCLUDED.total_cost, unrealized_profit = EXCLUDED.unrealized_profit """, (user_id, today, stats['market_value'], stats['total_cost'], stats['unrealized'])) conn.commit() print(f"[定时任务] 用户{user_id}持仓价格已更新") except Exception as e: conn.rollback() print(f"[定时任务] 更新用户{user_id}持仓价格失败: {e}") finally: conn.close() def job_morning_trade(): """早盘交易任务(09:35执行 — v7最优买入时点) 使用昨天收盘后的全景扫描数据做买入/卖出决策 优先使用智能引擎(smart_trade_engine),降级到旧引擎(execute_auto_trade_for_user) """ print(f"[定时任务] ===== 早盘交易任务开始 {datetime.now()} =====") if not is_trading_day(): print("[定时任务] 今天不是交易日,跳过") return users = get_all_users() print(f"[定时任务] 找到{len(users)}个用户需要执行自动交易") for user in users: user_id = user['user_id'] # 尝试使用智能引擎 try: from services.smart_trade_engine import execute_smart_trade from db import get_db conn = get_db() if conn: result = execute_smart_trade(conn, user_id, scan_date=None) conn.close() if result.get('success'): print(f"[定时任务] 用户{user_id} 智能引擎执行成功 " f"(算法:{result.get('algo','?')}, 信号:{result.get('signals',0)})") continue except Exception as e: print(f"[定时任务] 用户{user_id} 智能引擎异常,降级到旧引擎: {e}") # 降级:使用旧引擎 trade_quantity = user.get('trade_quantity') or 1000 execute_auto_trade_for_user(user_id, trade_quantity, scan_date=None) print(f"[定时任务] ===== 早盘交易任务结束 {datetime.now()} =====") def job_afternoon_trade(): """午后交易任务(13:40执行 — v7最优卖出时点) 使用当天中午的全景扫描数据做买入/卖出决策 """ print(f"[定时任务] ===== 午后交易任务开始 {datetime.now()} =====") if not is_trading_day(): print("[定时任务] 今天不是交易日,跳过") return users = get_all_users() # 1. 先执行自动交易(使用今天中午11:50生成的扫描数据) today = date.today() print(f"[定时任务] 找到{len(users)}个用户需要执行午后自动交易") for user in users: user_id = user['user_id'] # 尝试使用智能引擎 try: from services.smart_trade_engine import execute_smart_trade from db import get_db conn = get_db() if conn: result = execute_smart_trade(conn, user_id, scan_date=today) conn.close() if result.get('success'): print(f"[定时任务] 用户{user_id} 午后智能引擎执行成功") continue except Exception as e: print(f"[定时任务] 用户{user_id} 智能引擎异常,降级: {e}") trade_quantity = user.get('trade_quantity') or 1000 execute_auto_trade_for_user(user_id, trade_quantity, scan_date=today) # 2. 更新持仓价格 print(f"[定时任务] 更新{len(users)}个用户持仓价格") for user in users: user_id = user['user_id'] update_positions_price_for_user(user_id) print(f"[定时任务] ===== 午后交易任务结束 {datetime.now()} =====") def run_scheduler(): """运行定时任务调度器""" global _is_running # 设置定时任务 — v7最优时点: 09:35买入 / 13:40卖出 schedule.every().day.at("09:35").do(job_morning_trade) schedule.every().day.at("13:40").do(job_afternoon_trade) schedule.every().day.at("15:05").do(trigger_closing_update) # 收盘更新持仓价格 print("[定时任务] 调度器已启动 (v7最优时点)") print("[定时任务] - 09:35 早盘交易(使用昨日扫描数据 — 最优买入时点)") print("[定时任务] - 13:40 午后交易(使用当日中午扫描数据 — 最优卖出时点)") print("[定时任务] - 15:05 收盘更新持仓价格") _is_running = True while _is_running: schedule.run_pending() time.sleep(30) # 每30秒检查一次 def start_scheduler(): """启动定时任务调度器(在后台线程中运行)""" global _scheduler_thread, _is_running if _scheduler_thread is not None and _scheduler_thread.is_alive(): print("[定时任务] 调度器已在运行中") return _scheduler_thread = threading.Thread(target=run_scheduler, daemon=True) _scheduler_thread.start() print("[定时任务] 后台调度器线程已启动") def stop_scheduler(): """停止定时任务调度器""" global _is_running _is_running = False print("[定时任务] 调度器已停止") # 手动触发任务(用于测试) def trigger_morning_trade(): """手动触发早盘交易任务""" job_morning_trade() def trigger_afternoon_trade(): """手动触发午后交易任务""" job_afternoon_trade() def trigger_closing_update(): """手动触发收盘更新(仅更新持仓价格)""" from db import get_db if not is_trading_day(): print("[定时任务] 今天不是交易日,跳过") return users = get_all_users() for user in users: update_positions_price_for_user(user['user_id'])