""" 股票分析 API 路由(纯数据库版) """ import json from flask import Blueprint, request, jsonify from datetime import datetime, date from services.stock_service import ( get_stock_fund_flow, analyze_fund_flow_impact, get_realtime_price, get_stock_name ) from services.stock_algorithms import ( compute_recommend, get_kline_data as algo_get_kline_data, compute_bull_stage, find_bull_stocks, BULL_STAGES, ) from db import ( login_required, get_current_user_id, db_get_alerts_cache, db_save_alerts_cache ) bp = Blueprint('analysis', __name__, url_prefix='/api') @bp.route('/analyze', methods=['POST']) def analyze(): """分析单只股票(数据库优先 + 增量更新)""" try: from db import db_get_fund_flow_history, db_save_fund_flow_history data = request.get_json() stock_code = data.get('stock_code', '').strip() if not stock_code: return jsonify({'error': '股票代码不能为空'}), 400 # 从数据库获取历史数据(东方财富资金流向API已不可用,仅使用数据库缓存) history_records, latest_date = db_get_fund_flow_history(stock_code) if not history_records: return jsonify({'error': '无法获取数据'}), 400 # 2. 基于数据库数据进行分析 import pandas as pd df = pd.DataFrame(history_records) df.rename(columns={ 'trade_date': '日期', 'close_price': '收盘价', 'change_pct': '涨跌幅', 'main_net_inflow': '主力净流入-净额', 'main_net_inflow_pct': '主力净流入-净占比', 'super_net_inflow': '超大单净流入-净额', 'super_net_inflow_pct': '超大单净流入-净占比', 'big_net_inflow': '大单净流入-净额', 'big_net_inflow_pct': '大单净流入-净占比', }, inplace=True) result = analyze_fund_flow_impact(df) if result is None: return jsonify({'error': '分析失败'}), 500 # 3. 获取实时价格补充到结果(优先腾讯API,兼容腾讯云) try: import requests as _req _tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code _r = _req.get(f'http://qt.gtimg.cn/q={_tcode}', timeout=5, headers={'Referer': 'https://finance.qq.com'}) if _r.status_code == 200 and '\"' in _r.text: _fields = _r.text.split('\"')[1].split('~') if len(_fields) > 35 and _fields[3]: result['实时价格'] = float(_fields[3]) result['实时涨跌幅'] = float(_fields[32]) if _fields[32] else 0 except Exception as e: print(f"获取实时价格失败(腾讯): {e}") # 获取股票名称 stock_name = get_stock_name(stock_code) or f'股票{stock_code}' return jsonify({ 'success': True, 'stock_code': stock_code, 'stock_name': stock_name, 'data': result, 'source': 'database', 'latest_date': latest_date }) except Exception as e: import traceback traceback.print_exc() return jsonify({'error': str(e)}), 500 @bp.route('/realtime_price/', methods=['GET']) def realtime_price(stock_code): """获取实时价格(直接调用实时API,不使用数据库缓存)""" # 直接调用实时API获取最新价格 result = get_realtime_price(stock_code) if result['success']: result['source'] = 'api' return jsonify(result) return jsonify(result), 500 @bp.route('/alerts_cache', methods=['GET']) @login_required def get_alerts_cache(): """获取分析缓存""" user_id = get_current_user_id() cache = db_get_alerts_cache(user_id) raw = cache.get('alerts', []) if isinstance(raw, dict): alerts = raw.get('alerts', []) version = raw.get('version', 0) elif isinstance(raw, list): alerts = raw version = 0 else: alerts = [] version = 0 return jsonify({ 'success': True, 'lastUpdate': cache.get('lastUpdate'), 'alerts': alerts, 'version': version }) @bp.route('/alerts_cache', methods=['POST']) @login_required def save_alerts_cache(): """保存分析缓存""" try: user_id = get_current_user_id() data = request.get_json() alerts = data.get('alerts', []) version = data.get('version', 0) cache_obj = {'alerts': alerts, 'version': version} success = db_save_alerts_cache(user_id, cache_obj) return jsonify({'success': success}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/ai_analyze_stream/', methods=['GET']) def ai_analyze_stream(stock_code): """使用豆包AI分析股票(SSE流式输出)""" from flask import Response from services.doubao_api import analyze_stock_stream, format_fund_flow, format_market_cap from services.mairui_api import get_realtime_price as mairui_price, get_financial_indicators from db import get_db def generate(): # 获取股票数据 stock_data = {} # 获取实时价格 price_result = mairui_price(stock_code) if price_result['success']: data = price_result['data'] stock_data['price'] = data.get('price') stock_data['change'] = data.get('change') stock_data['pe'] = data.get('pe') stock_data['pb'] = data.get('pb') stock_data['total_market_cap'] = data.get('total_market_cap') # 获取财务指标 fin_result = get_financial_indicators(stock_code) if fin_result['success']: data = fin_result['data'] stock_data['roe'] = data.get('roe') # 获取股票名称 stock_name = get_stock_name(stock_code) or stock_code # 获取资金流向和技术信号 try: conn = get_db() if conn: cur = conn.cursor() cur.execute(""" SELECT trade_date, change_pct, main_net_inflow_pct, super_net_inflow_pct FROM stock_fund_flow_history WHERE code = %s ORDER BY trade_date DESC LIMIT 3 """, (stock_code,)) rows = cur.fetchall() fund_flow = [] for row in rows: fund_flow.append({ 'date': row[0].strftime('%m-%d') if row[0] else '', 'change_pct': float(row[1]) if row[1] else 0, 'main_pct': float(row[2]) if row[2] else 0, 'super_pct': float(row[3]) if row[3] else 0, }) stock_data['fund_flow_3days'] = fund_flow from psycopg2.extras import RealDictCursor cur2 = conn.cursor(cursor_factory=RealDictCursor) # 优先今天的扫描数据,无则回退到最近可用日期 scan_date = date.today().strftime('%Y-%m-%d') cur2.execute(""" SELECT signal_status, indicators, triggered_count FROM stock_signal_scan WHERE code = %s AND scan_date = %s """, (stock_code, scan_date)) scan_row = cur2.fetchone() if not scan_row: cur2.execute(""" SELECT signal_status, indicators, triggered_count FROM stock_signal_scan WHERE code = %s AND scan_date = (SELECT MAX(scan_date) FROM stock_signal_scan) """, (stock_code,)) scan_row = cur2.fetchone() if scan_row: stock_data['signal_status'] = scan_row['signal_status'] or [] stock_data['indicators'] = scan_row['indicators'] or {} stock_data['triggered_count'] = scan_row['triggered_count'] or 0 cur2.close() conn.close() except Exception as e: print(f"获取数据失败: {e}") # 记录AI调用日志 try: from flask import session as _sess _uid = _sess.get('user_id') if _uid: _conn = get_db() if _conn: _cur = _conn.cursor() _cur.execute("INSERT INTO ai_call_log (user_id, stock_code, stock_name) VALUES (%s, %s, %s)", (_uid, stock_code, stock_name)) _conn.commit() _conn.close() except Exception: pass # 流式调用AI for chunk in analyze_stock_stream(stock_code, stock_name, stock_data): yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" return Response(generate(), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' }) @bp.route('/ai_analyze/', methods=['GET']) def ai_analyze(stock_code): """使用豆包AI分析股票""" try: from services.doubao_api import analyze_stock from services.mairui_api import get_realtime_price as mairui_price, get_financial_indicators from db import get_db # 获取股票数据 stock_data = {} # 获取实时价格 price_result = mairui_price(stock_code) if price_result['success']: data = price_result['data'] stock_data['price'] = data.get('price') stock_data['change'] = data.get('change') stock_data['pe'] = data.get('pe') stock_data['pb'] = data.get('pb') stock_data['total_market_cap'] = data.get('total_market_cap') # 获取财务指标 fin_result = get_financial_indicators(stock_code) if fin_result['success']: data = fin_result['data'] stock_data['roe'] = data.get('roe') # 获取股票名称和行业 stock_name = get_stock_name(stock_code) or stock_code # 获取近三日资金流向 try: conn = get_db() if conn: cur = conn.cursor() cur.execute(""" SELECT trade_date, change_pct, main_net_inflow_pct, super_net_inflow_pct FROM stock_fund_flow_history WHERE code = %s ORDER BY trade_date DESC LIMIT 3 """, (stock_code,)) rows = cur.fetchall() fund_flow = [] for row in rows: fund_flow.append({ 'date': row[0].strftime('%m-%d') if row[0] else '', 'change_pct': float(row[1]) if row[1] else 0, 'main_pct': float(row[2]) if row[2] else 0, 'super_pct': float(row[3]) if row[3] else 0, }) stock_data['fund_flow_3days'] = fund_flow conn.close() except Exception as e: print(f"获取资金流向失败: {e}") # 调用AI分析 result = analyze_stock(stock_code, stock_name, stock_data) return jsonify(result) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/technical_signals/', methods=['GET']) def technical_signals(stock_code): """检测7个技术交易信号(主升浪、底背离、龙抬头、真龙、短底背离、老鼠仓、反弹),并给出与提醒一致的综合推荐""" try: import pandas as pd from services.signal_detector import detect_all_signals lookback = request.args.get('lookback', 5, type=int) days = request.args.get('days', 120, type=int) holding_codes_str = request.args.get('holding_codes', '') holding_set = set(c.strip() for c in holding_codes_str.split(',') if c.strip()) is_holding = stock_code in holding_set kline_df = _get_kline_data(stock_code, days) if kline_df is None or kline_df.empty: return jsonify({'success': False, 'error': '无法获取K线数据'}), 400 result = detect_all_signals(kline_df, lookback=lookback) if 'error' in result: return jsonify({'success': False, 'error': result['error']}), 400 stock_name = get_stock_name(stock_code) or stock_code signal_status = result.get('signal_status', []) indicators = result.get('indicators', {}) triggered_count = sum(1 for s in signal_status if s.get('triggered')) st, recommend_text, recommend_reason, recommend_rate = _compute_recommend( signal_status, indicators, triggered_count, is_holding, ) # 计算持仓说明:当非持仓且建议买入时,模拟持仓情况下的建议 holding_note = None if not is_holding and recommend_text == '买入': _, disp_h, reason_h, _ = _compute_recommend(signal_status, indicators, triggered_count, True) if disp_h in ('卖出', '观望'): holding_note = f"若已持仓:{disp_h}({reason_h})" resp = { 'success': True, 'stock_code': stock_code, 'stock_name': stock_name, 'signals': result['signals'], 'latest_signals': result['latest_signals'], 'signal_summary': result['signal_summary'], 'indicators': indicators, 'signal_status': signal_status, 'recommend_type': st, 'recommend_text': recommend_text, 'recommend_reason': recommend_reason, 'recommend_rate': recommend_rate, } if holding_note: resp['holding_note'] = holding_note return jsonify(resp) except Exception as e: import traceback traceback.print_exc() return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/batch_technical_signals', methods=['POST']) def batch_technical_signals(): """批量检测技术交易信号 — 优先从 stock_signal_scan 读取(与提醒一致),无记录时实时计算""" try: import pandas as pd import psycopg2 from psycopg2.extras import RealDictCursor from services.signal_detector import detect_all_signals from config import Config data = request.get_json() codes = data.get('codes', []) lookback = data.get('lookback', 5) days = data.get('days', 120) holding_codes = data.get('holding_codes', []) holding_set = set(holding_codes) if not codes: return jsonify({'success': False, 'error': '股票代码列表为空'}), 400 codes = codes[:20] # 限制数量 # 查询实时价格 & 当日扫描缓存 price_map = {} change_map = {} scan_map = {} try: conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) cur = conn.cursor(cursor_factory=RealDictCursor) placeholders = ','.join(['%s'] * len(codes)) # 实时价格 cur.execute(f"SELECT code, price, change_pct FROM stock_realtime_price WHERE code IN ({placeholders})", codes) for pr in cur.fetchall(): price_map[pr['code']] = float(pr['price'] or 0) change_map[pr['code']] = float(pr['change_pct'] or 0) # 全景扫描缓存:优先今天,无则回退到最近可用日期 scan_date = date.today().strftime('%Y-%m-%d') cur.execute(f""" SELECT code, name, signal_status, indicators, triggered_count FROM stock_signal_scan WHERE scan_date = %s AND code IN ({placeholders}) """, [scan_date] + codes) scan_rows = cur.fetchall() if not scan_rows: # 今天无扫描数据,回退到最近一次扫描 cur.execute("SELECT MAX(scan_date)::text FROM stock_signal_scan") latest_row = cur.fetchone() if latest_row and latest_row[0]: scan_date = latest_row[0] cur.execute(f""" SELECT code, name, signal_status, indicators, triggered_count FROM stock_signal_scan WHERE scan_date = %s AND code IN ({placeholders}) """, [scan_date] + codes) scan_rows = cur.fetchall() for row in scan_rows: scan_map[row['code']] = row cur.close() conn.close() except Exception as e: print(f"批量扫描获取数据失败: {e}") results = [] errors = [] for code in codes: try: scan = scan_map.get(code) if scan: # 优先使用全景扫描缓存(与提醒推荐一致) signal_status = scan['signal_status'] or [] indicators = scan['indicators'] or {} triggered_count = scan['triggered_count'] or 0 stock_name = scan['name'] or get_stock_name(code) or code else: # 无当日扫描记录,实时计算 kline_df = _get_kline_data(code, days) if kline_df is None or kline_df.empty: errors.append({'code': code, 'error': '无法获取K线数据'}) continue result = detect_all_signals(kline_df, lookback=lookback) signal_status = result.get('signal_status', []) indicators = result.get('indicators', {}) triggered_count = sum(1 for ss in signal_status if ss.get('triggered')) stock_name = get_stock_name(code) or code is_holding = code in holding_set st, disp, reason, rate = _compute_recommend(signal_status, indicators, triggered_count, is_holding) # 计算持仓说明 holding_note = None if not is_holding and disp == '买入': _, disp_h, reason_h, _ = _compute_recommend(signal_status, indicators, triggered_count, True) if disp_h in ('卖出', '观望'): holding_note = f"若已持仓:{disp_h}({reason_h})" item = { 'code': code, 'name': stock_name, 'latest_signals': [], 'indicators': indicators, 'signal_status': signal_status, 'triggered_count': triggered_count, 'price': price_map.get(code), 'change_pct': change_map.get(code), 'recommend_type': st, 'recommend_text': disp, 'recommend_reason': reason, 'recommend_rate': rate, } if holding_note: item['holding_note'] = holding_note results.append(item) except Exception as e: errors.append({'code': code, 'error': str(e)}) return jsonify({ 'success': True, 'results': results, 'errors': errors, 'total': len(codes), }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/scan_results', methods=['GET']) def get_scan_results(): """查询全量扫描结果""" try: import psycopg2 from config import Config scan_date = request.args.get('date', datetime.now().strftime('%Y-%m-%d')) min_triggered = int(request.args.get('min_triggered', 0)) signal_type = request.args.get('signal_type', '') page = int(request.args.get('page', 1)) per_page = int(request.args.get('per_page', 50)) sort_by = request.args.get('sort', 'triggered_count') holding_codes_str = request.args.get('holding_codes', '') holding_set = set(c.strip() for c in holding_codes_str.split(',') if c.strip()) recommend_text = (request.args.get('recommend_text') or '').strip() conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) cur = conn.cursor() # 检查请求日期是否有数据,如果没有则自动回退到最近可用的扫描日期 cur.execute( "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", (scan_date,), ) total_scanned = cur.fetchone()[0] if total_scanned == 0 and not request.args.get('date'): # 前端未指定日期且今天无数据,自动回退到最近一次扫描日期 cur.execute("SELECT MAX(scan_date)::text FROM stock_signal_scan") latest_date_row = cur.fetchone() if latest_date_row and latest_date_row[0]: scan_date = latest_date_row[0] cur.execute( "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", (scan_date,), ) total_scanned = cur.fetchone()[0] cur.execute("SELECT count(*) FROM stock_realtime_price") total_stocks = cur.fetchone()[0] where_clauses = ["scan_date = %s"] params = [scan_date] # 过滤退市/ST股票(psycopg2中 %% 才是字面 %) where_clauses.append("name NOT LIKE '%%退%%'") where_clauses.append("name NOT LIKE '%%ST%%'") if min_triggered > 0: where_clauses.append("triggered_count >= %s") params.append(min_triggered) if signal_type: signal_types = [s.strip() for s in signal_type.split(',') if s.strip()] for st in signal_types: where_clauses.append("""EXISTS ( SELECT 1 FROM jsonb_array_elements(signal_status) elem WHERE elem.value->>'type' = %s AND (elem.value->>'triggered')::boolean = true )""") params.append(st) where = " AND ".join(where_clauses) cur.execute(f"SELECT count(*) FROM stock_signal_scan WHERE {where}", params) filtered_count = cur.fetchone()[0] order = "s.triggered_count DESC, s.code ASC" if sort_by == 'code': order = "s.code ASC" where_s = where.replace("scan_date", "s.scan_date") \ .replace("triggered_count", "s.triggered_count") \ .replace("signal_status", "s.signal_status") \ .replace("name NOT", "s.name NOT") offset = (page - 1) * per_page results = [] codes_for_page = None if recommend_text: cur.execute(""" SELECT code, signal_status, indicators, triggered_count FROM stock_signal_scan WHERE scan_date = %s AND name NOT LIKE '%%退%%' AND name NOT LIKE '%%ST%%' """, (scan_date,)) recommend_counts = {} filtered_ordered = [] for r in cur.fetchall(): code, signal_status, indicators, triggered_count = r[0], r[1] or [], r[2] or {}, r[3] or 0 is_holding = code in holding_set _st, disp, _reason, _rate = _compute_recommend(signal_status, indicators, triggered_count, is_holding) recommend_counts[disp] = recommend_counts.get(disp, 0) + 1 if disp == recommend_text: filtered_ordered.append((code, triggered_count or 0)) filtered_ordered.sort(key=lambda x: (-x[1], x[0])) filtered_count = len(filtered_ordered) codes_for_page = [c for c, _ in filtered_ordered[offset:offset + per_page]] else: cur.execute(f""" SELECT s.code, s.name, s.triggered_count, s.signal_status, s.indicators, s.latest_signals, p.price, p.change_pct FROM stock_signal_scan s LEFT JOIN stock_realtime_price p ON s.code = p.code WHERE {where_s} ORDER BY {order} LIMIT %s OFFSET %s """, params + [per_page, offset]) for row in cur.fetchall(): code, name, triggered_count, signal_status, indicators = row[0], row[1], row[2], row[3] or [], row[4] or {} is_holding = code in holding_set st, disp, reason, rate = _compute_recommend(signal_status, indicators, triggered_count or 0, is_holding) rec_cls = 'hold' if st == 'hold' else st item = { 'code': code, 'name': name, 'triggered_count': triggered_count, 'signal_status': signal_status, 'indicators': indicators, 'latest_signals': row[5] or [], 'price': float(row[6]) if row[6] else None, 'change_pct': float(row[7]) if row[7] else None, 'recommend_type': rec_cls, 'recommend_text': disp, 'recommend_reason': reason, 'recommend_rate': rate, } if not is_holding and disp == '买入': _, dh, rh, _ = _compute_recommend(signal_status, indicators, triggered_count or 0, True) if dh in ('卖出', '观望'): item['holding_note'] = f"若已持仓:{dh}({rh})" results.append(item) if codes_for_page is not None: if codes_for_page: placeholders = ','.join(['%s'] * len(codes_for_page)) cur.execute(f""" SELECT s.code, s.name, s.triggered_count, s.signal_status, s.indicators, s.latest_signals, p.price, p.change_pct FROM stock_signal_scan s LEFT JOIN stock_realtime_price p ON s.code = p.code WHERE s.scan_date = %s AND s.code IN ({placeholders}) """, [scan_date] + codes_for_page) by_code = {} for row in cur.fetchall(): code, name, triggered_count, signal_status, indicators = row[0], row[1], row[2], row[3] or [], row[4] or {} is_holding = code in holding_set st, disp, reason, rate = _compute_recommend(signal_status, indicators, triggered_count or 0, is_holding) rec_cls = 'hold' if st == 'hold' else st item = { 'code': code, 'name': name, 'triggered_count': triggered_count, 'signal_status': signal_status, 'indicators': indicators, 'latest_signals': row[5] or [], 'price': float(row[6]) if row[6] else None, 'change_pct': float(row[7]) if row[7] else None, 'recommend_type': rec_cls, 'recommend_text': disp, 'recommend_reason': reason, 'recommend_rate': rate, } if not is_holding and disp == '买入': _, dh, rh, _ = _compute_recommend(signal_status, indicators, triggered_count or 0, True) if dh in ('卖出', '观望'): item['holding_note'] = f"若已持仓:{dh}({rh})" by_code[code] = item results = [by_code[c] for c in codes_for_page if c in by_code] cur.execute(""" SELECT s.value->>'name' as signal_name, s.value->>'type' as signal_type, count(*) as cnt FROM stock_signal_scan, jsonb_array_elements(signal_status) s WHERE scan_date = %s AND (s.value->>'triggered')::boolean = true GROUP BY s.value->>'name', s.value->>'type' ORDER BY cnt DESC """, (scan_date,)) signal_distribution = [ {'name': r[0], 'type': r[1], 'count': r[2]} for r in cur.fetchall() ] cur.execute(""" SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s AND triggered_count > 0 """, (scan_date,)) triggered_stocks = cur.fetchone()[0] cur.execute(""" SELECT MIN(created_at)::text, MAX(created_at)::text FROM stock_signal_scan WHERE scan_date = %s """, (scan_date,)) time_row = cur.fetchone() scan_start = time_row[0] if time_row else None scan_end = time_row[1] if time_row else None if not recommend_text: cur.execute(""" SELECT code, signal_status, indicators, triggered_count FROM stock_signal_scan WHERE scan_date = %s """, (scan_date,)) recommend_counts = {} for r in cur.fetchall(): code, signal_status, indicators, triggered_count = r[0], r[1] or [], r[2] or {}, r[3] or 0 is_holding = code in holding_set _st, disp, _reason, _rate = _compute_recommend(signal_status, indicators, triggered_count, is_holding) recommend_counts[disp] = recommend_counts.get(disp, 0) + 1 cur.close() conn.close() return jsonify({ 'success': True, 'scan_date': scan_date, 'total_scanned': total_scanned, 'total_stocks': total_stocks, 'triggered_stocks': triggered_stocks, 'filtered_count': filtered_count, 'recommend_counts': recommend_counts, 'page': page, 'per_page': per_page, 'total_pages': (filtered_count + per_page - 1) // per_page, 'results': results, 'signal_distribution': signal_distribution, 'scan_start': scan_start, 'scan_end': scan_end, }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 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) @bp.route('/signal_alerts', methods=['POST']) def signal_alerts(): """基于信号扫描结果生成买入/卖出/观望提醒(与扫描结果共用 _compute_recommend)""" import psycopg2 from psycopg2.extras import RealDictCursor from config import Config try: data = request.get_json() or {} stock_codes = [s.get('code', '') for s in data.get('stocks', [])] stock_names = {s.get('code', ''): s.get('name', '') for s in data.get('stocks', [])} holding_codes = data.get('holding_codes', []) if not stock_codes: return jsonify({'success': True, 'results': []}) conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) cur = conn.cursor(cursor_factory=RealDictCursor) # 优先今天的扫描数据,无则回退到最近可用日期 scan_date = date.today().strftime('%Y-%m-%d') placeholders = ','.join(['%s'] * len(stock_codes)) cur.execute(f""" SELECT code, name, signal_status, indicators, triggered_count FROM stock_signal_scan WHERE scan_date = %s AND code IN ({placeholders}) """, [scan_date] + stock_codes) rows = cur.fetchall() if not rows: # 今天无扫描数据,回退到最近一次扫描日期 cur.execute("SELECT MAX(scan_date)::text AS d FROM stock_signal_scan") latest = cur.fetchone() if latest and latest['d']: scan_date = latest['d'] cur.execute(f""" SELECT code, name, signal_status, indicators, triggered_count FROM stock_signal_scan WHERE scan_date = %s AND code IN ({placeholders}) """, [scan_date] + stock_codes) rows = cur.fetchall() cur.execute(f""" SELECT code, price, change_pct FROM stock_realtime_price WHERE code IN ({placeholders}) """, stock_codes) price_rows = cur.fetchall() conn.close() price_map = {} change_map = {} for pr in price_rows: price_map[pr['code']] = float(pr['price'] or 0) change_map[pr['code']] = float(pr['change_pct'] or 0) scan_map = {} for row in rows: scan_map[row['code']] = row results = [] for code in stock_codes: name = stock_names.get(code, '') scan = scan_map.get(code) is_holding = code in holding_codes signal_type = 'watch' recommend_text = '观望' reason = '今日尚未扫描此股' recommend_rate = 0 price = price_map.get(code, 0) triggered_signals = [] holding_note = None if scan: st, disp, reason, recommend_rate = _compute_recommend( scan['signal_status'] or [], scan['indicators'] or {}, scan['triggered_count'] or 0, is_holding, ) signal_type = st recommend_text = disp name = name or scan['name'] or '' for s in (scan['signal_status'] or []): if s.get('triggered'): triggered_signals.append(s.get('name', s.get('type', ''))) # 推荐买入时,再按持仓算一遍,给出综合结论,避免买入后立刻变成卖出令用户困惑 if not is_holding and disp == '买入': _, disp_h, reason_h, _ = _compute_recommend( scan['signal_status'] or [], scan['indicators'] or {}, scan['triggered_count'] or 0, True, ) if disp_h in ('卖出', '观望'): holding_note = f"若已持仓:{disp_h}({reason_h})" else: signal_type = 'watch' reason = '尚无扫描数据' scan_change = change_map.get(code, 0) item = { 'code': code, 'name': name, 'signalType': signal_type, 'recommendText': recommend_text, 'recommendRate': recommend_rate, 'reason': reason, 'price': price, 'changePct': scan_change, 'scanPrice': price, 'scanChangePct': scan_change, 'triggeredSignals': triggered_signals, } if holding_note: item['holdingNote'] = holding_note results.append(item) return jsonify({ 'success': True, 'results': results, 'total': len(stock_codes), 'success_count': len(results), 'error_count': 0, }) except Exception as e: import traceback traceback.print_exc() return jsonify({'success': False, 'error': str(e)}), 500 def _is_scan_running(): import subprocess try: result = subprocess.run(['/usr/bin/pgrep', '-f', 'full_signal_scan.py'], capture_output=True, text=True) return result.returncode == 0 except Exception: return False @bp.route('/scan_status', methods=['GET']) def get_scan_status(): """查询扫描进度""" try: import psycopg2 from config import Config conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) cur = conn.cursor() scan_date = datetime.now().strftime('%Y-%m-%d') cur.execute( "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", (scan_date,), ) scanned = cur.fetchone()[0] cur.execute("SELECT count(*) FROM stock_realtime_price") total = cur.fetchone()[0] cur.execute( "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s AND triggered_count > 0", (scan_date,), ) triggered = cur.fetchone()[0] cur.close() conn.close() return jsonify({ 'success': True, 'scan_date': scan_date, 'total': total, 'scanned': scanned, 'triggered': triggered, 'progress': round(scanned / total * 100, 1) if total > 0 else 0, 'is_complete': scanned >= total, 'scan_running': _is_scan_running(), }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/start_full_scan', methods=['POST']) def start_full_scan(): """在后台启动全量信号扫描""" try: import subprocess import os if _is_scan_running(): return jsonify({'success': False, 'error': '扫描正在进行中,请稍后再试'}), 409 script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'full_signal_scan.py') if not os.path.exists(script_path): return jsonify({'success': False, 'error': '扫描脚本不存在'}), 404 force = request.json.get('force', False) if request.is_json else False env = os.environ.copy() env['PATH'] = '/opt/stock-app/venv/bin:/usr/local/bin:/usr/bin:/bin' if force: env['FORCE_RESCAN'] = '1' subprocess.Popen( ['python', script_path], cwd=os.path.dirname(script_path), stdout=open(os.path.join(os.path.dirname(script_path), 'scan.log'), 'w'), stderr=subprocess.STDOUT, env=env, start_new_session=True, ) return jsonify({ 'success': True, 'message': '全量扫描已在后台启动' + ('(强制重新扫描)' if force else ''), }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/scan_strategy', methods=['GET']) def get_scan_strategy(): """基于体系最强战法,给出分梯队买卖建议。与全景扫描推荐共用 _compute_recommend,算法一致。""" try: import psycopg2 from psycopg2.extras import RealDictCursor from config import Config scan_date = request.args.get('date', datetime.now().strftime('%Y-%m-%d')) holding_codes_str = request.args.get('holding_codes', '') holding_set = set(c.strip() for c in holding_codes_str.split(',') if c.strip()) conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) cur = conn.cursor(cursor_factory=RealDictCursor) # 如果前端未指定日期,且当天无数据,自动回退到最近扫描日期 if not request.args.get('date'): cur.execute("SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", (scan_date,)) if cur.fetchone()['count'] == 0: cur.execute("SELECT MAX(scan_date)::text AS d FROM stock_signal_scan") row = cur.fetchone() if row and row['d']: scan_date = row['d'] cur.execute(""" SELECT code, name, triggered_count, signal_status, indicators FROM stock_signal_scan WHERE scan_date = %s AND name NOT LIKE '%%退%%' AND name NOT LIKE '%%ST%%' ORDER BY triggered_count DESC, code """, (scan_date,)) rows = cur.fetchall() cur.close() conn.close() tier1, tier2, tier3, tier4 = [], [], [], [] for r in rows: code = r['code'] name = r['name'] signal_status = r.get('signal_status') or [] indicators = r.get('indicators') or {} triggered_count = r.get('triggered_count') or 0 is_holding = code in holding_set st, disp, reason, _ = _compute_recommend(signal_status, indicators, triggered_count, is_holding) triggered = [s.get('name', s.get('type', '')) for s in signal_status if s.get('triggered')] item = { 'code': code, 'name': name, 'triggered_count': triggered_count, 'triggered_signals': triggered, 'signal_status': signal_status, 'indicators': indicators, } # 计算持仓说明 if not is_holding and disp == '买入': _, dh, rh, _ = _compute_recommend(signal_status, indicators, triggered_count, True) if dh in ('卖出', '观望'): item['holding_note'] = f"若已持仓:{dh}({rh})" if disp == '买入': tier1.append(item) elif disp in ('加仓', '持有'): tier2.append(item) elif disp == '关注': sig_map = {s.get('type', ''): s for s in signal_status} has_dragon = sig_map.get('dragon_head', {}).get('triggered', False) if has_dragon: # 龙抬头+MACD死叉 → 信号冲突,关注等待金叉 tier3.append(item) else: # 底背离/其他信号 → 纳入关注,等待龙抬头 tier4.append(item) return jsonify({ 'success': True, 'scan_date': scan_date, 'tiers': [ { 'level': 1, 'action': '立即买入', 'emoji': '🔴', 'condition': '龙抬头 + MACD金叉(核心买入信号)', 'desc': '龙抬头=资金进场起爆点,MACD金叉确认趋势向上', 'count': len(tier1), 'stocks': tier1, }, { 'level': 2, 'action': '持仓加仓', 'emoji': '🟢', 'condition': '主升浪/真龙(持仓持有)', 'desc': '趋势最强阶段,不见主升浪消失不出场', 'count': len(tier2), 'stocks': tier2, }, { 'level': 3, 'action': '关注', 'emoji': '🟡', 'condition': '龙抬头+MACD死叉(信号冲突)', 'desc': '龙抬头出现但MACD趋势未确认,等待金叉再入场', 'count': len(tier3), 'stocks': tier3, }, { 'level': 4, 'action': '纳入关注', 'emoji': '👀', 'condition': '日线底背离/其他信号', 'desc': '底部信号出现,等待龙抬头+MACD金叉确认', 'count': len(tier4), 'stocks': tier4, }, ], }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 def _get_kline_data(stock_code, days=120): """获取K线数据 — 委托给 services.stock_algorithms.get_kline_data(实时分析不用本地DB缓存)""" return algo_get_kline_data(stock_code, days=days, use_local_db=False) @bp.route('/bull_stocks', methods=['GET']) def get_bull_stocks(): """ 找牛股 — 基于标准牛股启动信号先后顺序(suanfa.md) 流程: 阶段1: 底部探测(日线底背离/短底背离)→ 跌到底部 阶段2: 资金进场(龙抬头)→ 短线起爆,最佳买入 阶段3: 趋势确立(真龙)→ 中期趋势确认 阶段4: 加速拉升(主升浪)→ 利润兑现最快 阶段5: 回调补涨(反弹)→ 中途回调补涨 参数: stage: 可选,筛选特定阶段(1-5) holdingStocks: 可选,持仓代码逗号分隔 """ import psycopg2 from psycopg2.extras import RealDictCursor from config import Config try: stage_filter = request.args.get('stage', type=int, default=0) holding_str = request.args.get('holdingStocks', '') holding_codes = set(holding_str.split(',')) if holding_str else set() conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) cur = conn.cursor(cursor_factory=RealDictCursor) # 获取最近一次扫描数据(过滤退市/ST) 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) AND triggered_count > 0 AND name NOT LIKE '%%退%%' AND name NOT LIKE '%%ST%%' """) scan_rows = cur.fetchall() # 获取价格数据 cur.execute("SELECT code, price, change_pct FROM stock_realtime_price WHERE price > 0") price_map = {} for p in cur.fetchall(): price_map[p['code']] = {'price': float(p['price']), 'change_pct': float(p.get('change_pct') or 0)} conn.close() # 使用统一算法找牛股 result = find_bull_stocks(scan_rows, holding_codes) # 为每只股票附加价格信息 for stage_num, stocks in result['stages'].items(): for stock in stocks: pm = price_map.get(stock['code'], {}) stock['price'] = pm.get('price', 0) stock['change_pct'] = pm.get('change_pct', 0) # 如果指定了阶段筛选 if stage_filter and stage_filter in result['stages']: filtered_stages = {stage_filter: result['stages'][stage_filter]} else: filtered_stages = result['stages'] # 构建阶段信息(给前端用) stage_info_list = [] for sn in [1, 2, 3, 4, 5]: info = BULL_STAGES[sn] stage_info_list.append({ 'stage': sn, 'name': info['name'], 'icon': info['icon'], 'color': info['color'], 'desc': info['desc'], 'count': result['summary'].get(sn, 0), }) return jsonify({ 'success': True, 'stages': {str(k): v for k, v in filtered_stages.items()}, 'summary': result['summary'], 'total': result['total'], 'stage_info': stage_info_list, }) except Exception as e: import traceback traceback.print_exc() return jsonify({'success': False, 'error': str(e)}), 500