Files
小钰 4e0f2c5e74
CI / tests (py3.10) (push) Has been cancelled
CI / tests (py3.11) (push) Has been cancelled
CI / tests (py3.12) (push) Has been cancelled
CI / tests (py3.13) (push) Has been cancelled
CI / clean-install smoke (push) Has been cancelled
CI / ruff (strict, full repo) (push) Has been cancelled
improve: 增强进度反馈,捕获TradingAgents实时输出
- 捕获stdout输出同步到前端日志
- 添加等待提示(3-8分钟)
- 过滤有意义的进度信息
2026-07-08 17:36:39 +08:00

641 lines
23 KiB
Python

"""TradingAgents Web 可视化页面
提供Web界面让用户输入股票代码,运行TradingAgents分析,查看结果。
"""
import json
import os
import sys
import threading
import uuid
from datetime import datetime
from flask import Flask, render_template_string, request, jsonify
# 确保项目根目录在路径中
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
app = Flask(__name__)
# 存储运行中的任务
tasks = {}
# HTML模板
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TradingAgents AI 交易分析</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a0a2e 0%, #1a1a4e 50%, #0d0d3d 100%);
color: #e0e0e0;
min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
/* Header */
.header {
text-align: center;
padding: 40px 0 30px;
}
.header h1 {
font-size: 2.5em;
background: linear-gradient(120deg, #00d4ff, #7c3aed, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 10px;
}
.header p {
color: #8888aa;
font-size: 1.1em;
}
/* Input Form */
.form-card {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 30px;
margin-bottom: 30px;
backdrop-filter: blur(10px);
}
.form-row {
display: flex;
gap: 15px;
align-items: flex-end;
flex-wrap: wrap;
}
.form-group {
flex: 1;
min-width: 200px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #aaaacc;
font-size: 0.9em;
}
.form-group input, .form-group select {
width: 100%;
padding: 12px 16px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 10px;
color: #fff;
font-size: 1em;
transition: all 0.3s;
}
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 20px rgba(124, 58, 237, 0.3);
}
.form-group input::placeholder { color: #666688; }
.btn {
padding: 12px 30px;
border: none;
border-radius: 10px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #7c3aed, #3b82f6);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(124, 58, 237, 0.4);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Preset Buttons */
.presets {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
.preset-btn {
padding: 6px 14px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
color: #aaaacc;
font-size: 0.85em;
cursor: pointer;
transition: all 0.3s;
}
.preset-btn:hover {
background: rgba(124, 58, 237, 0.2);
border-color: #7c3aed;
color: #fff;
}
/* Status */
.status-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
display: none;
}
.status-card.active { display: block; }
.status-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(124, 58, 237, 0.3);
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.log-box {
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 15px;
max-height: 200px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #8888aa;
line-height: 1.6;
}
.log-box .info { color: #3b82f6; }
.log-box .success { color: #10b981; }
.log-box .error { color: #ef4444; }
/* Results */
.results-card {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 30px;
margin-bottom: 20px;
display: none;
}
.results-card.active { display: block; }
.result-section {
margin-bottom: 25px;
padding-bottom: 25px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.result-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.result-section h3 {
color: #7c3aed;
margin-bottom: 15px;
font-size: 1.2em;
}
.decision-box {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(59, 130, 246, 0.15));
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 12px;
padding: 25px;
text-align: center;
margin-bottom: 20px;
}
.decision-label {
font-size: 0.9em;
color: #8888aa;
margin-bottom: 10px;
}
.decision-value {
font-size: 2em;
font-weight: bold;
}
.decision-buy { color: #10b981; }
.decision-sell { color: #ef4444; }
.decision-hold { color: #f59e0b; }
.agent-report {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 20px;
margin-bottom: 15px;
}
.agent-report h4 {
color: #3b82f6;
margin-bottom: 12px;
font-size: 1.05em;
}
.agent-report pre {
white-space: pre-wrap;
word-wrap: break-word;
font-size: 0.9em;
line-height: 1.6;
color: #ccccee;
}
/* Footer */
.footer {
text-align: center;
padding: 30px 0;
color: #555577;
font-size: 0.85em;
}
/* Responsive */
@media (max-width: 768px) {
.header h1 { font-size: 1.8em; }
.form-row { flex-direction: column; }
.form-group { min-width: 100%; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 TradingAgents</h1>
<p>AI 多智能体交易分析系统 · Powered by MiMo-v2.5-pro</p>
</div>
<div class="form-card">
<form id="analysisForm">
<div class="form-row">
<div class="form-group">
<label>📊 股票代码</label>
<input type="text" id="ticker" placeholder="例: AAPL, 600519.SS, BTC-USD" required>
</div>
<div class="form-group" style="max-width: 200px;">
<label>📅 分析日期</label>
<input type="date" id="date" value="{{ default_date }}">
</div>
<div class="form-group" style="max-width: 150px;">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary" id="submitBtn">
🚀 开始分析
</button>
</div>
</div>
<div class="presets">
<span class="preset-btn" onclick="setTicker('AAPL')">🍎 AAPL</span>
<span class="preset-btn" onclick="setTicker('NVDA')">💚 NVDA</span>
<span class="preset-btn" onclick="setTicker('600519.SS')">🍷 茅台</span>
<span class="preset-btn" onclick="setTicker('000001.SZ')">🏦 平安</span>
<span class="preset-btn" onclick="setTicker('0700.HK')">🐧 腾讯</span>
<span class="preset-btn" onclick="setTicker('BTC-USD')">₿ BTC</span>
<span class="preset-btn" onclick="setTicker('SPY')">📈 SPY</span>
</div>
</form>
</div>
<div class="status-card" id="statusCard">
<div class="status-header">
<div class="spinner"></div>
<span id="statusText">正在初始化分析...</span>
</div>
<div class="log-box" id="logBox"></div>
</div>
<div class="results-card" id="resultsCard">
<div class="decision-box">
<div class="decision-label">📊 交易决策</div>
<div class="decision-value" id="decisionValue">--</div>
</div>
<div class="result-section">
<h3>📈 技术分析师</h3>
<div class="agent-report"><pre id="techReport">等待分析...</pre></div>
</div>
<div class="result-section">
<h3>💭 情绪分析师</h3>
<div class="agent-report"><pre id="sentimentReport">等待分析...</pre></div>
</div>
<div class="result-section">
<h3>📰 新闻分析师</h3>
<div class="agent-report"><pre id="newsReport">等待分析...</pre></div>
</div>
<div class="result-section">
<h3>🔬 研究员辩论</h3>
<div class="agent-report"><pre id="debateReport">等待分析...</pre></div>
</div>
<div class="result-section">
<h3>⚠️ 风险评估</h3>
<div class="agent-report"><pre id="riskReport">等待分析...</pre></div>
</div>
</div>
<div class="footer">
TradingAgents v1.0 · 数据来源: Yahoo Finance, StockTwits, Reddit, Polymarket, FRED<br>
⚠️ 仅供参考,不构成投资建议
</div>
</div>
<script>
function setTicker(t) {
document.getElementById('ticker').value = t;
}
document.getElementById('analysisForm').addEventListener('submit', async function(e) {
e.preventDefault();
const ticker = document.getElementById('ticker').value.trim();
const date = document.getElementById('date').value;
if (!ticker) return;
const btn = document.getElementById('submitBtn');
const statusCard = document.getElementById('statusCard');
const resultsCard = document.getElementById('resultsCard');
btn.disabled = true;
btn.textContent = '⏳ 分析中...';
statusCard.classList.add('active');
resultsCard.classList.remove('active');
const logBox = document.getElementById('logBox');
logBox.innerHTML = '<span class="info">⏳ 提交分析任务...</span><br>';
try {
const resp = await fetch('/api/analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ticker, date})
});
const data = await resp.json();
if (data.error) {
logBox.innerHTML += `<span class="error">❌ ${data.error}</span><br>`;
btn.disabled = false;
btn.textContent = '🚀 开始分析';
return;
}
const taskId = data.task_id;
logBox.innerHTML += `<span class="info">📋 任务ID: ${taskId}</span><br>`;
// Poll for results
pollTask(taskId);
} catch(err) {
logBox.innerHTML += `<span class="error">❌ 请求失败: ${err.message}</span><br>`;
btn.disabled = false;
btn.textContent = '🚀 开始分析';
}
});
async function pollTask(taskId) {
const logBox = document.getElementById('logBox');
const btn = document.getElementById('submitBtn');
const statusCard = document.getElementById('statusCard');
const resultsCard = document.getElementById('resultsCard');
const maxPolls = 120;
for (let i = 0; i < maxPolls; i++) {
await new Promise(r => setTimeout(r, 5000));
try {
const resp = await fetch(`/api/status/${taskId}`);
const data = await resp.json();
// Update logs
if (data.logs && data.logs.length > 0) {
logBox.innerHTML = data.logs.map(l => {
const cls = l.includes('✅') ? 'success' : l.includes('❌') ? 'error' : 'info';
return `<span class="${cls}">${l}</span>`;
}).join('<br>');
logBox.scrollTop = logBox.scrollHeight;
}
if (data.status === 'completed') {
btn.disabled = false;
btn.textContent = '🚀 开始分析';
// Show results
resultsCard.classList.add('active');
if (data.result) {
// Decision
const decision = data.result.final_decision || 'HOLD';
const decEl = document.getElementById('decisionValue');
decEl.textContent = decision;
decEl.className = 'decision-value ' +
(decision.includes('BUY') ? 'decision-buy' :
decision.includes('SELL') ? 'decision-sell' : 'decision-hold');
// Reports
const reports = data.result.reports || {};
document.getElementById('techReport').textContent = reports.technical || '无数据';
document.getElementById('sentimentReport').textContent = reports.sentiment || '无数据';
document.getElementById('newsReport').textContent = reports.news || '无数据';
document.getElementById('debateReport').textContent = reports.debate || '无数据';
document.getElementById('riskReport').textContent = reports.risk || '无数据';
}
logBox.innerHTML += '<br><span class="success">✅ 分析完成!</span>';
return;
}
if (data.status === 'failed') {
btn.disabled = false;
btn.textContent = '🚀 开始分析';
logBox.innerHTML += `<br><span class="error">❌ 分析失败: ${data.error}</span>`;
return;
}
} catch(err) {
logBox.innerHTML += `<br><span class="error">❌ 状态查询失败: ${err.message}</span>`;
}
}
btn.disabled = false;
btn.textContent = '🚀 开始分析';
logBox.innerHTML += '<br><span class="error">❌ 分析超时</span>';
}
</script>
</body>
</html>
"""
@app.route('/')
def index():
default_date = datetime.now().strftime('%Y-%m-%d')
return render_template_string(HTML_TEMPLATE, default_date=default_date)
@app.route('/api/analyze', methods=['POST'])
def analyze():
data = request.json
ticker = data.get('ticker', '').strip()
date = data.get('date', datetime.now().strftime('%Y-%m-%d'))
if not ticker:
return jsonify({'error': '请输入股票代码'}), 400
task_id = str(uuid.uuid4())[:8]
tasks[task_id] = {
'status': 'running',
'ticker': ticker,
'date': date,
'logs': ['⏳ 初始化分析...'],
'result': None,
'error': None,
'started_at': datetime.now().isoformat()
}
# 在后台线程运行分析
thread = threading.Thread(target=run_analysis, args=(task_id, ticker, date))
thread.daemon = True
thread.start()
return jsonify({'task_id': task_id, 'status': 'running'})
@app.route('/api/status/<task_id>')
def task_status(task_id):
task = tasks.get(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
return jsonify(task)
def run_analysis(task_id, ticker, date):
"""后台运行TradingAgents分析。"""
import sys
import io
try:
task = tasks[task_id]
def log(msg):
task['logs'].append(msg)
# 保持日志不超过50条
if len(task['logs']) > 50:
task['logs'] = task['logs'][-50:]
# 设置环境变量
os.environ['OPENAI_COMPATIBLE_API_KEY'] = 'tp-ce6eaqg1gh1uev46i4oexh4sahloyfo2hfpxuc2n9m7yl9dd'
os.environ['FRED_API_KEY'] = '825283926dd016ea66e96cc919e047da'
os.environ['ALPHA_VANTAGE_API_KEY'] = 'W5PTA2J5OB16W02L'
log(f'📊 开始分析 {ticker} ({date})')
log(f'🤖 模型: MiMo-v2.5-pro')
log(f'🌐 数据源: Alpha Vantage + SSH中转代理')
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
config = DEFAULT_CONFIG.copy()
config['llm_provider'] = 'openai_compatible'
config['backend_url'] = 'https://token-plan-cn.xiaomimimo.com/v1'
config['deep_think_llm'] = 'mimo-v2.5-pro'
config['quick_think_llm'] = 'mimo-v2.5-pro'
config['max_debate_rounds'] = 1
config['max_risk_discuss_rounds'] = 1
# 配置数据源:Yahoo Finance优先,Alpha Vantage作为备选
config['data_vendors'] = {
'core_stock_apis': 'yfinance,alpha_vantage',
'technical_indicators': 'yfinance,alpha_vantage',
'fundamental_data': 'yfinance,alpha_vantage',
'news_data': 'yfinance,alpha_vantage',
'macro_data': 'fred',
'prediction_markets': 'polymarket',
}
log('🔧 配置完成,启动分析引擎...')
log('⏳ 分析通常需要3-8分钟,请耐心等待...')
# 捕获stdout输出以获取实时进度
class LogCapture:
def __init__(self, task):
self.task = task
self.buffer = ''
def write(self, text):
self.buffer += text
if '\n' in self.buffer:
lines = self.buffer.split('\n')
self.buffer = lines[-1]
for line in lines[:-1]:
line = line.strip()
if line and len(line) > 5:
# 只保留有意义的输出
if any(k in line for k in ['Tool', 'Message', 'Agent', 'failed', 'error', '✅', '❌', '📊', '📈', '💭', '📰', '🔬', '⚠️']):
self.task['logs'].append(f'📋 {line[:100]}')
if len(self.task['logs']) > 50:
self.task['logs'] = self.task['logs'][-50:]
def flush(self):
pass
# 临时捕获输出
old_stdout = sys.stdout
sys.stdout = LogCapture(task)
try:
ta = TradingAgentsGraph(debug=True, config=config)
log('📈 技术分析师开始工作...')
state, decision = ta.propagate(ticker, date)
finally:
sys.stdout = old_stdout
log(f'✅ 分析完成: {decision}')
# 提取各Agent的报告
reports = {}
# 从state中提取各阶段报告
if 'market_report' in state:
reports['technical'] = state['market_report'][:2000]
if 'sentiment_report' in state:
reports['sentiment'] = state['sentiment_report'][:2000]
if 'news_report' in state:
reports['news'] = state['news_report'][:2000]
if 'investment_debate_state' in state:
debate = state['investment_debate_state']
if isinstance(debate, dict):
reports['debate'] = str(debate.get('judge_decision', ''))[:2000]
else:
reports['debate'] = str(debate)[:2000]
if 'risk_debate_state' in state:
risk = state['risk_debate_state']
if isinstance(risk, dict):
reports['risk'] = str(risk.get('judge_decision', ''))[:2000]
else:
reports['risk'] = str(risk)[:2000]
task['status'] = 'completed'
task['result'] = {
'final_decision': decision,
'reports': reports
}
task['logs'].append(f'✅ 最终决策: {decision}')
except Exception as e:
task = tasks.get(task_id)
if task:
task['status'] = 'failed'
task['error'] = str(e)
task['logs'].append(f'❌ 错误: {str(e)}')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8890, debug=False)