1485 lines
61 KiB
Python
1485 lines
61 KiB
Python
"""
|
||
股票分析 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
|
||
)
|
||
|
||
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('/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/<stock_code>', 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/<stock_code>', 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/<stock_code>', 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/<stock_code>', 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')
|
||
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 = 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 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()
|
||
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()
|
||
|
||
# ---- 批量计算综合评分 ----
|
||
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
|
||
|
||
|
||
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
|