diff --git a/web_dashboard/backend/main.py b/web_dashboard/backend/main.py new file mode 100644 index 00000000..a95a6a4f --- /dev/null +++ b/web_dashboard/backend/main.py @@ -0,0 +1,470 @@ +""" +TradingAgents Web Dashboard Backend +FastAPI REST API + WebSocket for real-time analysis progress +""" +import asyncio +import json +import os +import subprocess +import sys +import time +import traceback +from datetime import datetime +from pathlib import Path +from typing import Optional +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +# Path to TradingAgents repo root +REPO_ROOT = Path(__file__).parent.parent.parent +# Use the currently running Python interpreter +ANALYSIS_PYTHON = Path(sys.executable) + + +# ============== Lifespan ============== + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown events""" + app.state.active_connections: dict[str, list[WebSocket]] = {} + app.state.task_results: dict[str, dict] = {} + app.state.analysis_tasks: dict[str, asyncio.Task] = {} + yield + + +# ============== App ============== + +app = FastAPI( + title="TradingAgents Web Dashboard API", + version="0.1.0", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ============== Pydantic Models ============== + +class AnalysisRequest(BaseModel): + ticker: str + date: Optional[str] = None + +class ScreenRequest(BaseModel): + mode: str = "china_strict" + + +# ============== Cache Helpers ============== + +CACHE_DIR = Path(__file__).parent.parent / "cache" +CACHE_TTL_SECONDS = 300 # 5 minutes + + +def _get_cache_path(mode: str) -> Path: + return CACHE_DIR / f"screen_{mode}.json" + + +def _load_from_cache(mode: str) -> Optional[dict]: + cache_path = _get_cache_path(mode) + if not cache_path.exists(): + return None + try: + age = time.time() - cache_path.stat().st_mtime + if age > CACHE_TTL_SECONDS: + return None + with open(cache_path) as f: + return json.load(f) + except Exception: + return None + + +def _save_to_cache(mode: str, data: dict): + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + cache_path = _get_cache_path(mode) + with open(cache_path, "w") as f: + json.dump(data, f) + except Exception: + pass + + +# ============== SEPA Screening ============== + +def _run_sepa_screening(mode: str) -> dict: + """Run SEPA screening synchronously in thread""" + sys.path.insert(0, str(REPO_ROOT)) + from sepa_screener import screen_all, china_stocks + results = screen_all(mode=mode, max_workers=5) + total = len(china_stocks) + return { + "mode": mode, + "total_stocks": total, + "passed": len(results), + "results": results, + } + + +@app.get("/api/stocks/screen") +async def screen_stocks(mode: str = Query("china_strict"), refresh: bool = Query(False)): + """Screen stocks using SEPA criteria with caching""" + if not refresh: + cached = _load_from_cache(mode) + if cached: + return {**cached, "cached": True} + + # Run in thread pool (blocks thread but not event loop) + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, lambda: _run_sepa_screening(mode)) + + _save_to_cache(mode, result) + return {**result, "cached": False} + + +# ============== Analysis Execution ============== + +# Script template for subprocess-based analysis +# ticker and date are passed as command-line args to avoid injection +ANALYSIS_SCRIPT_TEMPLATE = """ +import sys +ticker = sys.argv[1] +date = sys.argv[2] +repo_root = sys.argv[3] +api_key = sys.argv[4] + +sys.path.insert(0, repo_root) +import os +os.environ["ANTHROPIC_API_KEY"] = api_key +os.environ["ANTHROPIC_BASE_URL"] = "https://api.minimaxi.com/anthropic" +import py_mini_racer +sys.modules["mini_racer"] = py_mini_racer +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.default_config import DEFAULT_CONFIG +from pathlib import Path + +config = DEFAULT_CONFIG.copy() +config["llm_provider"] = "anthropic" +config["deep_think_llm"] = "MiniMax-M2.7-highspeed" +config["quick_think_llm"] = "MiniMax-M2.7-highspeed" +config["backend_url"] = "https://api.minimaxi.com/anthropic" +config["max_debate_rounds"] = 1 +config["max_risk_discuss_rounds"] = 1 + +ta = TradingAgentsGraph(debug=False, config=config) +final_state, decision = ta.propagate(ticker, date) + +results_dir = Path(repo_root) / "results" / ticker / date +results_dir.mkdir(parents=True, exist_ok=True) + +signal = decision if isinstance(decision, str) else decision.get("signal", "HOLD") +report_content = ( + "# TradingAgents 分析报告\\n\\n" + "**股票**: " + ticker + "\\n" + "**日期**: " + date + "\\n\\n" + "## 最终决策\\n\\n" + "**" + signal + "**\\n\\n" + "## 分析摘要\\n\\n" + + final_state.get("market_report", "N/A") + "\\n\\n" + "## 基本面\\n\\n" + + final_state.get("fundamentals_report", "N/A") + "\\n" +) + +report_path = results_dir / "complete_report.md" +report_path.write_text(report_content) + +print("ANALYSIS_COMPLETE:" + signal) +""" + + +@app.post("/api/analysis/start") +async def start_analysis(request: AnalysisRequest): + """Start a new analysis task""" + import uuid + task_id = f"{request.ticker}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + date = request.date or datetime.now().strftime("%Y-%m-%d") + + # Initialize task state + app.state.task_results[task_id] = { + "task_id": task_id, + "ticker": request.ticker, + "date": date, + "status": "running", + "progress": 0, + "current_stage": "analysts", + "elapsed": 0, + "stages": [ + {"status": "running", "completed_at": None}, + {"status": "pending", "completed_at": None}, + {"status": "pending", "completed_at": None}, + {"status": "pending", "completed_at": None}, + {"status": "pending", "completed_at": None}, + ], + "logs": [], + "decision": None, + "error": None, + } + # Get API key - fail fast before storing a running task + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + raise HTTPException(status_code=500, detail="ANTHROPIC_API_KEY environment variable not set") + + await broadcast_progress(task_id, app.state.task_results[task_id]) + + # Write analysis script to temp file (avoids subprocess -c quoting issues) + script_path = Path(f"/tmp/analysis_{task_id}.py") + script_content = ANALYSIS_SCRIPT_TEMPLATE + script_path.write_text(script_content) + + # Store process reference for cancellation + app.state.processes = getattr(app.state, 'processes', {}) + app.state.processes[task_id] = None + + async def run_analysis(): + """Run analysis subprocess and broadcast progress""" + try: + # Use clean environment - don't inherit parent env + clean_env = {k: v for k, v in os.environ.items() + if not k.startswith(("PYTHON", "CONDA", "VIRTUAL"))} + clean_env["ANTHROPIC_API_KEY"] = api_key + clean_env["ANTHROPIC_BASE_URL"] = "https://api.minimaxi.com/anthropic" + + proc = await asyncio.create_subprocess_exec( + str(ANALYSIS_PYTHON), + str(script_path), + request.ticker, + date, + str(REPO_ROOT), + api_key, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=clean_env, + ) + app.state.processes[task_id] = proc + + stdout, stderr = await proc.communicate() + + # Clean up script file + try: + script_path.unlink() + except Exception: + pass + + if proc.returncode == 0: + output = stdout.decode() + decision = "HOLD" + for line in output.split("\n"): + if line.startswith("ANALYSIS_COMPLETE:"): + decision = line.split(":", 1)[1].strip() + + app.state.task_results[task_id]["status"] = "completed" + app.state.task_results[task_id]["progress"] = 100 + app.state.task_results[task_id]["decision"] = decision + app.state.task_results[task_id]["current_stage"] = "portfolio" + for i in range(5): + app.state.task_results[task_id]["stages"][i]["status"] = "completed" + app.state.task_results[task_id]["stages"][i]["completed_at"] = datetime.now().strftime("%H:%M:%S") + else: + error_msg = stderr.decode()[-1000:] if stderr else "Unknown error" + app.state.task_results[task_id]["status"] = "failed" + app.state.task_results[task_id]["error"] = error_msg + + except Exception as e: + app.state.task_results[task_id]["status"] = "failed" + app.state.task_results[task_id]["error"] = str(e) + try: + script_path.unlink() + except Exception: + pass + + await broadcast_progress(task_id, app.state.task_results[task_id]) + + task = asyncio.create_task(run_analysis()) + app.state.analysis_tasks[task_id] = task + + return { + "task_id": task_id, + "ticker": request.ticker, + "date": date, + "status": "running", + } + + +@app.get("/api/analysis/status/{task_id}") +async def get_task_status(task_id: str): + """Get task status""" + if task_id not in app.state.task_results: + raise HTTPException(status_code=404, detail="Task not found") + return app.state.task_results[task_id] + + +@app.get("/api/analysis/tasks") +async def list_tasks(): + """List all tasks (active and recent)""" + tasks = [] + for task_id, state in app.state.task_results.items(): + tasks.append({ + "task_id": task_id, + "ticker": state.get("ticker"), + "date": state.get("date"), + "status": state.get("status"), + "progress": state.get("progress", 0), + "decision": state.get("decision"), + "error": state.get("error"), + "created_at": state.get("stages", [{}])[0].get("completed_at") if state.get("stages") else None, + }) + # Sort by task_id (which includes timestamp) descending + tasks.sort(key=lambda x: x["task_id"], reverse=True) + return {"tasks": tasks, "total": len(tasks)} + + +@app.delete("/api/analysis/cancel/{task_id}") +async def cancel_task(task_id: str): + """Cancel a running task""" + if task_id not in app.state.task_results: + raise HTTPException(status_code=404, detail="Task not found") + + # Kill the subprocess if it's still running + proc = app.state.processes.get(task_id) + if proc and proc.returncode is None: + try: + proc.kill() + except Exception: + pass + + # Cancel the asyncio task + task = app.state.analysis_tasks.get(task_id) + if task: + task.cancel() + app.state.task_results[task_id]["status"] = "failed" + app.state.task_results[task_id]["error"] = "用户取消" + await broadcast_progress(task_id, app.state.task_results[task_id]) + + # Clean up temp script + script_path = Path(f"/tmp/analysis_{task_id}.py") + try: + script_path.unlink() + except Exception: + pass + + return {"task_id": task_id, "status": "cancelled"} + + +# ============== WebSocket ============== + +@app.websocket("/ws/analysis/{task_id}") +async def websocket_analysis(websocket: WebSocket, task_id: str): + """WebSocket for real-time analysis progress""" + await websocket.accept() + + if task_id not in app.state.active_connections: + app.state.active_connections[task_id] = [] + app.state.active_connections[task_id].append(websocket) + + # Send current state immediately if available + if task_id in app.state.task_results: + await websocket.send_text(json.dumps({ + "type": "progress", + **app.state.task_results[task_id] + })) + + try: + while True: + data = await websocket.receive_text() + message = json.loads(data) + if message.get("type") == "ping": + await websocket.send_text(json.dumps({"type": "pong"})) + except WebSocketDisconnect: + if task_id in app.state.active_connections: + app.state.active_connections[task_id].remove(websocket) + + +async def broadcast_progress(task_id: str, progress: dict): + """Broadcast progress to all connections for a task""" + if task_id not in app.state.active_connections: + return + + message = json.dumps({"type": "progress", **progress}) + dead = [] + + for connection in app.state.active_connections[task_id]: + try: + await connection.send_text(message) + except Exception: + dead.append(connection) + + for conn in dead: + app.state.active_connections[task_id].remove(conn) + + +# ============== Reports ============== + +def get_results_dir() -> Path: + return Path(__file__).parent.parent.parent / "results" + + +def get_reports_list(): + """Get all historical reports""" + results_dir = get_results_dir() + reports = [] + if not results_dir.exists(): + return reports + for ticker_dir in results_dir.iterdir(): + if ticker_dir.is_dir() and ticker_dir.name != "TradingAgentsStrategy_logs": + ticker = ticker_dir.name + for date_dir in ticker_dir.iterdir(): + # Skip non-date directories like TradingAgentsStrategy_logs + if date_dir.is_dir() and date_dir.name.startswith("20"): + reports.append({ + "ticker": ticker, + "date": date_dir.name, + "path": str(date_dir) + }) + return sorted(reports, key=lambda x: x["date"], reverse=True) + + +def get_report_content(ticker: str, date: str) -> Optional[dict]: + """Get report content for a specific ticker and date""" + report_dir = get_results_dir() / ticker / date + if not report_dir.exists(): + return None + content = {} + complete_report = report_dir / "complete_report.md" + if complete_report.exists(): + content["report"] = complete_report.read_text() + for stage in ["1_analysts", "2_research", "3_trading", "4_risk", "5_portfolio"]: + stage_dir = report_dir / "reports" / stage + if stage_dir.exists(): + for f in stage_dir.glob("*.md"): + content[f.name] = f.read_text() + return content + + +@app.get("/api/reports/list") +async def list_reports(): + return get_reports_list() + + +@app.get("/api/reports/{ticker}/{date}") +async def get_report(ticker: str, date: str): + content = get_report_content(ticker, date) + if not content: + raise HTTPException(status_code=404, detail="Report not found") + return content + + +@app.get("/") +async def root(): + return {"message": "TradingAgents Web Dashboard API", "version": "0.1.0"} + + +if __name__ == "__main__": + import uvicorn + # Run with: cd web_dashboard && ../env312/bin/python -m uvicorn main:app --reload + # Or: cd web_dashboard/backend && python3 main.py (requires env312 in PATH) + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx new file mode 100644 index 00000000..5f1db984 --- /dev/null +++ b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx @@ -0,0 +1,291 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Card, Progress, Timeline, Badge, Empty, Button, Tag, Result, message } from 'antd' +import { CheckCircleOutlined, SyncOutlined, CloseCircleOutlined } from '@ant-design/icons' + +const ANALYSIS_STAGES = [ + { key: 'analysts', label: '分析师团队', description: 'Market / Social / News / Fundamentals' }, + { key: 'research', label: '研究员辩论', description: 'Bull vs Bear Researcher debate' }, + { key: 'trader', label: '交易员', description: 'Compose investment plan' }, + { key: 'risk', label: '风险管理', description: 'Aggressive vs Conservative vs Neutral' }, + { key: 'portfolio', label: '组合经理', description: 'Final BUY/HOLD/SELL decision' }, +] + +export default function AnalysisMonitor() { + const [searchParams] = useSearchParams() + const taskId = searchParams.get('task_id') + const [task, setTask] = useState(null) + const [wsConnected, setWsConnected] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const wsRef = useRef(null) + + const fetchInitialState = useCallback(async () => { + setLoading(true) + try { + const res = await fetch(`/api/analysis/status/${taskId}`) + if (!res.ok) throw new Error('获取任务状态失败') + const data = await res.json() + setTask(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + }, [taskId]) + + const connectWebSocket = useCallback(() => { + if (wsRef.current) wsRef.current.close() + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const ws = new WebSocket(`${protocol}//${host}/ws/analysis/${taskId}`) + + ws.onopen = () => { + setWsConnected(true) + setError(null) + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'progress') { + const { type, ...taskData } = data + setTask(taskData) + } + } catch (e) { + // Ignore parse errors + } + } + + ws.onerror = () => { + setError('WebSocket连接失败') + setWsConnected(false) + } + + ws.onclose = () => { + setWsConnected(false) + } + + wsRef.current = ws + }, [taskId]) + + useEffect(() => { + if (!taskId) return + fetchInitialState() + connectWebSocket() + return () => { + if (wsRef.current) wsRef.current.close() + } + }, [taskId, fetchInitialState, connectWebSocket]) + + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + const getStageStatusIcon = (status) => { + switch (status) { + case 'completed': + return + case 'running': + return + case 'failed': + return + default: + return + } + } + + const getDecisionBadge = (decision) => { + if (!decision) return null + const colorMap = { + BUY: 'var(--color-buy)', + SELL: 'var(--color-sell)', + HOLD: 'var(--color-hold)', + } + return ( + + {decision} + + ) + } + + return ( +
+ {/* Current Task Card */} + + 当前分析任务 + +
+ } + > + {loading ? ( +
+
+ 连接中... +
+
+ ) : error ? ( + { + fetchInitialState() + connectWebSocket() + }} + aria-label="重新连接" + > + 重新连接 + + } + /> + ) : task ? ( + <> + {/* Task Header */} +
+
+ {task.name} + + {task.ticker} + + {getDecisionBadge(task.decision)} +
+ + {/* Progress */} +
+ + + {formatTime(task.elapsed)} + +
+
+ + {/* Stages */} +
+ {ANALYSIS_STAGES.map((stage, index) => ( +
+
+ {getStageStatusIcon(task.stages[index]?.status)} + {stage.label} +
+
+ ))} +
+ + {/* Logs */} +
+
+ 实时日志 +
+
+ {task.logs.map((log, i) => ( +
+ [{log.time}]{' '} + {log.stage}:{' '} + {log.message} +
+ ))} +
+
+ + ) : ( + + + + + } /> + )} + + + {/* No Active Task */} + {!task && ( +
+
+ + + + +
暂无进行中的分析
+
+ 在股票筛选页面选择股票并点击"分析"开始 +
+ +
+
+ )} + + ) +} diff --git a/web_dashboard/frontend/src/pages/BatchManager.jsx b/web_dashboard/frontend/src/pages/BatchManager.jsx new file mode 100644 index 00000000..a586883a --- /dev/null +++ b/web_dashboard/frontend/src/pages/BatchManager.jsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useCallback } from 'react' +import { Table, Button, Tag, Progress, Result, Empty, Tabs, InputNumber, Card, Skeleton, message } from 'antd' +import { + PlayCircleOutlined, + PauseCircleOutlined, + DeleteOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + SyncOutlined, +} from '@ant-design/icons' + +const MAX_CONCURRENT = 3 + +export default function BatchManager() { + const [tasks, setTasks] = useState([]) + const [maxConcurrent, setMaxConcurrent] = useState(MAX_CONCURRENT) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchTasks = useCallback(async () => { + setLoading(true) + try { + const res = await fetch('/api/analysis/tasks') + if (!res.ok) throw new Error('获取任务列表失败') + const data = await res.json() + setTasks(data.tasks || []) + setError(null) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchTasks() + const interval = setInterval(fetchTasks, 5000) + return () => clearInterval(interval) + }, [fetchTasks]) + + const handleCancel = async (taskId) => { + try { + const res = await fetch(`/api/analysis/cancel/${taskId}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('取消失败') + message.success('任务已取消') + fetchTasks() + } catch (err) { + message.error(err.message) + } + } + + const handleRetry = async (taskId) => { + const task = tasks.find(t => t.task_id === taskId) + if (!task) return + try { + const res = await fetch('/api/analysis/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ticker: task.ticker }), + }) + if (!res.ok) throw new Error('重试失败') + message.success('任务已重新提交') + fetchTasks() + } catch (err) { + message.error(err.message) + } + } + + const getStatusIcon = (status) => { + switch (status) { + case 'completed': + return + case 'running': + return + case 'failed': + return + default: + return + } + } + + const getDecisionBadge = (decision) => { + if (!decision) return null + const colorMap = { + BUY: 'var(--color-buy)', + SELL: 'var(--color-sell)', + HOLD: 'var(--color-hold)', + } + return ( + + {decision} + + ) + } + + const getStatusTag = (task) => { + const statusMap = { + pending: { text: '等待', color: 'var(--color-hold)' }, + running: { text: '分析中', color: 'var(--color-running)' }, + completed: { text: '完成', color: 'var(--color-buy)' }, + failed: { text: '失败', color: 'var(--color-sell)' }, + } + const s = statusMap[task.status] + return ( + + {s.text} + + ) + } + + const columns = [ + { + title: '状态', + key: 'status', + width: 100, + render: (_, record) => ( +
+ {getStatusIcon(record.status)} + {getStatusTag(record)} +
+ ), + }, + { + title: '股票', + key: 'stock', + render: (_, record) => ( +
+
{record.ticker}
+
+ ), + }, + { + title: '进度', + dataIndex: 'progress', + key: 'progress', + width: 150, + render: (val, record) => + record.status === 'running' || record.status === 'pending' ? ( + + ) : ( + {val}% + ), + }, + { + title: '决策', + dataIndex: 'decision', + key: 'decision', + width: 80, + render: (decision) => getDecisionBadge(decision), + }, + { + title: '任务ID', + dataIndex: 'task_id', + key: 'task_id', + width: 200, + render: (text) => ( + {text} + ), + }, + { + title: '错误', + dataIndex: 'error', + key: 'error', + render: (error) => + error ? ( + {error} + ) : null, + }, + { + title: '操作', + key: 'action', + width: 150, + render: (_, record) => ( +
+ {record.status === 'running' && ( + + )} + {record.status === 'failed' && ( + + )} +
+ ), + }, + ] + + const pendingCount = tasks.filter((t) => t.status === 'pending').length + const runningCount = tasks.filter((t) => t.status === 'running').length + const completedCount = tasks.filter((t) => t.status === 'completed').length + const failedCount = tasks.filter((t) => t.status === 'failed').length + + return ( +
+ {/* Stats */} +
+ +
+ {pendingCount} +
+
等待中
+
+ +
+ {runningCount} +
+
分析中
+
+ +
+ {completedCount} +
+
已完成
+
+ +
+ {failedCount} +
+
失败
+
+
+ + {/* Settings */} + +
+ 最大并发数: + setMaxConcurrent(val)} + style={{ width: 80 }} + /> + + 同时运行的分析任务数量 + +
+
+ + {/* Tasks Table */} +
+ {loading ? ( + + ) : error ? ( + { + fetchTasks() + }} + aria-label="重试" + > + 重试 + + } + /> + ) : tasks.length === 0 ? ( + + + + + + + } + /> + ) : ( + + )} + + + ) +} diff --git a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx new file mode 100644 index 00000000..108009ef --- /dev/null +++ b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx @@ -0,0 +1,273 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Table, Button, Select, Input, Space, Statistic, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd' +import { PlayCircleOutlined, ReloadOutlined, QuestionCircleOutlined } from '@ant-design/icons' + +const SCREEN_MODES = [ + { value: 'china_strict', label: '中国严格 (China Strict)' }, + { value: 'china_relaxed', label: '中国宽松 (China Relaxed)' }, + { value: 'strict', label: '严格 (Strict)' }, + { value: 'relaxed', label: '宽松 (Relaxed)' }, + { value: 'fundamentals_only', label: '纯基本面 (Fundamentals Only)' }, +] + +export default function ScreeningPanel() { + const navigate = useNavigate() + const [mode, setMode] = useState('china_strict') + const [loading, setLoading] = useState(true) + const [screening, setScreening] = useState(false) + const [results, setResults] = useState([]) + const [stats, setStats] = useState({ total: 0, passed: 0 }) + const [error, setError] = useState(null) + + const fetchResults = async () => { + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/stocks/screen?mode=${mode}`) + if (!res.ok) throw new Error(`请求失败: ${res.status}`) + const data = await res.json() + setResults(data.results || []) + setStats({ total: data.total_stocks || 0, passed: data.passed || 0 }) + } catch (err) { + setError(err.message) + message.error('筛选失败: ' + err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchResults() + }, [mode]) + + const columns = [ + { + title: '代码', + dataIndex: 'ticker', + key: 'ticker', + width: 120, + render: (text) => ( + {text} + ), + }, + { + title: '名称', + dataIndex: 'name', + key: 'name', + width: 120, + }, + { + title: ( + + 营收增速 + + ), + dataIndex: 'revenue_growth', + key: 'revenue_growth', + align: 'right', + render: (val) => ( + + {val?.toFixed(1)}% + + ), + }, + { + title: ( + + 利润增速 + + ), + dataIndex: 'profit_growth', + key: 'profit_growth', + align: 'right', + render: (val) => ( + + {val?.toFixed(1)}% + + ), + }, + { + title: ( + + ROE + + ), + dataIndex: 'roe', + key: 'roe', + align: 'right', + render: (val) => ( + + {val?.toFixed(1)}% + + ), + }, + { + title: '价格', + dataIndex: 'current_price', + key: 'current_price', + align: 'right', + render: (val) => ( + + ¥{val?.toFixed(2)} + + ), + }, + { + title: ( + + Vol比 + + ), + dataIndex: 'vol_ratio', + key: 'vol_ratio', + align: 'right', + render: (val) => ( + + {val?.toFixed(2)}x + + ), + }, + { + title: '操作', + key: 'action', + width: 140, + render: (_, record) => ( + handleStartAnalysis(record)} + okText="确认" + cancelText="取消" + > + + + ), + }, + ] + + const handleStartAnalysis = async (stock) => { + try { + const res = await fetch('/api/analysis/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ticker: stock.ticker }), + }) + if (!res.ok) throw new Error('启动分析失败') + const data = await res.json() + message.success(`已提交分析任务: ${stock.name} (${stock.ticker})`) + navigate(`/monitor?task_id=${data.task_id}`) + } catch (err) { + message.error(err.message) + } + } + + return ( +
+ {/* Stats Row */} + +
+
+ m.value === mode)?.label} + /> +
+ + +
+ +
+ + +
+ +
+ + + + {/* Controls */} +
+
+
SEPA 筛选
+ +
+ )} + + + ) +}