Files
stock/stock-html/routes/analysis.py
T

1485 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
股票分析 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