Files
stock/stock-html/services/scheduler.py
T
freedakgmail 40ce519188 fix: 修复6个数据和代码问题
1. 修复 _generate_plain_summary position key 不匹配 (20d→d20, pct→range_pct)
2. 修复 mairui_api.py 日期解析不一致及 float(None) 崩溃风险
3. 修复 fund_flow_analyzer.py 麦蕊回退数据 close_price=0 导致量价背离误判
4. 修复 db_get_fund_flow_history 缺少完整字段和日期过滤
5. 修复 scheduler.py 连接池泄漏 (conn.close() → put_db(conn))
6. 修复多处北交所股票代码映射缺失 (8/9开头→bj)
2026-07-22 07:20:44 +08:00

657 lines
26 KiB
Python

"""
模拟交易定时任务调度器
- 交易日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, put_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:
put_db(conn)
# 自定义股票列表(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, put_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:
put_db(conn)
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, put_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}')
elif c.startswith('8') or c.startswith('9'):
tencent_codes.append(f'bj{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:
put_db(conn)
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, put_db
conn = get_db()
if conn:
result = execute_smart_trade(conn, user_id, scan_date=None)
put_db(conn)
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, put_db
conn = get_db()
if conn:
result = execute_smart_trade(conn, user_id, scan_date=today)
put_db(conn)
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 job_precompute_market_factors():
"""开市前预热市场级外部因素缓存(09:15执行)
预计算 P1市场情绪、P2北向、P3美股、P4商品、P7汇率、P6政策面,
结果存入 score_engine 内存缓存,后续全景扫描和评分直接复用。
"""
print(f"[定时任务] ===== 开市前预热外部因素 {datetime.now()} =====")
try:
from services.score_engine import precompute_market_factors
precompute_market_factors()
print("[定时任务] 外部因素预热完成")
except Exception as e:
print(f"[定时任务] 外部因素预热失败: {e}")
def run_scheduler():
"""运行定时任务调度器"""
global _is_running
# 设置定时任务 — v7最优时点: 09:35买入 / 13:40卖出
schedule.every().day.at("09:15").do(job_precompute_market_factors) # 开市前预热外部因素
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:15 开市前预热外部因素缓存")
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, put_db
if not is_trading_day():
print("[定时任务] 今天不是交易日,跳过")
return
users = get_all_users()
for user in users:
update_positions_price_for_user(user['user_id'])