""" 股票分析 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, compute_deep_analysis, ) from db import ( login_required, get_current_user_id, db_get_alerts_cache, db_save_alerts_cache, get_db, put_db ) 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 'bj' if stock_code.startswith(('8', '9')) 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('/deep_analyze', methods=['POST']) def deep_analyze(): """单股深度分析(价格位置、压力支撑、量价、空间、综合评分)""" try: data = request.get_json() stock_code = data.get('stock_code', '').strip() if not stock_code: return jsonify({'error': '股票代码不能为空'}), 400 df = algo_get_kline_data(stock_code, days=180) if df is None or len(df) < 30: return jsonify({'error': 'K线数据不足'}), 400 from services.technical_indicators import calc_all_indicators from services.signal_detector import detect_all_signals df = calc_all_indicators(df) signal_result = detect_all_signals(df, lookback=5) from db import get_db, put_db from psycopg2.extras import RealDictCursor realtime_info = None conn = get_db() if conn: try: cur = conn.cursor(cursor_factory=RealDictCursor) cur.execute(""" SELECT code, name, price, change_pct, volume, amount, high, low, open, prev_close, pe, pb, total_market_cap FROM stock_realtime_price WHERE code = %s """, (stock_code,)) realtime_info = cur.fetchone() finally: put_db(conn) report = compute_deep_analysis(df, signal_result, realtime_info) stock_name = get_stock_name(stock_code) or (realtime_info or {}).get('name', '') sig_status = signal_result.get('signal_status', []) indicators = signal_result.get('indicators', {}) sig_count = signal_result.get('signal_summary', {}).get('total_signals', 0) rec = compute_recommend(sig_status, indicators, sig_count, False) report['stock_code'] = stock_code report['stock_name'] = stock_name report['recommend'] = { 'signal_type': rec[0], 'display': rec[1], 'reason': rec[2], 'rate': rec[3], } report['signals'] = signal_result.get('signals', []) report['signal_status'] = sig_status # ---- 综合评分引擎:整合外部因素(P0-P7)---- try: from services.score_engine import compute_comprehensive_score tech_score = report.get('deep_score', 50) comprehensive = compute_comprehensive_score( stock_code, stock_name, tech_score, df ) report['comprehensive'] = comprehensive # 用综合评分更新最终评分和评级 report['deep_score'] = comprehensive['final_score'] report['verdict'] = comprehensive['verdict'] report['score_reasons'].extend(comprehensive.get('all_reasons', [])) # ---- 根据综合评级修正买卖建议 ---- # 技术面推荐(compute_recommend)不含外部因素, # 当综合评级与技术面推荐矛盾时,以综合评级为准调整推荐 final_score = comprehensive['final_score'] final_verdict = comprehensive['verdict'] orig_display = report['recommend'].get('display', '') orig_reason = report['recommend'].get('reason', '') orig_rate = report['recommend'].get('rate', 0) # 综合评级偏空但技术面建议买入/加仓 → 降级为关注 if final_score < 50 and orig_display in ('买入', '加仓'): report['recommend'] = { 'signal_type': 'watch', 'display': '关注', 'reason': f"技术面信号偏多,但综合评级「{final_verdict}」(外部因素拖累),建议观望", 'rate': final_score, } # 综合评级强烈看多但技术面建议观望/关注 → 升级为买入 elif final_score >= 80 and orig_display in ('观望', '关注', '观察'): report['recommend'] = { 'signal_type': 'buy', 'display': '买入', 'reason': f"技术面{orig_display},但综合评级「{final_verdict}」(外部因素共振看好),建议买入", 'rate': final_score, } # 综合评级看空但技术面建议持有 → 降级为卖出 elif final_score < 35 and orig_display in ('持有', '观望'): report['recommend'] = { 'signal_type': 'sell', 'display': '卖出', 'reason': f"技术面{orig_display},但综合评级「{final_verdict}」(外部因素重大利空),建议卖出", 'rate': final_score, } # 其他情况保持技术面推荐,但更新评分为综合评分 else: report['recommend']['rate'] = final_score # ---- 追加三项得分汇总到 AI 解说 ---- tech_score = comprehensive.get('technical_score', 0) ext_score = comprehensive.get('external_score', 0) fin_score = comprehensive.get('final_score', 0) fin_verdict = comprehensive.get('verdict', '') ext_summary = comprehensive.get('summary', '') score_line = ( f'综合评分汇总:技术得分{tech_score:.0f}分,' f'外部得分{"+" if ext_score >= 0 else ""}{ext_score}分,' f'综合得分{fin_score}分({fin_verdict})。' ) if ext_summary: score_line += f'外部因素:{ext_summary}。' if report.get('ai_summary'): report['ai_summary']['text'] += score_line # 更新 action_tip 以综合得分为准 if fin_score >= 80: report['ai_summary']['action_tip'] = '综合评级强烈看多,技术面与外部因素共振看好,可以考虑积极参与。' elif fin_score >= 65: report['ai_summary']['action_tip'] = '综合评级看多,整体偏积极,可以逢低关注。' elif fin_score >= 50: report['ai_summary']['action_tip'] = '综合评级中性偏多,多空均衡,建议观望为主。' elif fin_score >= 35: report['ai_summary']['action_tip'] = '综合评级中性偏空,外部因素拖累,不建议急于买入。' else: report['ai_summary']['action_tip'] = '综合评级看空,外部因素重大利空,建议回避或减仓。' report['ai_summary']['confidence'] = '高' if fin_score >= 70 or fin_score <= 30 else '中' except Exception as e: print(f"综合评分引擎计算失败,使用技术面评分: {e}") if realtime_info: report['realtime'] = { 'price': float(realtime_info.get('price') or 0), 'change_pct': float(realtime_info.get('change_pct') or 0), 'pe': float(realtime_info.get('pe') or 0), 'pb': float(realtime_info.get('pb') or 0), 'total_market_cap': float(realtime_info.get('total_market_cap') or 0), 'volume': int(realtime_info.get('volume') or 0), } skip_llm = data.get('skip_llm', False) if report.get('ai_summary') and not skip_llm: try: polished = _llm_polish_summary( stock_name, stock_code, report['ai_summary'], report.get('deep_score', 0), report.get('verdict', '') ) if polished: report['ai_summary']['text'] = polished['text'] report['ai_summary']['action_tip'] = polished['action_tip'] except Exception as e: print(f"LLM润色失败,使用规则文本: {e}") return jsonify({'success': True, 'report': report}) 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, put_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 # 获取资金流向和技术信号 conn = None 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() except Exception as e: print(f"获取数据失败: {e}") finally: if conn: put_db(conn) # 记录AI调用日志 _conn = None 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() except Exception: pass finally: if _conn: put_db(_conn) # 流式调用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, put_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 # 获取近三日资金流向 conn = None 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 except Exception as e: print(f"获取资金流向失败: {e}") finally: if conn: put_db(conn) # 调用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 from psycopg2.extras import RealDictCursor from services.signal_detector import detect_all_signals 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 = get_db() if not conn: raise Exception('数据库连接失败') 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() except Exception as e: print(f"批量扫描获取数据失败: {e}") finally: put_db(conn) 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: 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') with_scores = request.args.get('with_scores', 'false').lower() == 'true' 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 = get_db() if not conn: return jsonify({'success': False, 'error': '数据库连接失败'}), 500 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 with_scores and results: try: from services.score_engine import compute_comprehensive_score_batch stocks_input = [ {'stock_code': r['code'], 'stock_name': r.get('name', ''), 'technical_score': r.get('recommend_rate', 50)} for r in results ] scores_map = compute_comprehensive_score_batch(stocks_input) for r in results: sc = scores_map.get(r['code']) if sc: r['technical_score'] = sc['technical_score'] r['external_score'] = sc['external_score'] r['final_score'] = sc['final_score'] r['verdict'] = sc['verdict'] r['recommend_rate'] = sc['final_score'] # 按综合得分降序重排当前页 results.sort(key=lambda x: (-x.get('final_score', 0), -x.get('triggered_count', 0))) except Exception as e: print(f'批量综合评分计算失败: {e}') 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] # ---- 批量计算综合评分(recommend_text 筛选路径)---- if with_scores and results and codes_for_page is not None: try: from services.score_engine import compute_comprehensive_score_batch stocks_input = [ {'stock_code': r['code'], 'stock_name': r.get('name', ''), 'technical_score': r.get('recommend_rate', 50)} for r in results ] scores_map = compute_comprehensive_score_batch(stocks_input) for r in results: sc = scores_map.get(r['code']) if sc: r['technical_score'] = sc['technical_score'] r['external_score'] = sc['external_score'] r['final_score'] = sc['final_score'] r['verdict'] = sc['verdict'] r['recommend_rate'] = sc['final_score'] results.sort(key=lambda x: (-x.get('final_score', 0), -x.get('triggered_count', 0))) except Exception as e: print(f'批量综合评分计算失败(recommend_text路径): {e}') 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() 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 finally: put_db(conn) 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)""" from psycopg2.extras import RealDictCursor 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 = get_db() if not conn: return jsonify({'success': False, 'error': '数据库连接失败'}), 500 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() 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 finally: put_db(conn) 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('/stock_score_detail/', methods=['GET']) def get_stock_score_detail(code): """获取单只股票的综合评分详情(外部因素) 通过 query 参数 tech_score 传入列表中的技术得分,确保明细与列表分数一致。 """ try: from services.score_engine import compute_comprehensive_score_batch tech_score = float(request.args.get('tech_score', 50)) stocks_input = [{'stock_code': code, 'stock_name': '', 'technical_score': tech_score}] scores_map = compute_comprehensive_score_batch(stocks_input) sc = scores_map.get(code) if sc: return jsonify({ 'success': True, 'technical_score': sc['technical_score'], 'external_score': sc['external_score'], 'final_score': sc['final_score'], 'verdict': sc['verdict'], 'factors': sc.get('factors', {}), 'summary': sc.get('summary', ''), }) return jsonify({'success': False, 'error': '未找到评分数据'}), 404 except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/scan_status', methods=['GET']) def get_scan_status(): """查询扫描进度""" try: conn = get_db() if not conn: return jsonify({'success': False, 'error': '数据库连接失败'}), 500 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() 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 finally: put_db(conn) @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: from psycopg2.extras import RealDictCursor 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 = get_db() if not conn: return jsonify({'success': False, 'error': '数据库连接失败'}), 500 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() 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 finally: put_db(conn) 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: 可选,持仓代码逗号分隔 """ from psycopg2.extras import RealDictCursor 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 = get_db() if not conn: return jsonify({'success': False, 'error': '数据库连接失败'}), 500 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)} # ---- 批量计算综合评分 ---- scores_map = None try: from services.score_engine import compute_comprehensive_score_batch # 先用 compute_recommend 算出技术面基础分 stocks_input = [] for row in scan_rows: sig_status = row.get('signal_status') or [] indicators = row.get('indicators') or {} tc = row.get('triggered_count') or 0 is_holding = row.get('code', '') in holding_codes _, _, _, rate = compute_recommend(sig_status, indicators, tc, is_holding) stocks_input.append({ 'stock_code': row.get('code', ''), 'stock_name': row.get('name', ''), 'technical_score': rate, }) scores_map = compute_comprehensive_score_batch(stocks_input) except Exception as e: print(f'牛股筛选综合评分计算失败: {e}') # 使用统一算法找牛股(传入综合评分) result = find_bull_stocks(scan_rows, holding_codes, scores_map=scores_map) # 为每只股票附加价格信息 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 finally: put_db(conn) def _llm_polish_summary(stock_name, stock_code, ai_summary, score, verdict): """调用豆包LLM将规则模板生成的分析文本润色成更自然流畅的表达""" import requests as _req from config import Config api_key = Config.DOUBAO_API_KEY if not api_key: return None draft_text = ai_summary.get('text', '') draft_action = ai_summary.get('action_tip', '') prompt = f"""你是一位资深股票分析师,擅长用通俗易懂的语言给普通投资者解读技术分析。 以下是对{stock_name}({stock_code})的技术分析草稿,综合评分{score}分({verdict}): 【分析草稿】 {draft_text} 【操作建议草稿】 {draft_action} 请你将上面的草稿改写成更自然、更生动的表达。要求: 1. 用口语化表达,像老朋友聊天一样,避免专业术语堆砌 2. 保留所有关键数据和结论,不要遗漏 3. 适当加入比喻或生活化的表达,让小白也能听懂 4. 操作建议要明确、具体,有可操作性 5. 总字数控制在200字以内 6. 不要用markdown格式,纯文本即可 请严格按以下JSON格式输出,不要输出其他内容: {{"text": "润色后的分析文本", "action_tip": "润色后的操作建议"}}""" try: headers = { "Content-Type": "application/json", "Authorization": f"Bearer {api_key}" } payload = { "model": "doubao-seed-1-6-251015", "max_completion_tokens": 2048, "stream": False, "messages": [{"role": "user", "content": prompt}] } resp = _req.post( "https://ark.cn-beijing.volces.com/api/v3/chat/completions", headers=headers, json=payload, timeout=45 ) if resp.status_code != 200: return None data = resp.json() content = data.get('choices', [{}])[0].get('message', {}).get('content', '') if not content: return None content = content.strip() if content.startswith('```'): content = content.split('\n', 1)[-1].rsplit('```', 1)[0].strip() import json as _json result = _json.loads(content) if result.get('text') and result.get('action_tip'): return result return None except Exception as e: print(f"LLM polish error: {e}") return None