diff --git a/tradingagents/dataflows/china_data.py b/tradingagents/dataflows/china_data.py
new file mode 100644
index 00000000..fecfcd14
--- /dev/null
+++ b/tradingagents/dataflows/china_data.py
@@ -0,0 +1,16 @@
+"""
+china_data vendor for TradingAgents dataflows.
+
+NOTE: This stub exists because the actual china_data implementation (akshare-based)
+lives in web_dashboard/backend/china_data.py, not here. The tradingagents package
+does not currently ship with a china_data vendor implementation.
+
+To use china_data functionality, run analysis through the web dashboard where
+akshare is available as a data source.
+"""
+from typing import Optional
+
+def __getattr__(name: str):
+ # Return None for all china_data imports so interface.py can handle them gracefully
+ return None
+
diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py
index 0caf4b68..82a9bcb1 100644
--- a/tradingagents/dataflows/interface.py
+++ b/tradingagents/dataflows/interface.py
@@ -24,6 +24,42 @@ from .alpha_vantage import (
)
from .alpha_vantage_common import AlphaVantageRateLimitError
+# Lazy china_data import — only fails at runtime if akshare is missing and china_data vendor is selected
+try:
+ from .china_data import (
+ get_china_data_online,
+ get_indicators_china,
+ get_china_stock_info,
+ get_china_financials,
+ get_china_news,
+ get_china_market_news,
+ # Wrappers matching caller signatures:
+ get_china_fundamentals,
+ get_china_balance_sheet,
+ get_china_cashflow,
+ get_china_income_statement,
+ get_china_news_wrapper,
+ get_china_global_news_wrapper,
+ get_china_insider_transactions,
+ )
+ _china_data_available = True
+except (ImportError, AttributeError):
+ _china_data_available = False
+ get_china_data_online = None
+ get_indicators_china = None
+ get_china_stock_info = None
+ get_china_financials = None
+ get_china_news = None
+ get_china_market_news = None
+ get_china_fundamentals = None
+ get_china_balance_sheet = None
+ get_china_cashflow = None
+ get_china_income_statement = None
+ get_china_news_wrapper = None
+ get_china_global_news_wrapper = None
+ get_china_insider_transactions = None
+
+
# Configuration and routing logic
from .config import get_config
@@ -31,15 +67,11 @@ from .config import get_config
TOOLS_CATEGORIES = {
"core_stock_apis": {
"description": "OHLCV stock price data",
- "tools": [
- "get_stock_data"
- ]
+ "tools": ["get_stock_data"],
},
"technical_indicators": {
"description": "Technical analysis indicators",
- "tools": [
- "get_indicators"
- ]
+ "tools": ["get_indicators"],
},
"fundamental_data": {
"description": "Company fundamentals",
@@ -47,8 +79,8 @@ TOOLS_CATEGORIES = {
"get_fundamentals",
"get_balance_sheet",
"get_cashflow",
- "get_income_statement"
- ]
+ "get_income_statement",
+ ],
},
"news_data": {
"description": "News and insider data",
@@ -56,17 +88,19 @@ TOOLS_CATEGORIES = {
"get_news",
"get_global_news",
"get_insider_transactions",
- ]
- }
+ ],
+ },
}
VENDOR_LIST = [
"yfinance",
"alpha_vantage",
+ *(["china_data"] if _china_data_available else []),
]
# Mapping of methods to their vendor-specific implementations
-VENDOR_METHODS = {
+# china_data entries are only present if akshare is installed (_china_data_available)
+_base_vendor_methods = {
# core_stock_apis
"get_stock_data": {
"alpha_vantage": get_alpha_vantage_stock,
@@ -109,6 +143,22 @@ VENDOR_METHODS = {
},
}
+# Conditionally add china_data vendor only if akshare is available
+if _china_data_available:
+ _base_vendor_methods["get_stock_data"]["china_data"] = get_china_data_online
+ _base_vendor_methods["get_indicators"]["china_data"] = get_indicators_china
+ _base_vendor_methods["get_fundamentals"]["china_data"] = get_china_fundamentals
+ _base_vendor_methods["get_balance_sheet"]["china_data"] = get_china_balance_sheet
+ _base_vendor_methods["get_cashflow"]["china_data"] = get_china_cashflow
+ _base_vendor_methods["get_income_statement"]["china_data"] = get_china_income_statement
+ _base_vendor_methods["get_news"]["china_data"] = get_china_news_wrapper
+ _base_vendor_methods["get_global_news"]["china_data"] = get_china_global_news_wrapper
+ _base_vendor_methods["get_insider_transactions"]["china_data"] = get_china_insider_transactions
+
+VENDOR_METHODS = _base_vendor_methods
+del _base_vendor_methods
+
+
def get_category_for_method(method: str) -> str:
"""Get the category that contains the specified method."""
for category, info in TOOLS_CATEGORIES.items():
@@ -116,6 +166,7 @@ def get_category_for_method(method: str) -> str:
return category
raise ValueError(f"Method '{method}' not found in any category")
+
def get_vendor(category: str, method: str = None) -> str:
"""Get the configured vendor for a data category or specific tool method.
Tool-level configuration takes precedence over category-level.
@@ -131,11 +182,12 @@ def get_vendor(category: str, method: str = None) -> str:
# Fall back to category-level configuration
return config.get("data_vendors", {}).get(category, "default")
+
def route_to_vendor(method: str, *args, **kwargs):
"""Route method calls to appropriate vendor implementation with fallback support."""
category = get_category_for_method(method)
vendor_config = get_vendor(category, method)
- primary_vendors = [v.strip() for v in vendor_config.split(',')]
+ primary_vendors = [v.strip() for v in vendor_config.split(",")]
if method not in VENDOR_METHODS:
raise ValueError(f"Method '{method}' not supported")
@@ -159,4 +211,4 @@ def route_to_vendor(method: str, *args, **kwargs):
except AlphaVantageRateLimitError:
continue # Only rate limits trigger fallback
- raise RuntimeError(f"No available vendor for '{method}'")
\ No newline at end of file
+ raise RuntimeError(f"No available vendor for '{method}'")
diff --git a/web_dashboard/backend/api/__init__.py b/web_dashboard/backend/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/web_dashboard/backend/api/portfolio.py b/web_dashboard/backend/api/portfolio.py
new file mode 100644
index 00000000..ce23590b
--- /dev/null
+++ b/web_dashboard/backend/api/portfolio.py
@@ -0,0 +1,325 @@
+"""
+Portfolio API — 自选股、持仓、每日建议
+"""
+import asyncio
+import fcntl
+import json
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+import yfinance
+
+# Data directory
+DATA_DIR = Path(__file__).parent.parent.parent / "data"
+DATA_DIR.mkdir(parents=True, exist_ok=True)
+
+WATCHLIST_FILE = DATA_DIR / "watchlist.json"
+POSITIONS_FILE = DATA_DIR / "positions.json"
+RECOMMENDATIONS_DIR = DATA_DIR / "recommendations"
+WATCHLIST_LOCK = DATA_DIR / "watchlist.lock"
+POSITIONS_LOCK = DATA_DIR / "positions.lock"
+
+
+# ============== Watchlist ==============
+
+def get_watchlist() -> list:
+ if not WATCHLIST_FILE.exists():
+ return []
+ try:
+ with open(WATCHLIST_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_SH)
+ try:
+ return json.loads(WATCHLIST_FILE.read_text()).get("watchlist", [])
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+ except Exception:
+ return []
+
+
+def _save_watchlist(watchlist: list):
+ with open(WATCHLIST_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
+ try:
+ WATCHLIST_FILE.write_text(json.dumps({"watchlist": watchlist}, ensure_ascii=False, indent=2))
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+
+
+def add_to_watchlist(ticker: str, name: str) -> dict:
+ with open(WATCHLIST_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
+ try:
+ watchlist = json.loads(WATCHLIST_FILE.read_text()).get("watchlist", []) if WATCHLIST_FILE.exists() else []
+ if any(s["ticker"] == ticker for s in watchlist):
+ raise ValueError(f"{ticker} 已在自选股中")
+ entry = {
+ "ticker": ticker,
+ "name": name,
+ "added_at": datetime.now().strftime("%Y-%m-%d"),
+ }
+ watchlist.append(entry)
+ WATCHLIST_FILE.write_text(json.dumps({"watchlist": watchlist}, ensure_ascii=False, indent=2))
+ return entry
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+
+
+def remove_from_watchlist(ticker: str) -> bool:
+ with open(WATCHLIST_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
+ try:
+ watchlist = json.loads(WATCHLIST_FILE.read_text()).get("watchlist", []) if WATCHLIST_FILE.exists() else []
+ new_list = [s for s in watchlist if s["ticker"] != ticker]
+ if len(new_list) == len(watchlist):
+ return False
+ WATCHLIST_FILE.write_text(json.dumps({"watchlist": new_list}, ensure_ascii=False, indent=2))
+ return True
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+
+
+# ============== Accounts ==============
+
+def get_accounts() -> dict:
+ if not POSITIONS_FILE.exists():
+ return {"accounts": {}}
+ try:
+ with open(POSITIONS_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_SH)
+ try:
+ return json.loads(POSITIONS_FILE.read_text())
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+ except Exception:
+ return {"accounts": {}}
+
+
+def _save_accounts(data: dict):
+ with open(POSITIONS_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
+ try:
+ POSITIONS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+
+
+def create_account(account_name: str) -> dict:
+ with open(POSITIONS_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
+ try:
+ accounts = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {"accounts": {}}
+ if account_name in accounts.get("accounts", {}):
+ raise ValueError(f"账户 {account_name} 已存在")
+ accounts["accounts"][account_name] = {"positions": {}}
+ POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
+ return {"account_name": account_name}
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+
+
+def delete_account(account_name: str) -> bool:
+ with open(POSITIONS_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
+ try:
+ accounts = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {"accounts": {}}
+ if account_name not in accounts.get("accounts", {}):
+ return False
+ del accounts["accounts"][account_name]
+ POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
+ return True
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+
+
+# ============== Positions =============
+
+# Semaphore to limit concurrent yfinance requests (avoid rate limiting)
+MAX_CONCURRENT_YFINANCE_REQUESTS = 5
+_yfinance_semaphore: asyncio.Semaphore = asyncio.Semaphore(MAX_CONCURRENT_YFINANCE_REQUESTS)
+
+
+def _fetch_price(ticker: str) -> float | None:
+ """Fetch current price synchronously (called in thread executor)"""
+ try:
+ stock = yfinance.Ticker(ticker)
+ info = stock.info or {}
+ return info.get("currentPrice") or info.get("regularMarketPrice")
+ except Exception:
+ return None
+
+
+async def _fetch_price_throttled(ticker: str) -> float | None:
+ """Fetch price with semaphore throttling."""
+ async with _yfinance_semaphore:
+ return _fetch_price(ticker)
+
+
+async def get_positions(account: Optional[str] = None) -> list:
+ """
+ Returns positions with live price from yfinance and computed P&L.
+ Uses asyncio executor with concurrency limit (max 5 simultaneous requests).
+ """
+ accounts = get_accounts()
+
+ if account:
+ acc = accounts.get("accounts", {}).get(account)
+ if not acc:
+ return []
+ positions = [(_ticker, _pos) for _ticker, _positions in acc.get("positions", {}).items()
+ for _pos in _positions]
+ else:
+ positions = [
+ (_ticker, _pos)
+ for _acc_data in accounts.get("accounts", {}).values()
+ for _ticker, _positions in _acc_data.get("positions", {}).items()
+ for _pos in _positions
+ ]
+
+ if not positions:
+ return []
+
+ tickers = [t for t, _ in positions]
+ prices = await asyncio.gather(*[_fetch_price_throttled(t) for t in tickers])
+
+ result = []
+ for (ticker, pos), current_price in zip(positions, prices):
+ shares = pos.get("shares", 0)
+ cost_price = pos.get("cost_price", 0)
+ unrealized_pnl = None
+ unrealized_pnl_pct = None
+ if current_price is not None and cost_price:
+ unrealized_pnl = (current_price - cost_price) * shares
+ unrealized_pnl_pct = (current_price / cost_price - 1) * 100
+
+ result.append({
+ "ticker": ticker,
+ "name": pos.get("name", ticker),
+ "account": pos.get("account", "默认账户"),
+ "shares": shares,
+ "cost_price": cost_price,
+ "current_price": current_price,
+ "unrealized_pnl": unrealized_pnl,
+ "unrealized_pnl_pct": unrealized_pnl_pct,
+ "purchase_date": pos.get("purchase_date"),
+ "notes": pos.get("notes", ""),
+ "position_id": pos.get("position_id"),
+ })
+ return result
+
+
+def add_position(ticker: str, shares: float, cost_price: float,
+ purchase_date: Optional[str], notes: str, account: str) -> dict:
+ with open(POSITIONS_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
+ try:
+ accounts = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {"accounts": {}}
+ acc = accounts.get("accounts", {}).get(account)
+ if not acc:
+ if "默认账户" not in accounts.get("accounts", {}):
+ accounts["accounts"]["默认账户"] = {"positions": {}}
+ acc = accounts["accounts"]["默认账户"]
+
+ position_id = f"pos_{uuid.uuid4().hex[:6]}"
+ position = {
+ "position_id": position_id,
+ "shares": shares,
+ "cost_price": cost_price,
+ "purchase_date": purchase_date,
+ "notes": notes,
+ "account": account,
+ "name": ticker,
+ }
+
+ if ticker not in acc["positions"]:
+ acc["positions"][ticker] = []
+ acc["positions"][ticker].append(position)
+ POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
+ return position
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+
+
+def remove_position(ticker: str, position_id: str, account: Optional[str]) -> bool:
+ if not position_id:
+ return False # Require explicit position_id to prevent mass deletion
+ with open(POSITIONS_LOCK, "w") as lf:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
+ try:
+ accounts = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {"accounts": {}}
+ if account:
+ acc = accounts.get("accounts", {}).get(account)
+ if acc and ticker in acc.get("positions", {}):
+ acc["positions"][ticker] = [
+ p for p in acc["positions"][ticker]
+ if p.get("position_id") != position_id
+ ]
+ if not acc["positions"][ticker]:
+ del acc["positions"][ticker]
+ POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
+ return True
+ else:
+ for acc_data in accounts.get("accounts", {}).values():
+ if ticker in acc_data.get("positions", {}):
+ original_len = len(acc_data["positions"][ticker])
+ acc_data["positions"][ticker] = [
+ p for p in acc_data["positions"][ticker]
+ if p.get("position_id") != position_id
+ ]
+ if len(acc_data["positions"][ticker]) < original_len:
+ if not acc_data["positions"][ticker]:
+ del acc_data["positions"][ticker]
+ POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
+ return True
+ return False
+ finally:
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
+
+
+# ============== Recommendations ==============
+
+def get_recommendations(date: Optional[str] = None) -> list:
+ """List recommendations, optionally filtered by date."""
+ RECOMMENDATIONS_DIR.mkdir(parents=True, exist_ok=True)
+ if date:
+ date_dir = RECOMMENDATIONS_DIR / date
+ if not date_dir.exists():
+ return []
+ return [
+ json.loads(f.read_text())
+ for f in date_dir.glob("*.json")
+ if f.suffix == ".json"
+ ]
+ else:
+ # Return most recent first
+ all_recs = []
+ for date_dir in sorted(RECOMMENDATIONS_DIR.iterdir(), reverse=True):
+ if date_dir.is_dir() and date_dir.name.startswith("20"):
+ for f in date_dir.glob("*.json"):
+ if f.suffix == ".json":
+ all_recs.append(json.loads(f.read_text()))
+ return all_recs
+
+
+def get_recommendation(date: str, ticker: str) -> Optional[dict]:
+ # Validate inputs to prevent path traversal
+ if ".." in ticker or "/" in ticker or "\\" in ticker:
+ return None
+ if ".." in date or "/" in date or "\\" in date:
+ return None
+ path = RECOMMENDATIONS_DIR / date / f"{ticker}.json"
+ if not path.exists():
+ return None
+ # Ensure resolved path is within RECOMMENDATIONS_DIR (strict traversal check)
+ try:
+ path.resolve().relative_to(RECOMMENDATIONS_DIR.resolve())
+ except ValueError:
+ return None
+ return json.loads(path.read_text())
+
+
+def save_recommendation(date: str, ticker: str, data: dict):
+ date_dir = RECOMMENDATIONS_DIR / date
+ date_dir.mkdir(parents=True, exist_ok=True)
+ (date_dir / f"{ticker}.json").write_text(json.dumps(data, ensure_ascii=False, indent=2))
diff --git a/web_dashboard/backend/main.py b/web_dashboard/backend/main.py
index a95a6a4f..bb4b054f 100644
--- a/web_dashboard/backend/main.py
+++ b/web_dashboard/backend/main.py
@@ -3,10 +3,12 @@ TradingAgents Web Dashboard Backend
FastAPI REST API + WebSocket for real-time analysis progress
"""
import asyncio
+import fcntl
import json
import os
import subprocess
import sys
+import tempfile
import time
import traceback
from datetime import datetime
@@ -17,11 +19,14 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
+from fastapi.responses import Response
# Path to TradingAgents repo root
REPO_ROOT = Path(__file__).parent.parent.parent
# Use the currently running Python interpreter
ANALYSIS_PYTHON = Path(sys.executable)
+# Task state persistence directory
+TASK_STATUS_DIR = Path(__file__).parent / "data" / "task_status"
# ============== Lifespan ==============
@@ -32,6 +37,16 @@ async def lifespan(app: FastAPI):
app.state.active_connections: dict[str, list[WebSocket]] = {}
app.state.task_results: dict[str, dict] = {}
app.state.analysis_tasks: dict[str, asyncio.Task] = {}
+
+ # Restore persisted task states from disk
+ TASK_STATUS_DIR.mkdir(parents=True, exist_ok=True)
+ for f in TASK_STATUS_DIR.glob("*.json"):
+ try:
+ data = json.loads(f.read_text())
+ app.state.task_results[data["task_id"]] = data
+ except Exception:
+ pass
+
yield
@@ -46,7 +61,6 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
- allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -65,6 +79,9 @@ class ScreenRequest(BaseModel):
CACHE_DIR = Path(__file__).parent.parent / "cache"
CACHE_TTL_SECONDS = 300 # 5 minutes
+MAX_RETRY_COUNT = 2
+RETRY_BASE_DELAY_SECS = 1
+MAX_CONCURRENT_YFINANCE = 5
def _get_cache_path(mode: str) -> Path:
@@ -86,6 +103,7 @@ def _load_from_cache(mode: str) -> Optional[dict]:
def _save_to_cache(mode: str, data: dict):
+ """Save screening result to cache"""
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_path = _get_cache_path(mode)
@@ -95,6 +113,23 @@ def _save_to_cache(mode: str, data: dict):
pass
+def _save_task_status(task_id: str, data: dict):
+ """Persist task state to disk"""
+ try:
+ TASK_STATUS_DIR.mkdir(parents=True, exist_ok=True)
+ (TASK_STATUS_DIR / f"{task_id}.json").write_text(json.dumps(data, ensure_ascii=False))
+ except Exception:
+ pass
+
+
+def _delete_task_status(task_id: str):
+ """Remove persisted task state from disk"""
+ try:
+ (TASK_STATUS_DIR / f"{task_id}.json").unlink(missing_ok=True)
+ except Exception:
+ pass
+
+
# ============== SEPA Screening ==============
def _run_sepa_screening(mode: str) -> dict:
@@ -130,17 +165,15 @@ async def screen_stocks(mode: str = Query("china_strict"), refresh: bool = Query
# ============== Analysis Execution ==============
# Script template for subprocess-based analysis
-# ticker and date are passed as command-line args to avoid injection
+# api_key is passed via environment variable (not CLI) for security
ANALYSIS_SCRIPT_TEMPLATE = """
import sys
+import os
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
@@ -148,6 +181,8 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
from pathlib import Path
+print("STAGE:analysts", flush=True)
+
config = DEFAULT_CONFIG.copy()
config["llm_provider"] = "anthropic"
config["deep_think_llm"] = "MiniMax-M2.7-highspeed"
@@ -156,9 +191,15 @@ config["backend_url"] = "https://api.minimaxi.com/anthropic"
config["max_debate_rounds"] = 1
config["max_risk_discuss_rounds"] = 1
+print("STAGE:research", flush=True)
+
ta = TradingAgentsGraph(debug=False, config=config)
+print("STAGE:trading", flush=True)
+
final_state, decision = ta.propagate(ticker, date)
+print("STAGE:risk", flush=True)
+
results_dir = Path(repo_root) / "results" / ticker / date
results_dir.mkdir(parents=True, exist_ok=True)
@@ -178,7 +219,8 @@ report_content = (
report_path = results_dir / "complete_report.md"
report_path.write_text(report_content)
-print("ANALYSIS_COMPLETE:" + signal)
+print("STAGE:portfolio", flush=True)
+print("ANALYSIS_COMPLETE:" + signal, flush=True)
"""
@@ -189,6 +231,11 @@ async def start_analysis(request: AnalysisRequest):
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")
+ # Validate API key before storing any task state
+ 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")
+
# Initialize task state
app.state.task_results[task_id] = {
"task_id": task_id,
@@ -197,6 +244,7 @@ async def start_analysis(request: AnalysisRequest):
"status": "running",
"progress": 0,
"current_stage": "analysts",
+ "created_at": datetime.now().isoformat(),
"elapsed": 0,
"stages": [
{"status": "running", "completed_at": None},
@@ -209,22 +257,73 @@ async def start_analysis(request: AnalysisRequest):
"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)
+ # Write analysis script to temp file with restrictive permissions (avoids subprocess -c quoting issues)
+ fd, script_path_str = tempfile.mkstemp(suffix=".py", prefix=f"analysis_{task_id}_")
+ script_path = Path(script_path_str)
+ os.chmod(script_path, 0o600)
+ with os.fdopen(fd, "w") as f:
+ f.write(ANALYSIS_SCRIPT_TEMPLATE)
# Store process reference for cancellation
app.state.processes = getattr(app.state, 'processes', {})
app.state.processes[task_id] = None
+ # Cancellation event for the monitor coroutine
+ cancel_event = asyncio.Event()
+
+ # Stage name to index mapping
+ STAGE_NAMES = ["analysts", "research", "trading", "risk", "portfolio"]
+
+ def _update_task_stage(stage_name: str):
+ """Update task state for a completed stage and mark next as running."""
+ try:
+ idx = STAGE_NAMES.index(stage_name)
+ except ValueError:
+ return
+ # Mark all previous stages as completed
+ for i in range(idx):
+ if app.state.task_results[task_id]["stages"][i]["status"] != "completed":
+ 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")
+ # Mark current as completed
+ if app.state.task_results[task_id]["stages"][idx]["status"] != "completed":
+ app.state.task_results[task_id]["stages"][idx]["status"] = "completed"
+ app.state.task_results[task_id]["stages"][idx]["completed_at"] = datetime.now().strftime("%H:%M:%S")
+ # Mark next as running
+ if idx + 1 < 5:
+ if app.state.task_results[task_id]["stages"][idx + 1]["status"] == "pending":
+ app.state.task_results[task_id]["stages"][idx + 1]["status"] = "running"
+ # Update progress
+ app.state.task_results[task_id]["progress"] = int((idx + 1) / 5 * 100)
+ app.state.task_results[task_id]["current_stage"] = stage_name
+
+ async def monitor_subprocess(task_id: str, proc: asyncio.subprocess.Process, cancel_evt: asyncio.Event):
+ """Monitor subprocess stdout for stage markers and broadcast progress."""
+ # Set stdout to non-blocking
+ fd = proc.stdout.fileno()
+ fl = fcntl.fcntl(fd, fcntl.GETFL)
+ fcntl.fcntl(fd, fcntl.SETFL, fl | os.O_NONBLOCK)
+
+ while not cancel_evt.is_set():
+ if proc.returncode is not None:
+ break
+ await asyncio.sleep(5)
+ if cancel_evt.is_set():
+ break
+ try:
+ chunk = os.read(fd, 32768)
+ if chunk:
+ for line in chunk.decode().splitlines():
+ if line.startswith("STAGE:"):
+ stage = line.split(":", 1)[1].strip()
+ _update_task_stage(stage)
+ await broadcast_progress(task_id, app.state.task_results[task_id])
+ except (BlockingIOError, OSError):
+ # No data available yet
+ pass
+
async def run_analysis():
"""Run analysis subprocess and broadcast progress"""
try:
@@ -240,15 +339,24 @@ async def start_analysis(request: AnalysisRequest):
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
+ # Start monitor coroutine alongside subprocess
+ monitor_task = asyncio.create_task(monitor_subprocess(task_id, proc, cancel_event))
+
stdout, stderr = await proc.communicate()
+ # Signal monitor to stop and wait for it
+ cancel_event.set()
+ try:
+ await asyncio.wait_for(monitor_task, timeout=1.0)
+ except asyncio.TimeoutError:
+ monitor_task.cancel()
+
# Clean up script file
try:
script_path.unlink()
@@ -258,7 +366,7 @@ async def start_analysis(request: AnalysisRequest):
if proc.returncode == 0:
output = stdout.decode()
decision = "HOLD"
- for line in output.split("\n"):
+ for line in output.splitlines():
if line.startswith("ANALYSIS_COMPLETE:"):
decision = line.split(":", 1)[1].strip()
@@ -268,13 +376,17 @@ async def start_analysis(request: AnalysisRequest):
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")
+ if not app.state.task_results[task_id]["stages"][i].get("completed_at"):
+ 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
+ _save_task_status(task_id, app.state.task_results[task_id])
+
except Exception as e:
+ cancel_event.set()
app.state.task_results[task_id]["status"] = "failed"
app.state.task_results[task_id]["error"] = str(e)
try:
@@ -282,6 +394,8 @@ async def start_analysis(request: AnalysisRequest):
except Exception:
pass
+ _save_task_status(task_id, app.state.task_results[task_id])
+
await broadcast_progress(task_id, app.state.task_results[task_id])
task = asyncio.create_task(run_analysis())
@@ -316,10 +430,10 @@ async def list_tasks():
"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,
+ "created_at": state.get("created_at"),
})
- # Sort by task_id (which includes timestamp) descending
- tasks.sort(key=lambda x: x["task_id"], reverse=True)
+ # Sort by created_at descending (most recent first)
+ tasks.sort(key=lambda x: x.get("created_at") or "", reverse=True)
return {"tasks": tasks, "total": len(tasks)}
@@ -343,14 +457,18 @@ async def cancel_task(task_id: str):
task.cancel()
app.state.task_results[task_id]["status"] = "failed"
app.state.task_results[task_id]["error"] = "用户取消"
+ _save_task_status(task_id, app.state.task_results[task_id])
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
+ # Clean up temp script (may use tempfile.mkstemp with random suffix)
+ for p in Path("/tmp").glob(f"analysis_{task_id}_*.py"):
+ try:
+ p.unlink()
+ except Exception:
+ pass
+
+ # Remove persisted task state
+ _delete_task_status(task_id)
return {"task_id": task_id, "status": "cancelled"}
@@ -430,7 +548,17 @@ def get_reports_list():
def get_report_content(ticker: str, date: str) -> Optional[dict]:
"""Get report content for a specific ticker and date"""
+ # Validate inputs to prevent path traversal
+ if ".." in ticker or "/" in ticker or "\\" in ticker:
+ return None
+ if ".." in date or "/" in date or "\\" in date:
+ return None
report_dir = get_results_dir() / ticker / date
+ # Strict traversal check: resolved path must be within get_results_dir()
+ try:
+ report_dir.resolve().relative_to(get_results_dir().resolve())
+ except ValueError:
+ return None
if not report_dir.exists():
return None
content = {}
@@ -458,6 +586,419 @@ async def get_report(ticker: str, date: str):
return content
+# ============== Report Export ==============
+
+import csv
+import io
+import re
+from fpdf import FPDF
+
+
+def _extract_decision(markdown_text: str) -> str:
+ """Extract BUY/SELL/HOLD from markdown bold text."""
+ match = re.search(r'\*\*(BUY|SELL|HOLD)\*\*', markdown_text)
+ return match.group(1) if match else 'UNKNOWN'
+
+
+def _extract_summary(markdown_text: str) -> str:
+ """Extract first ~200 chars after '## 分析摘要'."""
+ match = re.search(r'## 分析摘要\s*\n+(.{0,300}?)(?=\n##|\Z)', markdown_text, re.DOTALL)
+ if match:
+ text = match.group(1).strip()
+ # Strip markdown formatting
+ text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
+ text = re.sub(r'\*(.*?)\*', r'\1', text)
+ text = re.sub(r'[#\n]+', ' ', text)
+ return text[:200].strip()
+ return ''
+
+
+@app.get("/api/reports/export")
+async def export_reports_csv():
+ """Export all reports as CSV: ticker,date,decision,summary."""
+ reports = get_reports_list()
+ output = io.StringIO()
+ writer = csv.DictWriter(output, fieldnames=["ticker", "date", "decision", "summary"])
+ writer.writeheader()
+ for r in reports:
+ content = get_report_content(r["ticker"], r["date"])
+ if content and content.get("report"):
+ writer.writerow({
+ "ticker": r["ticker"],
+ "date": r["date"],
+ "decision": _extract_decision(content["report"]),
+ "summary": _extract_summary(content["report"]),
+ })
+ else:
+ writer.writerow({
+ "ticker": r["ticker"],
+ "date": r["date"],
+ "decision": "UNKNOWN",
+ "summary": "",
+ })
+ return Response(
+ content=output.getvalue(),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=tradingagents_reports.csv"},
+ )
+
+
+@app.get("/api/reports/{ticker}/{date}/pdf")
+async def export_report_pdf(ticker: str, date: str):
+ """Export a single report as PDF."""
+ content = get_report_content(ticker, date)
+ if not content or not content.get("report"):
+ raise HTTPException(status_code=404, detail="Report not found")
+
+ markdown_text = content["report"]
+ decision = _extract_decision(markdown_text)
+ summary = _extract_summary(markdown_text)
+
+ pdf = FPDF()
+ pdf.set_auto_page_break(auto=True, margin=20)
+
+ # Try multiple font paths for cross-platform support
+ font_paths = [
+ "/System/Library/Fonts/Supplemental/DejaVuSans.ttf",
+ "/System/Library/Fonts/Supplemental/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+ "/usr/share/fonts/dejavu/DejaVuSans.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
+ str(Path.home() / ".local/share/fonts/DejaVuSans.ttf"),
+ str(Path.home() / ".fonts/DejaVuSans.ttf"),
+ ]
+ regular_font = None
+ bold_font = None
+ for p in font_paths:
+ if Path(p).exists():
+ if "Bold" in p and bold_font is None:
+ bold_font = p
+ elif regular_font is None and "Bold" not in p:
+ regular_font = p
+
+ use_dejavu = bool(regular_font and bold_font)
+ if use_dejavu:
+ pdf.add_font("DejaVu", "", regular_font, unicode=True)
+ pdf.add_font("DejaVu", "B", bold_font, unicode=True)
+ font_regular = "DejaVu"
+ font_bold = "DejaVu"
+ else:
+ font_regular = "Helvetica"
+ font_bold = "Helvetica"
+
+ pdf.add_page()
+ pdf.set_font(font_bold, "B", 18)
+ pdf.cell(0, 12, f"TradingAgents 分析报告", ln=True, align="C")
+ pdf.ln(5)
+
+ pdf.set_font(font_regular, "", 11)
+ pdf.cell(0, 8, f"股票: {ticker} 日期: {date}", ln=True)
+ pdf.ln(3)
+
+ # Decision badge
+ pdf.set_font(font_bold, "B", 14)
+ if decision == "BUY":
+ pdf.set_text_color(34, 197, 94)
+ elif decision == "SELL":
+ pdf.set_text_color(220, 38, 38)
+ else:
+ pdf.set_text_color(245, 158, 11)
+ pdf.cell(0, 10, f"决策: {decision}", ln=True)
+ pdf.set_text_color(0, 0, 0)
+ pdf.ln(5)
+
+ # Summary
+ pdf.set_font(font_bold, "B", 12)
+ pdf.cell(0, 8, "分析摘要", ln=True)
+ pdf.set_font(font_regular, "", 10)
+ pdf.multi_cell(0, 6, summary or "无")
+ pdf.ln(5)
+
+ # Full report text (stripped of heavy markdown)
+ pdf.set_font(font_bold, "B", 12)
+ pdf.cell(0, 8, "完整报告", ln=True)
+ pdf.set_font(font_regular, "", 9)
+ # Split into lines, filter out very long lines
+ for line in markdown_text.splitlines():
+ line = re.sub(r'\*\*(.*?)\*\*', r'\1', line)
+ line = re.sub(r'\*(.*?)\*', r'\1', line)
+ line = re.sub(r'#{1,6} ', '', line)
+ line = line.strip()
+ if not line:
+ pdf.ln(2)
+ continue
+ if len(line) > 120:
+ line = line[:120] + "..."
+ try:
+ pdf.multi_cell(0, 5, line)
+ except Exception:
+ pass
+
+ return Response(
+ content=pdf.output(),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f"attachment; filename={ticker}_{date}_report.pdf"},
+ )
+
+
+# ============== Portfolio ==============
+
+import sys
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from api.portfolio import (
+ get_watchlist, add_to_watchlist, remove_from_watchlist,
+ get_positions, add_position, remove_position,
+ get_accounts, create_account, delete_account,
+ get_recommendations, get_recommendation, save_recommendation,
+ RECOMMENDATIONS_DIR,
+)
+
+
+# --- Watchlist ---
+
+@app.get("/api/portfolio/watchlist")
+async def list_watchlist():
+ return {"watchlist": get_watchlist()}
+
+
+@app.post("/api/portfolio/watchlist")
+async def create_watchlist_entry(body: dict):
+ try:
+ entry = add_to_watchlist(body["ticker"], body.get("name", body["ticker"]))
+ return entry
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@app.delete("/api/portfolio/watchlist/{ticker}")
+async def delete_watchlist_entry(ticker: str):
+ if remove_from_watchlist(ticker):
+ return {"ok": True}
+ raise HTTPException(status_code=404, detail="Ticker not found in watchlist")
+
+
+# --- Accounts ---
+
+@app.get("/api/portfolio/accounts")
+async def list_accounts():
+ accounts = get_accounts()
+ return {"accounts": list(accounts.get("accounts", {}).keys())}
+
+
+@app.post("/api/portfolio/accounts")
+async def create_account_endpoint(body: dict):
+ try:
+ return create_account(body["account_name"])
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@app.delete("/api/portfolio/accounts/{account_name}")
+async def delete_account_endpoint(account_name: str):
+ if delete_account(account_name):
+ return {"ok": True}
+ raise HTTPException(status_code=404, detail="Account not found")
+
+
+# --- Positions ---
+
+@app.get("/api/portfolio/positions")
+async def list_positions(account: Optional[str] = Query(None)):
+ return {"positions": get_positions(account)}
+
+
+@app.post("/api/portfolio/positions")
+async def create_position(body: dict):
+ try:
+ pos = add_position(
+ ticker=body["ticker"],
+ shares=body["shares"],
+ cost_price=body["cost_price"],
+ purchase_date=body.get("purchase_date"),
+ notes=body.get("notes", ""),
+ account=body.get("account", "默认账户"),
+ )
+ return pos
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@app.delete("/api/portfolio/positions/{ticker}")
+async def delete_position(ticker: str, position_id: Optional[str] = Query(None), account: Optional[str] = Query(None)):
+ removed = remove_position(ticker, position_id or "", account)
+ if removed:
+ return {"ok": True}
+ raise HTTPException(status_code=404, detail="Position not found")
+
+
+@app.get("/api/portfolio/positions/export")
+async def export_positions_csv(account: Optional[str] = Query(None)):
+ positions = get_positions(account)
+ import csv
+ import io
+ output = io.StringIO()
+ writer = csv.DictWriter(output, fieldnames=["ticker", "shares", "cost_price", "purchase_date", "notes", "account"])
+ writer.writeheader()
+ for p in positions:
+ writer.writerow({k: p[k] for k in ["ticker", "shares", "cost_price", "purchase_date", "notes", "account"]})
+ return Response(content=output.getvalue(), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=positions.csv"})
+
+
+# --- Recommendations ---
+
+@app.get("/api/portfolio/recommendations")
+async def list_recommendations(date: Optional[str] = Query(None)):
+ return {"recommendations": get_recommendations(date)}
+
+
+@app.get("/api/portfolio/recommendations/{date}/{ticker}")
+async def get_recommendation_endpoint(date: str, ticker: str):
+ rec = get_recommendation(date, ticker)
+ if not rec:
+ raise HTTPException(status_code=404, detail="Recommendation not found")
+ return rec
+
+
+# --- Batch Analysis ---
+
+@app.post("/api/portfolio/analyze")
+async def start_portfolio_analysis():
+ """
+ Trigger batch analysis for all watchlist tickers.
+ Runs serially, streaming progress via WebSocket (task_id prefixed with 'port_').
+ """
+ import uuid
+ date = datetime.now().strftime("%Y-%m-%d")
+ task_id = f"port_{date}_{uuid.uuid4().hex[:6]}"
+
+ watchlist = get_watchlist()
+ if not watchlist:
+ raise HTTPException(status_code=400, detail="自选股为空,请先添加股票")
+
+ total = len(watchlist)
+ app.state.task_results[task_id] = {
+ "task_id": task_id,
+ "type": "portfolio",
+ "status": "running",
+ "total": total,
+ "completed": 0,
+ "failed": 0,
+ "current_ticker": None,
+ "results": [],
+ "error": None,
+ }
+
+ 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])
+
+ async def run_portfolio_analysis():
+ max_retries = MAX_RETRY_COUNT
+
+ async def run_single_analysis(ticker: str, stock: dict) -> tuple[bool, str, dict | None]:
+ """Run analysis for one ticker. Returns (success, decision, rec_or_error)."""
+ last_error = None
+ for attempt in range(max_retries + 1):
+ script_path = None
+ try:
+ fd, script_path_str = tempfile.mkstemp(suffix=".py", prefix=f"analysis_{task_id}_{stock['_idx']}_")
+ script_path = Path(script_path_str)
+ os.chmod(script_path, 0o600)
+ with os.fdopen(fd, "w") as f:
+ f.write(ANALYSIS_SCRIPT_TEMPLATE)
+
+ 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), ticker, date, str(REPO_ROOT),
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
+ env=clean_env,
+ )
+ app.state.processes[task_id] = proc
+
+ stdout, stderr = await proc.communicate()
+
+ try:
+ script_path.unlink()
+ except Exception:
+ pass
+
+ if proc.returncode == 0:
+ output = stdout.decode()
+ decision = "HOLD"
+ for line in output.splitlines():
+ if line.startswith("ANALYSIS_COMPLETE:"):
+ decision = line.split(":", 1)[1].strip()
+ rec = {
+ "ticker": ticker,
+ "name": stock.get("name", ticker),
+ "analysis_date": date,
+ "decision": decision,
+ "created_at": datetime.now().isoformat(),
+ }
+ save_recommendation(date, ticker, rec)
+ return True, decision, rec
+ else:
+ last_error = stderr.decode()[-500:] if stderr else f"exit {proc.returncode}"
+ except Exception as e:
+ last_error = str(e)
+ finally:
+ if script_path:
+ try:
+ script_path.unlink()
+ except Exception:
+ pass
+ if attempt < max_retries:
+ await asyncio.sleep(RETRY_BASE_DELAY_SECS ** attempt) # exponential backoff: 1s, 2s
+
+ return False, "HOLD", None
+
+ try:
+ for i, stock in enumerate(watchlist):
+ stock["_idx"] = i # used in temp file name
+ ticker = stock["ticker"]
+ app.state.task_results[task_id]["current_ticker"] = ticker
+ app.state.task_results[task_id]["status"] = "running"
+ app.state.task_results[task_id]["completed"] = i
+ await broadcast_progress(task_id, app.state.task_results[task_id])
+
+ success, decision, rec = await run_single_analysis(ticker, stock)
+ if success:
+ app.state.task_results[task_id]["completed"] = i + 1
+ app.state.task_results[task_id]["results"].append(rec)
+ else:
+ app.state.task_results[task_id]["failed"] += 1
+
+ await broadcast_progress(task_id, app.state.task_results[task_id])
+
+ app.state.task_results[task_id]["status"] = "completed"
+ app.state.task_results[task_id]["current_ticker"] = None
+ _save_task_status(task_id, app.state.task_results[task_id])
+
+ except Exception as e:
+ app.state.task_results[task_id]["status"] = "failed"
+ app.state.task_results[task_id]["error"] = str(e)
+ _save_task_status(task_id, app.state.task_results[task_id])
+
+ await broadcast_progress(task_id, app.state.task_results[task_id])
+
+ task = asyncio.create_task(run_portfolio_analysis())
+ app.state.analysis_tasks[task_id] = task
+
+ return {
+ "task_id": task_id,
+ "total": total,
+ "status": "running",
+ }
+
+
+
@app.get("/")
async def root():
return {"message": "TradingAgents Web Dashboard API", "version": "0.1.0"}
diff --git a/web_dashboard/frontend/index.html b/web_dashboard/frontend/index.html
new file mode 100644
index 00000000..87db1b11
--- /dev/null
+++ b/web_dashboard/frontend/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ TradingAgents Dashboard
+
+
+
+
+
+
+
+
+
diff --git a/web_dashboard/frontend/src/App.jsx b/web_dashboard/frontend/src/App.jsx
new file mode 100644
index 00000000..374ec0bb
--- /dev/null
+++ b/web_dashboard/frontend/src/App.jsx
@@ -0,0 +1,170 @@
+import { useState, useEffect, lazy, Suspense } from 'react'
+import { Routes, Route, NavLink, useLocation, useNavigate } from 'react-router-dom'
+import {
+ FundOutlined,
+ MonitorOutlined,
+ FileTextOutlined,
+ ClusterOutlined,
+ MenuFoldOutlined,
+ MenuUnfoldOutlined,
+ WalletOutlined,
+} from '@ant-design/icons'
+
+const ScreeningPanel = lazy(() => import('./pages/ScreeningPanel'))
+const AnalysisMonitor = lazy(() => import('./pages/AnalysisMonitor'))
+const ReportsViewer = lazy(() => import('./pages/ReportsViewer'))
+const BatchManager = lazy(() => import('./pages/BatchManager'))
+const PortfolioPanel = lazy(() => import('./pages/PortfolioPanel'))
+
+const navItems = [
+ { path: '/', icon: , label: '筛选', key: '1' },
+ { path: '/monitor', icon: , label: '监控', key: '2' },
+ { path: '/reports', icon: , label: '报告', key: '3' },
+ { path: '/batch', icon: , label: '批量', key: '4' },
+ { path: '/portfolio', icon: , label: '组合', key: '5' },
+]
+
+function Layout({ children }) {
+ const [collapsed, setCollapsed] = useState(false)
+ const [isMobile, setIsMobile] = useState(false)
+ const location = useLocation()
+
+ useEffect(() => {
+ const checkMobile = () => setIsMobile(window.innerWidth < 768)
+ checkMobile()
+ window.addEventListener('resize', checkMobile)
+ return () => window.removeEventListener('resize', checkMobile)
+ }, [])
+
+ const currentPage = navItems.find(item =>
+ item.path === '/'
+ ? location.pathname === '/'
+ : location.pathname.startsWith(item.path)
+ )?.label || 'TradingAgents'
+
+ return (
+
+ {/* Sidebar - Apple Glass Navigation */}
+ {!isMobile && (
+
+ )}
+
+ {/* Main Content */}
+
+ {!isMobile && (
+
+ {currentPage}
+
+ {new Date().toLocaleDateString('zh-CN', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })}
+
+
+ )}
+
+
+ {children}
+
+
+
+ {/* Mobile TabBar */}
+ {isMobile && (
+
+ )}
+
+ )
+}
+
+export default function App() {
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
+ // Close modals on Escape
+ if (e.key === 'Escape') {
+ document.querySelector('.ant-modal-wrap')?.click()
+ return
+ }
+ // Navigation shortcuts
+ switch (e.key) {
+ case '1': navigate('/'); break
+ case '2': navigate('/monitor'); break
+ case '3': navigate('/reports'); break
+ case '4': navigate('/batch'); break
+ case '5': navigate('/portfolio'); break
+ default: break
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [navigate])
+
+ return (
+
+
+ 加载中...
+
+ }>
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ )
+}
diff --git a/web_dashboard/frontend/src/index.css b/web_dashboard/frontend/src/index.css
new file mode 100644
index 00000000..9c121dc9
--- /dev/null
+++ b/web_dashboard/frontend/src/index.css
@@ -0,0 +1,879 @@
+/* TradingAgents Dashboard - Apple Design System */
+
+:root {
+ /* === Apple Color System === */
+ /* Backgrounds */
+ --color-black: #000000;
+ --color-white: #ffffff;
+ --color-light-gray: #f5f5f7;
+ --color-near-black: #1d1d1f;
+
+ /* Interactive */
+ --color-apple-blue: #0071e3;
+ --color-link-blue: #0066cc;
+ --color-link-blue-bright: #2997ff;
+
+ /* Text */
+ --color-text-dark: rgba(0, 0, 0, 0.8);
+ --color-text-secondary: rgba(0, 0, 0, 0.48);
+ --color-text-white-80: rgba(255, 255, 255, 0.8);
+ --color-text-white-48: rgba(255, 255, 255, 0.48);
+
+ /* Dark Surfaces */
+ --color-dark-1: #272729;
+ --color-dark-2: #262628;
+ --color-dark-3: #28282a;
+ --color-dark-4: #2a2a2d;
+ --color-dark-5: #242426;
+
+ /* Buttons */
+ --color-btn-active: #ededf2;
+ --color-btn-light: #fafafc;
+ --color-overlay: rgba(210, 210, 215, 0.64);
+ --color-white-32: rgba(255, 255, 255, 0.32);
+
+ /* Shadows */
+ --shadow-card: rgba(0, 0, 0, 0.22) 3px 5px 30px 0px;
+
+ /* === Semantic Colors (kept for financial data) === */
+ --color-buy: #22c55e;
+ --color-sell: #dc2626;
+ --color-hold: #f59e0b;
+ --color-running: #a855f7;
+
+ /* === Spacing (Apple 8px base) === */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-7: 28px;
+ --space-8: 32px;
+ --space-9: 36px;
+ --space-10: 40px;
+ --space-11: 44px;
+ --space-12: 48px;
+ --space-14: 56px;
+ --space-16: 64px;
+
+ /* === Typography === */
+ --font-display: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ --font-text: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ --font-data: 'DM Sans', 'JetBrains Mono', 'Menlo', monospace;
+
+ /* Apple type scale */
+ --text-hero: 56px;
+ --text-section: 40px;
+ --text-tile: 28px;
+ --text-card: 21px;
+ --text-nav: 17px;
+ --text-body: 17px;
+ --text-button: 17px;
+ --text-link: 14px;
+ --text-caption: 12px;
+
+ /* === Border Radius === */
+ --radius-micro: 5px;
+ --radius-standard: 8px;
+ --radius-comfortable: 11px;
+ --radius-large: 12px;
+ --radius-pill: 980px;
+ --radius-circle: 50%;
+
+ /* === Transitions === */
+ --transition-fast: 150ms ease;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: var(--font-text);
+ background-color: var(--color-light-gray);
+ color: var(--color-near-black);
+ line-height: 1.47;
+ letter-spacing: -0.374px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* === Scrollbar === */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.15);
+ border-radius: var(--radius-standard);
+}
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.25);
+}
+
+/* === Layout === */
+.dashboard-layout {
+ display: flex;
+ min-height: 100vh;
+}
+
+/* === Sidebar (Apple Glass Nav) === */
+.sidebar {
+ width: 240px;
+ background: rgba(0, 0, 0, 0.8);
+ backdrop-filter: saturate(180%) blur(20px);
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
+ border-right: none;
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ z-index: 100;
+ transition: width var(--transition-fast);
+}
+
+.sidebar.collapsed {
+ width: 64px;
+}
+
+.sidebar-logo {
+ padding: var(--space-4) var(--space-4);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--color-white);
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ height: 48px;
+ letter-spacing: -0.28px;
+}
+
+.sidebar-nav {
+ flex: 1;
+ padding: var(--space-2) var(--space-2);
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-standard);
+ color: var(--color-text-white-80);
+ text-decoration: none;
+ transition: all var(--transition-fast);
+ cursor: pointer;
+ margin-bottom: var(--space-1);
+ font-size: 14px;
+ font-weight: 400;
+ height: 36px;
+}
+
+.nav-item:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--color-white);
+}
+
+.nav-item.active {
+ background: rgba(255, 255, 255, 0.12);
+ color: var(--color-white);
+}
+
+.nav-item svg {
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+}
+
+.nav-item span {
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.sidebar-collapse-btn {
+ background: none;
+ border: none;
+ color: var(--color-text-white-48);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 12px;
+ padding: var(--space-3) var(--space-3);
+ border-radius: var(--radius-standard);
+ transition: color var(--transition-fast);
+ width: 100%;
+ justify-content: flex-start;
+}
+
+.sidebar-collapse-btn:hover {
+ color: var(--color-white);
+}
+
+.sidebar-collapse-btn:focus-visible {
+ outline: 2px solid var(--color-apple-blue);
+ outline-offset: 2px;
+}
+
+/* Collapsed sidebar: hide button label, center icon */
+.sidebar.collapsed .sidebar-collapse-btn {
+ justify-content: center;
+ padding: var(--space-3);
+}
+.sidebar.collapsed .sidebar-collapse-btn span {
+ display: none;
+}
+
+/* === Main Content === */
+.main-content {
+ flex: 1;
+ margin-left: 240px;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ transition: margin-left var(--transition-fast);
+}
+
+.sidebar.collapsed ~ .main-content {
+ margin-left: 64px;
+}
+
+.topbar {
+ height: 48px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 var(--space-6);
+ background: var(--color-white);
+ position: sticky;
+ top: 0;
+ z-index: 50;
+}
+
+.topbar-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--color-near-black);
+ letter-spacing: -0.224px;
+}
+
+.topbar-date {
+ font-size: 14px;
+ color: var(--color-text-secondary);
+ font-weight: 400;
+}
+
+.page-content {
+ flex: 1;
+ padding: var(--space-8) var(--space-6);
+ max-width: 1200px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+/* === Apple Cards === */
+.card {
+ background: var(--color-white);
+ border: none;
+ border-radius: var(--radius-standard);
+ padding: var(--space-6);
+ box-shadow: none;
+ transition: box-shadow var(--transition-fast);
+}
+
+.card:hover {
+ box-shadow: var(--shadow-card);
+}
+
+.card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-4);
+}
+
+.card-title {
+ font-family: var(--font-display);
+ font-size: 21px;
+ font-weight: 700;
+ letter-spacing: 0.231px;
+ line-height: 1.19;
+ color: var(--color-near-black);
+}
+
+/* === Apple Section === */
+.section-dark {
+ background: var(--color-black);
+ color: var(--color-white);
+}
+
+.section-light {
+ background: var(--color-light-gray);
+ color: var(--color-near-black);
+}
+
+.section-full {
+ min-height: 100vh;
+}
+
+/* === Typography === */
+.text-hero {
+ font-family: var(--font-display);
+ font-size: var(--text-hero);
+ font-weight: 600;
+ line-height: 1.07;
+ letter-spacing: -0.28px;
+}
+
+.text-section-heading {
+ font-family: var(--font-display);
+ font-size: var(--text-section);
+ font-weight: 600;
+ line-height: 1.10;
+}
+
+.text-tile-heading {
+ font-family: var(--font-display);
+ font-size: var(--text-tile);
+ font-weight: 400;
+ line-height: 1.14;
+ letter-spacing: 0.196px;
+}
+
+.text-card-title {
+ font-family: var(--font-display);
+ font-size: var(--text-card);
+ font-weight: 700;
+ line-height: 1.19;
+ letter-spacing: 0.231px;
+}
+
+.text-body {
+ font-family: var(--font-text);
+ font-size: var(--text-body);
+ font-weight: 400;
+ line-height: 1.47;
+ letter-spacing: -0.374px;
+}
+
+.text-emphasis {
+ font-family: var(--font-text);
+ font-size: var(--text-body);
+ font-weight: 600;
+ line-height: 1.24;
+ letter-spacing: -0.374px;
+}
+
+.text-link {
+ font-family: var(--font-text);
+ font-size: var(--text-link);
+ font-weight: 400;
+ line-height: 1.43;
+ letter-spacing: -0.224px;
+}
+
+.text-caption {
+ font-family: var(--font-text);
+ font-size: var(--text-caption);
+ font-weight: 400;
+ line-height: 1.29;
+ letter-spacing: -0.224px;
+ color: var(--color-text-secondary);
+}
+
+.text-data {
+ font-family: var(--font-data);
+ font-size: 14px;
+}
+
+/* === Apple Buttons === */
+.btn-primary {
+ background: var(--color-apple-blue);
+ color: var(--color-white);
+ border: none;
+ border-radius: var(--radius-standard);
+ padding: 8px 15px;
+ font-family: var(--font-text);
+ font-size: var(--text-button);
+ font-weight: 400;
+ line-height: 1;
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.btn-primary:hover {
+ background: #0077ED;
+}
+
+.btn-primary:active {
+ background: var(--color-btn-active);
+}
+
+.btn-primary:focus-visible {
+ outline: 2px solid var(--color-apple-blue);
+ outline-offset: 2px;
+}
+
+.btn-secondary {
+ background: var(--color-near-black);
+ color: var(--color-white);
+ border: none;
+ border-radius: var(--radius-standard);
+ padding: 8px 15px;
+ font-family: var(--font-text);
+ font-size: var(--text-button);
+ font-weight: 400;
+ line-height: 1;
+ cursor: pointer;
+ transition: opacity var(--transition-fast);
+}
+
+.btn-secondary:hover {
+ opacity: 0.85;
+}
+
+.btn-secondary:active {
+ background: var(--color-dark-1);
+}
+
+.btn-secondary:focus-visible {
+ outline: 2px solid var(--color-apple-blue);
+ outline-offset: 2px;
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--color-link-blue);
+ border: 1px solid var(--color-link-blue);
+ border-radius: var(--radius-pill);
+ padding: 6px 14px;
+ font-family: var(--font-text);
+ font-size: var(--text-link);
+ font-weight: 400;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.btn-ghost:hover {
+ text-decoration: underline;
+}
+
+.btn-ghost:focus-visible {
+ outline: 2px solid var(--color-apple-blue);
+ outline-offset: 2px;
+}
+
+.btn-filter {
+ background: var(--color-btn-light);
+ color: var(--color-text-dark);
+ border: none;
+ border-radius: var(--radius-comfortable);
+ padding: 0px 14px;
+ height: 32px;
+ font-family: var(--font-text);
+ font-size: 12px;
+ font-weight: 400;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04);
+ transition: all var(--transition-fast);
+}
+
+.btn-filter:hover {
+ box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.08);
+}
+
+.btn-filter:active {
+ background: var(--color-btn-active);
+}
+
+.btn-filter:focus-visible {
+ outline: 2px solid var(--color-apple-blue);
+ outline-offset: 2px;
+}
+
+/* === Decision Badges === */
+.badge-buy {
+ background: var(--color-buy);
+ color: var(--color-white);
+ padding: 4px 12px;
+ border-radius: var(--radius-pill);
+ font-family: var(--font-text);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.badge-sell {
+ background: var(--color-sell);
+ color: var(--color-white);
+ padding: 4px 12px;
+ border-radius: var(--radius-pill);
+ font-family: var(--font-text);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.badge-hold {
+ background: var(--color-hold);
+ color: var(--color-white);
+ padding: 4px 12px;
+ border-radius: var(--radius-pill);
+ font-family: var(--font-text);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.badge-running {
+ background: var(--color-running);
+ color: var(--color-white);
+ padding: 4px 12px;
+ border-radius: var(--radius-pill);
+ font-family: var(--font-text);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+/* === Stage Pills === */
+.stage-pill {
+ padding: 8px 16px;
+ border-radius: var(--radius-standard);
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 14px;
+ font-weight: 500;
+ transition: all var(--transition-fast);
+}
+
+.stage-pill.completed {
+ background: rgba(34, 197, 94, 0.15);
+ color: var(--color-buy);
+}
+
+.stage-pill.running {
+ background: rgba(168, 85, 247, 0.15);
+ color: var(--color-running);
+}
+
+.stage-pill.pending {
+ background: rgba(0, 0, 0, 0.05);
+ color: var(--color-text-secondary);
+}
+
+.stage-pill.failed {
+ background: rgba(220, 38, 38, 0.15);
+ color: var(--color-sell);
+}
+
+/* === Empty States === */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-16);
+ text-align: center;
+}
+
+.empty-state svg {
+ width: 48px;
+ height: 48px;
+ color: var(--color-text-secondary);
+ margin-bottom: var(--space-4);
+}
+
+.empty-state-title {
+ font-family: var(--font-display);
+ font-size: 21px;
+ font-weight: 700;
+ letter-spacing: 0.231px;
+ color: var(--color-near-black);
+ margin-bottom: var(--space-2);
+}
+
+.empty-state-description {
+ font-size: 14px;
+ color: var(--color-text-secondary);
+ max-width: 280px;
+}
+
+/* === Progress Bar === */
+.progress-bar {
+ height: 4px;
+ background: rgba(0, 0, 0, 0.08);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: var(--color-apple-blue);
+ border-radius: 2px;
+ transition: width 300ms ease-out;
+}
+
+/* === Status Dot === */
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+}
+
+.status-dot.connected {
+ background: var(--color-buy);
+}
+
+.status-dot.error {
+ background: var(--color-sell);
+}
+
+/* === Data Table === */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.data-table th {
+ font-family: var(--font-text);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ text-align: left;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
+ letter-spacing: 0.024px;
+}
+
+.data-table td {
+ padding: var(--space-4);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+ font-size: 14px;
+ color: var(--color-near-black);
+}
+
+.data-table tr:last-child td {
+ border-bottom: none;
+}
+
+.data-table tr:hover td {
+ background: rgba(0, 0, 0, 0.02);
+}
+
+.data-table .numeric {
+ font-family: var(--font-data);
+ text-align: right;
+}
+
+/* === Loading Pulse === */
+@keyframes apple-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.loading-pulse {
+ animation: apple-pulse 2s ease-in-out infinite;
+ color: var(--color-apple-blue);
+}
+
+/* === Responsive === */
+@media (max-width: 1024px) {
+ .sidebar {
+ width: 64px;
+ }
+ .sidebar-logo span,
+ .nav-item span,
+ .sidebar-collapse-btn span:not(:first-child) {
+ display: none;
+ }
+ .main-content {
+ margin-left: 64px;
+ }
+}
+
+@media (max-width: 767px) {
+ .sidebar {
+ display: none;
+ }
+ .main-content {
+ margin-left: 0;
+ }
+ .topbar {
+ padding: 0 var(--space-4);
+ }
+ .page-content {
+ padding: var(--space-4);
+ }
+}
+
+/* === Ant Design Overrides === */
+.ant-table {
+ background: transparent !important;
+ font-family: var(--font-text) !important;
+}
+
+.ant-table-thead > tr > th {
+ background: transparent !important;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
+ color: var(--color-text-secondary) !important;
+ font-size: 12px !important;
+ font-weight: 600 !important;
+ letter-spacing: 0.024px !important;
+ padding: var(--space-3) var(--space-4) !important;
+}
+
+.ant-table-tbody > tr > td {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06) !important;
+ padding: var(--space-4) !important;
+ color: var(--color-near-black) !important;
+ font-size: 14px !important;
+}
+
+.ant-table-tbody > tr:hover > td {
+ background: rgba(0, 0, 0, 0.02) !important;
+}
+
+.ant-select-selector {
+ border-radius: var(--radius-comfortable) !important;
+ background: var(--color-btn-light) !important;
+ border: none !important;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important;
+ font-family: var(--font-text) !important;
+}
+
+.ant-select-dropdown {
+ border-radius: var(--radius-standard) !important;
+ box-shadow: var(--shadow-card) !important;
+}
+
+.ant-popover-inner {
+ border-radius: var(--radius-standard) !important;
+ box-shadow: var(--shadow-card) !important;
+}
+
+.ant-popover-title {
+ font-family: var(--font-display) !important;
+ font-weight: 600 !important;
+ border-bottom: none !important;
+}
+
+.ant-btn-primary {
+ background: var(--color-apple-blue) !important;
+ border: none !important;
+ border-radius: var(--radius-standard) !important;
+ font-family: var(--font-text) !important;
+ font-size: 14px !important;
+ font-weight: 400 !important;
+ box-shadow: none !important;
+}
+
+.ant-btn-primary:hover {
+ background: #0077ED !important;
+}
+
+.ant-btn-primary:active {
+ background: var(--color-btn-active) !important;
+}
+
+.ant-btn-primary:focus-visible {
+ outline: 2px solid var(--color-apple-blue) !important;
+ outline-offset: 2px !important;
+}
+
+.ant-btn-default {
+ border-radius: var(--radius-standard) !important;
+ border: none !important;
+ background: var(--color-btn-light) !important;
+ color: var(--color-text-dark) !important;
+ font-family: var(--font-text) !important;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important;
+}
+
+.ant-btn-default:hover {
+ background: var(--color-btn-active) !important;
+}
+
+.ant-skeleton {
+ padding: var(--space-4) !important;
+}
+
+.ant-result-title {
+ font-family: var(--font-display) !important;
+ font-weight: 600 !important;
+}
+
+.ant-statistic-title {
+ font-family: var(--font-text) !important;
+ font-size: 12px !important;
+ color: var(--color-text-secondary) !important;
+ letter-spacing: 0.024px !important;
+}
+
+.ant-statistic-content {
+ font-family: var(--font-data) !important;
+ font-size: 28px !important;
+ font-weight: 600 !important;
+ color: var(--color-near-black) !important;
+}
+
+.ant-progress-inner {
+ background: rgba(0, 0, 0, 0.08) !important;
+ border-radius: 2px !important;
+}
+
+.ant-progress-bg {
+ background: var(--color-apple-blue) !important;
+}
+
+.ant-tag {
+ border-radius: var(--radius-pill) !important;
+ font-family: var(--font-text) !important;
+ font-size: 12px !important;
+ font-weight: 600 !important;
+ border: none !important;
+}
+
+.ant-input-number {
+ border-radius: var(--radius-comfortable) !important;
+ border: none !important;
+ background: var(--color-btn-light) !important;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important;
+ font-family: var(--font-text) !important;
+}
+
+.ant-input-number-input {
+ font-family: var(--font-text) !important;
+}
+
+.ant-tabs-nav::before {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
+}
+
+.ant-tabs-tab {
+ font-family: var(--font-text) !important;
+ font-size: 14px !important;
+ color: var(--color-text-secondary) !important;
+}
+
+.ant-tabs-tab-active .ant-tabs-tab-btn {
+ color: var(--color-near-black) !important;
+ font-weight: 600 !important;
+}
diff --git a/web_dashboard/frontend/src/main.jsx b/web_dashboard/frontend/src/main.jsx
new file mode 100644
index 00000000..83c27d64
--- /dev/null
+++ b/web_dashboard/frontend/src/main.jsx
@@ -0,0 +1,48 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { ConfigProvider, theme } from 'antd'
+import App from './App'
+import './index.css'
+
+// Apple Design System Ant Design configuration
+const appleTheme = {
+ algorithm: theme.defaultAlgorithm,
+ token: {
+ colorPrimary: '#0071e3',
+ colorSuccess: '#22c55e',
+ colorError: '#dc2626',
+ colorWarning: '#f59e0b',
+ colorInfo: '#0071e3',
+ colorBgBase: '#ffffff',
+ colorBgContainer: '#ffffff',
+ colorBgElevated: '#f5f5f7',
+ colorBorder: 'rgba(0, 0, 0, 0.08)',
+ colorText: '#1d1d1f',
+ colorTextSecondary: 'rgba(0, 0, 0, 0.48)',
+ borderRadius: 8,
+ fontFamily: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif',
+ wireframe: false,
+ },
+ components: {
+ Button: {
+ borderRadius: 8,
+ },
+ Select: {
+ borderRadius: 11,
+ },
+ Table: {
+ borderRadius: 8,
+ },
+ },
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+
+
+)
diff --git a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx
index 5f1db984..f1866498 100644
--- a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx
+++ b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx
@@ -1,14 +1,14 @@
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 { Card, Progress, Badge, Empty, Button, 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' },
+ { key: 'analysts', label: '分析师团队' },
+ { key: 'research', label: '研究员辩论' },
+ { key: 'trading', label: '交易员' },
+ { key: 'risk', label: '风险管理' },
+ { key: 'portfolio', label: '组合经理' },
]
export default function AnalysisMonitor() {
@@ -21,6 +21,7 @@ export default function AnalysisMonitor() {
const wsRef = useRef(null)
const fetchInitialState = useCallback(async () => {
+ if (!taskId) return
setLoading(true)
try {
const res = await fetch(`/api/analysis/status/${taskId}`)
@@ -53,7 +54,7 @@ export default function AnalysisMonitor() {
setTask(taskData)
}
} catch (e) {
- // Ignore parse errors
+ // ignore parse errors
}
}
@@ -84,38 +85,46 @@ export default function AnalysisMonitor() {
return `${mins}:${secs.toString().padStart(2, '0')}`
}
- const getStageStatusIcon = (status) => {
+ const getStageIcon = (status) => {
switch (status) {
case 'completed':
- return
+ return
case 'running':
- return
+ return
case 'failed':
- return
+ return
default:
- return
+ return
}
}
const getDecisionBadge = (decision) => {
if (!decision) return null
- const colorMap = {
- BUY: 'var(--color-buy)',
- SELL: 'var(--color-sell)',
- HOLD: 'var(--color-hold)',
- }
+ const badgeClass = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
+ return {decision}
+ }
+
+ if (!taskId) {
return (
-
- {decision}
-
+
+
+
+
暂无分析任务
+
+ 在股票筛选页面选择股票并点击"分析"开始
+
+
+
+
)
}
@@ -127,21 +136,25 @@ export default function AnalysisMonitor() {
style={{ marginBottom: 'var(--space-6)' }}
title={
- 当前分析任务
+
+ 当前分析任务
+
+ {error ? '错误' : wsConnected ? '实时连接' : '连接中'}
+
+ }
/>
}
>
{loading ? (
- ) : error ? (
+ ) : error && !task ? (
重新连接
@@ -164,128 +176,75 @@ export default function AnalysisMonitor() {
{/* Task Header */}
- {task.name}
-
+
{task.ticker}
{getDecisionBadge(task.decision)}
{/* Progress */}
-
-
-
- {formatTime(task.elapsed)}
+
+
+
+ {task.progress || 0}%
{/* Stages */}
-
- {ANALYSIS_STAGES.map((stage, index) => (
-
-
- {getStageStatusIcon(task.stages[index]?.status)}
+
+ {ANALYSIS_STAGES.map((stage, index) => {
+ const stageState = task.stages?.[index]
+ const status = stageState?.status || 'pending'
+ return (
+
+ {getStageIcon(status)}
{stage.label}
-
- ))}
+ )
+ })}
{/* Logs */}
-
+
实时日志
- {task.logs.map((log, i) => (
-
-
[{log.time}]{' '}
-
{log.stage}:{' '}
-
{log.message}
+ {task.logs?.length > 0 ? (
+ 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
index a586883a..d421f83f 100644
--- a/web_dashboard/frontend/src/pages/BatchManager.jsx
+++ b/web_dashboard/frontend/src/pages/BatchManager.jsx
@@ -1,19 +1,12 @@
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'
+import { Table, Button, Progress, Result, Empty, Card, message, Popconfirm, Tooltip } from 'antd'
+import { CheckCircleOutlined, CloseCircleOutlined, SyncOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
const MAX_CONCURRENT = 3
export default function BatchManager() {
const [tasks, setTasks] = useState([])
- const [maxConcurrent, setMaxConcurrent] = useState(MAX_CONCURRENT)
+ const [maxConcurrent] = useState(MAX_CONCURRENT)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -66,90 +59,83 @@ export default function BatchManager() {
}
}
+ const handleCopyTaskId = (taskId) => {
+ navigator.clipboard.writeText(taskId).then(() => {
+ message.success('已复制任务ID')
+ }).catch(() => {
+ message.error('复制失败')
+ })
+ }
+
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
- return
- case 'running':
- return
+ return
case 'failed':
- return
+ return
+ case 'running':
+ return
default:
- return
+ return
}
}
+ const getStatusTag = (status) => {
+ const map = {
+ pending: { text: '等待', bg: 'rgba(0,0,0,0.06)', color: 'rgba(0,0,0,0.48)' },
+ running: { text: '分析中', bg: 'rgba(168,85,247,0.12)', color: 'var(--color-running)' },
+ completed: { text: '完成', bg: 'rgba(34,197,94,0.12)', color: 'var(--color-buy)' },
+ failed: { text: '失败', bg: 'rgba(220,38,38,0.12)', color: 'var(--color-sell)' },
+ }
+ const s = map[status] || map.pending
+ return (
+
+ {s.text}
+
+ )
+ }
+
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 cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
+ return
{decision}
}
const columns = [
{
title: '状态',
key: 'status',
- width: 100,
+ width: 110,
render: (_, record) => (
{getStatusIcon(record.status)}
- {getStatusTag(record)}
+ {getStatusTag(record.status)}
),
},
{
title: '股票',
- key: 'stock',
- render: (_, record) => (
-
+ dataIndex: 'ticker',
+ key: 'ticker',
+ render: (text) => (
+
{text}
),
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
- width: 150,
+ width: 140,
render: (val, record) =>
record.status === 'running' || record.status === 'pending' ? (
) : (
-
{val}%
+
{val || 0}%
),
},
{
@@ -157,50 +143,61 @@ export default function BatchManager() {
dataIndex: 'decision',
key: 'decision',
width: 80,
- render: (decision) => getDecisionBadge(decision),
+ render: getDecisionBadge,
},
{
title: '任务ID',
dataIndex: 'task_id',
key: 'task_id',
- width: 200,
+ width: 220,
render: (text) => (
-
{text}
+
+
+ {text.slice(0, 18)}...
+
+
+
),
},
{
title: '错误',
dataIndex: 'error',
key: 'error',
+ width: 180,
+ ellipsis: { showTitle: false },
render: (error) =>
error ? (
-
{error}
+
+ {error}
+
) : null,
},
{
title: '操作',
key: 'action',
- width: 150,
+ width: 120,
render: (_, record) => (
{record.status === 'running' && (
-
}
- onClick={() => handleCancel(record.task_id)}
- aria-label="取消"
+
handleCancel(record.task_id)}
+ okText="确认"
+ cancelText="取消"
>
- 取消
-
+ }>
+ 取消
+
+
)}
{record.status === 'failed' && (
-
}
- onClick={() => handleRetry(record.task_id)}
- aria-label="重试"
- >
+
} onClick={() => handleRetry(record.task_id)}>
重试
)}
@@ -209,98 +206,77 @@ export default function BatchManager() {
},
]
- 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
+ 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}
-
- 等待中
+ {pendingCount}
+ 等待中
-
- {runningCount}
-
- 分析中
+ {runningCount}
+ 分析中
-
- {completedCount}
-
- 已完成
+ {completedCount}
+ 已完成
-
- {failedCount}
-
- 失败
+ {failedCount}
+ 失败
- {/* Settings */}
-
-
- 最大并发数:
- setMaxConcurrent(val)}
- style={{ width: 80 }}
- />
-
- 同时运行的分析任务数量
-
-
-
-
{/* Tasks Table */}
- {loading ? (
-
- ) : error ? (
+ {loading && tasks.length === 0 ? (
+
+ ) : error && tasks.length === 0 ? (
{
- fetchTasks()
- }}
- aria-label="重试"
- >
+
}
/>
) : tasks.length === 0 ? (
-
-
-
-
-
-
- }
- />
+
+
+
暂无批量任务
+
+ 在股票筛选页面提交分析任务
+
+
+
) : (
)}
diff --git a/web_dashboard/frontend/src/pages/PortfolioPanel.jsx b/web_dashboard/frontend/src/pages/PortfolioPanel.jsx
new file mode 100644
index 00000000..def6a728
--- /dev/null
+++ b/web_dashboard/frontend/src/pages/PortfolioPanel.jsx
@@ -0,0 +1,467 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import {
+ Table, Button, Input, Select, Space, Row, Col, Card, Progress, Result,
+ message, Popconfirm, Modal, Tabs, Tag, Tooltip, Upload, Form, Typography,
+} from 'antd'
+import {
+ PlusOutlined, DeleteOutlined, PlayCircleOutlined, UploadOutlined,
+ DownloadOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined,
+ AccountBookOutlined,
+} from '@ant-design/icons'
+import { portfolioApi } from '../services/portfolioApi'
+
+const { Text } = Typography
+
+// ============== Helpers ==============
+
+const formatMoney = (v) =>
+ v == null ? '—' : `¥${v.toFixed(2)}`;
+
+const formatPct = (v) =>
+ v == null ? '—' : `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`;
+
+const DecisionBadge = ({ decision }) => {
+ if (!decision) return null
+ const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
+ return
{decision}
+}
+
+// ============== Tab 1: Watchlist ==============
+
+function WatchlistTab() {
+ const [data, setData] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [addOpen, setAddOpen] = useState(false)
+ const [form] = Form.useForm()
+
+ const fetch_ = useCallback(async () => {
+ setLoading(true)
+ try {
+ const res = await portfolioApi.getWatchlist()
+ setData(res.watchlist || [])
+ } catch {
+ message.error('加载失败')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetch_() }, [fetch_])
+
+ const handleAdd = async (vals) => {
+ try {
+ await portfolioApi.addToWatchlist(vals.ticker, vals.name || vals.ticker)
+ message.success('已添加')
+ setAddOpen(false)
+ form.resetFields()
+ fetch_()
+ } catch (e) {
+ message.error(e.message)
+ }
+ }
+
+ const handleDelete = async (ticker) => {
+ try {
+ await portfolioApi.removeFromWatchlist(ticker)
+ message.success('已移除')
+ fetch_()
+ } catch (e) {
+ message.error(e.message)
+ }
+ }
+
+ const columns = [
+ { title: '代码', dataIndex: 'ticker', key: 'ticker', width: 120,
+ render: t =>
{t} },
+ { title: '名称', dataIndex: 'name', key: 'name', render: t =>
{t} },
+ { title: '添加日期', dataIndex: 'added_at', key: 'added_at', width: 120 },
+ {
+ title: '操作', key: 'action', width: 100,
+ render: (_, r) => (
+
handleDelete(r.ticker)} okText="确认" cancelText="取消">
+ }>移除
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+
自选股列表
+
+ } type="primary" onClick={() => setAddOpen(true)}>添加
+ } onClick={fetch_} loading={loading}>刷新
+
+
+
+
+
+
+ {data.length === 0 && !loading && (
+
+
+
暂无自选股
+
点击上方"添加"将股票加入自选
+
+ )}
+
+
+
{ setAddOpen(false); form.resetFields() }} footer={null}>
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ============== Tab 2: Positions ==============
+
+function PositionsTab() {
+ const [data, setData] = useState([])
+ const [accounts, setAccounts] = useState(['默认账户'])
+ const [account, setAccount] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [addOpen, setAddOpen] = useState(false)
+ const [form] = Form.useForm()
+
+ const fetchPositions = useCallback(async () => {
+ setLoading(true)
+ try {
+ const [posRes, accRes] = await Promise.all([
+ portfolioApi.getPositions(account),
+ portfolioApi.getAccounts(),
+ ])
+ setData(posRes.positions || [])
+ setAccounts(accRes.accounts || ['默认账户'])
+ } catch {
+ message.error('加载失败')
+ } finally {
+ setLoading(false)
+ }
+ }, [account])
+
+ useEffect(() => { fetchPositions() }, [fetchPositions])
+
+ const handleAdd = async (vals) => {
+ try {
+ await portfolioApi.addPosition({ ...vals, account: account || '默认账户' })
+ message.success('已添加')
+ setAddOpen(false)
+ form.resetFields()
+ fetchPositions()
+ } catch (e) {
+ message.error(e.message)
+ }
+ }
+
+ const handleDelete = async (ticker, positionId) => {
+ try {
+ await portfolioApi.removePosition(ticker, positionId, account)
+ message.success('已移除')
+ fetchPositions()
+ } catch (e) {
+ message.error(e.message)
+ }
+ }
+
+ const handleExport = async () => {
+ try {
+ const blob = await portfolioApi.exportPositions(account)
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url; a.download = 'positions.csv'; a.click()
+ URL.revokeObjectURL(url)
+ } catch (e) {
+ message.error(e.message)
+ }
+ }
+
+ const totalPnl = data.reduce((s, p) => s + (p.unrealized_pnl || 0), 0)
+
+ const columns = [
+ { title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110,
+ render: t =>
{t} },
+ { title: '账户', dataIndex: 'account', key: 'account', width: 100 },
+ { title: '数量', dataIndex: 'shares', key: 'shares', align: 'right', width: 80,
+ render: v =>
{v} },
+ { title: '成本价', dataIndex: 'cost_price', key: 'cost_price', align: 'right', width: 90,
+ render: v =>
{formatMoney(v)} },
+ { title: '现价', dataIndex: 'current_price', key: 'current_price', align: 'right', width: 90,
+ render: v =>
{formatMoney(v)} },
+ {
+ title: '浮亏浮盈',
+ key: 'pnl',
+ align: 'right',
+ width: 110,
+ render: (_, r) => {
+ const pnl = r.unrealized_pnl
+ const pct = r.unrealized_pnl_pct
+ const color = pnl == null ? undefined : pnl >= 0 ? 'var(--color-buy)' : 'var(--color-sell)'
+ return (
+
+ {pnl == null ? '—' : `${pnl >= 0 ? '+' : ''}${formatMoney(pnl)}`}
+
+ {pct == null ? '' : formatPct(pct)}
+
+ )
+ },
+ },
+ {
+ title: '买入日期',
+ dataIndex: 'purchase_date',
+ key: 'purchase_date',
+ width: 100,
+ },
+ {
+ title: '操作', key: 'action', width: 80,
+ render: (_, r) => (
+
handleDelete(r.ticker, r.position_id)} okText="确认" cancelText="取消">
+ }>平仓
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+
+
账户
+
+
+
+
+
总浮亏浮盈
+
= 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
+ {formatMoney(totalPnl)}
+
+
+
+
+
+
+
+
持仓记录
+
+ } onClick={handleExport}>导出
+ } type="primary" onClick={() => setAddOpen(true)}>添加持仓
+ } onClick={fetchPositions} loading={loading}>刷新
+
+
+
+
+
+
+ {data.length === 0 && !loading && (
+
+
+
暂无持仓
+
点击"添加持仓"录入您的股票仓位
+
+ )}
+
+
+
{ setAddOpen(false); form.resetFields() }} footer={null}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ============== Tab 3: Recommendations ==============
+
+function RecommendationsTab() {
+ const [data, setData] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [analyzing, setAnalyzing] = useState(false)
+ const [taskId, setTaskId] = useState(null)
+ const [wsConnected, setWsConnected] = useState(false)
+ const [progress, setProgress] = useState(null)
+ const [selectedDate, setSelectedDate] = useState(null)
+ const [dates, setDates] = useState([])
+ const wsRef = useRef(null)
+
+ const fetchRecs = useCallback(async (date) => {
+ setLoading(true)
+ try {
+ const res = await portfolioApi.getRecommendations(date)
+ setData(res.recommendations || [])
+ if (!date) {
+ const d = [...new Set((res.recommendations || []).map(r => r.analysis_date))].sort().reverse()
+ setDates(d)
+ }
+ } catch {
+ message.error('加载失败')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchRecs(selectedDate) }, [fetchRecs, selectedDate])
+
+ const connectWs = useCallback((tid) => {
+ 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/${tid}`)
+ ws.onopen = () => setWsConnected(true)
+ ws.onmessage = (e) => {
+ const d = JSON.parse(e.data)
+ if (d.type === 'progress') setProgress(d)
+ }
+ ws.onclose = () => setWsConnected(false)
+ wsRef.current = ws
+ }, [])
+
+ const handleAnalyze = async () => {
+ try {
+ const res = await portfolioApi.startAnalysis()
+ setTaskId(res.task_id)
+ setAnalyzing(true)
+ setProgress({ completed: 0, total: res.total, status: 'running' })
+ connectWs(res.task_id)
+ message.info('开始批量分析...')
+ } catch (e) {
+ message.error(e.message)
+ }
+ }
+
+ useEffect(() => {
+ if (progress?.status === 'completed' || progress?.status === 'failed') {
+ setAnalyzing(false)
+ setTaskId(null)
+ setProgress(null)
+ fetchRecs(selectedDate)
+ }
+ }, [progress?.status])
+
+ useEffect(() => () => { if (wsRef.current) wsRef.current.close() }, [])
+
+ const columns = [
+ { title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110,
+ render: t =>
{t} },
+ { title: '名称', dataIndex: 'name', key: 'name', render: t =>
{t} },
+ {
+ title: '决策', dataIndex: 'decision', key: 'decision', width: 80,
+ render: d =>
,
+ },
+ { title: '分析日期', dataIndex: 'analysis_date', key: 'analysis_date', width: 120 },
+ ]
+
+ return (
+
+ {/* Analysis card */}
+
+
+
今日建议
+
+ {analyzing && progress && (
+
+ {wsConnected ? '🟢' : '🔴'}
+ {progress.completed || 0} / {progress.total || 0}
+
+ )}
+ }
+ onClick={handleAnalyze}
+ loading={analyzing}
+ disabled={analyzing}
+ >
+ {analyzing ? '分析中...' : '生成今日建议'}
+
+
+
+ {analyzing && progress && (
+
+ )}
+
+
+ {/* Date filter */}
+
+
+
+ {/* Recommendations list */}
+
+
+ {data.length === 0 && !loading && (
+
+
+
暂无建议
+
点击上方"生成今日建议"开始批量分析
+
+ )}
+
+
+ )
+}
+
+// ============== Main ==============
+
+export default function PortfolioPanel() {
+ const [activeTab, setActiveTab] = useState('watchlist')
+
+ const items = [
+ { key: 'watchlist', label: '自选股', children:
},
+ { key: 'positions', label: '持仓', children:
},
+ { key: 'recommendations', label: '今日建议', children:
},
+ ]
+
+ return (
+
+ )
+}
diff --git a/web_dashboard/frontend/src/pages/ReportsViewer.jsx b/web_dashboard/frontend/src/pages/ReportsViewer.jsx
new file mode 100644
index 00000000..4e17196b
--- /dev/null
+++ b/web_dashboard/frontend/src/pages/ReportsViewer.jsx
@@ -0,0 +1,225 @@
+import { useState, useEffect } from 'react'
+import { Table, Input, Modal, Skeleton, Button, Space, message } from 'antd'
+import { FileTextOutlined, SearchOutlined, CloseOutlined, DownloadOutlined } from '@ant-design/icons'
+import ReactMarkdown from 'react-markdown'
+
+const { Search } = Input
+
+export default function ReportsViewer() {
+ const [loading, setLoading] = useState(true)
+ const [reports, setReports] = useState([])
+ const [selectedReport, setSelectedReport] = useState(null)
+ const [reportContent, setReportContent] = useState(null)
+ const [loadingContent, setLoadingContent] = useState(false)
+ const [searchText, setSearchText] = useState('')
+
+ useEffect(() => {
+ fetchReports()
+ }, [])
+
+ const fetchReports = async () => {
+ setLoading(true)
+ try {
+ const res = await fetch('/api/reports/list')
+ if (!res.ok) throw new Error(`请求失败: ${res.status}`)
+ const data = await res.json()
+ setReports(data)
+ } catch {
+ setReports([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleExportCsv = async () => {
+ try {
+ const res = await fetch('/api/reports/export')
+ if (!res.ok) throw new Error('导出失败')
+ const blob = await res.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url; a.download = 'tradingagents_reports.csv'; a.click()
+ URL.revokeObjectURL(url)
+ } catch (e) {
+ message.error(e.message)
+ }
+ }
+
+ const handleExportPdf = async (ticker, date) => {
+ try {
+ const res = await fetch(`/api/reports/${ticker}/${date}/pdf`)
+ if (!res.ok) throw new Error('导出失败')
+ const blob = await res.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url; a.download = `${ticker}_${date}_report.pdf`; a.click()
+ URL.revokeObjectURL(url)
+ } catch (e) {
+ message.error(e.message)
+ }
+ }
+
+ const handleViewReport = async (record) => {
+ setSelectedReport(record)
+ setLoadingContent(true)
+ try {
+ const res = await fetch(`/api/reports/${record.ticker}/${record.date}`)
+ if (!res.ok) throw new Error(`加载失败: ${res.status}`)
+ const data = await res.json()
+ setReportContent(data)
+ } catch (err) {
+ setReportContent({ report: `# 加载失败\n\n无法加载报告: ${err.message}` })
+ } finally {
+ setLoadingContent(false)
+ }
+ }
+
+ const filteredReports = reports.filter(
+ (r) =>
+ r.ticker.toLowerCase().includes(searchText.toLowerCase()) ||
+ r.date.includes(searchText)
+ )
+
+ const columns = [
+ {
+ title: '代码',
+ dataIndex: 'ticker',
+ key: 'ticker',
+ width: 120,
+ render: (text) => (
+
{text}
+ ),
+ },
+ {
+ title: '日期',
+ dataIndex: 'date',
+ key: 'date',
+ width: 120,
+ render: (text) => (
+
{text}
+ ),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 100,
+ render: (_, record) => (
+
}
+ size="small"
+ onClick={() => handleViewReport(record)}
+ >
+ 查看
+
+ ),
+ },
+ ]
+
+ return (
+
+ {/* Search + Export */}
+
+
+ setSearchText(e.target.value)}
+ prefix={}
+ size="large"
+ style={{ flex: 1 }}
+ />
+ } onClick={handleExportCsv} disabled={reports.length === 0}>
+ 导出CSV
+
+
+
+
+ {/* Reports Table */}
+
+ {loading ? (
+
+
+
+ ) : filteredReports.length === 0 ? (
+
+
+
暂无历史报告
+
+ 在股票筛选页面提交分析任务后,报告将显示在这里
+
+
+ ) : (
+
`${r.ticker}-${r.date}`}
+ pagination={{ pageSize: 10 }}
+ size="middle"
+ />
+ )}
+
+
+ {/* Report Modal */}
+
+
+ {selectedReport.ticker}
+
+ {selectedReport.date}
+
+ ) : null
+ }
+ open={!!selectedReport}
+ onCancel={() => {
+ setSelectedReport(null)
+ setReportContent(null)
+ }}
+ footer={
+ selectedReport ? (
+
+ }
+ onClick={() => handleExportPdf(selectedReport.ticker, selectedReport.date)}
+ >
+ 导出PDF
+
+
+
+ ) : null
+ }
+ width={800}
+ closeIcon={}
+ styles={{
+ wrapper: { maxWidth: '95vw' },
+ body: { maxHeight: '70vh', overflow: 'auto', padding: 'var(--space-6)' },
+ header: { padding: 'var(--space-4) var(--space-6)', borderBottom: '1px solid rgba(0,0,0,0.08)' },
+ }}
+ >
+ {loadingContent ? (
+
+
+
+ ) : reportContent ? (
+
+ {reportContent.report || 'No content'}
+
+ ) : null}
+
+
+ )
+}
diff --git a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx
index 108009ef..5de31a39 100644
--- a/web_dashboard/frontend/src/pages/ScreeningPanel.jsx
+++ b/web_dashboard/frontend/src/pages/ScreeningPanel.jsx
@@ -1,6 +1,6 @@
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 { Table, Button, Select, Space, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd'
import { PlayCircleOutlined, ReloadOutlined, QuestionCircleOutlined } from '@ant-design/icons'
const SCREEN_MODES = [
@@ -15,7 +15,6 @@ 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)
@@ -41,6 +40,22 @@ export default function ScreeningPanel() {
fetchResults()
}, [mode])
+ 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)
+ }
+ }
+
const columns = [
{
title: '代码',
@@ -48,7 +63,7 @@ export default function ScreeningPanel() {
key: 'ticker',
width: 120,
render: (text) => (
- {text}
+ {text}
),
},
{
@@ -56,18 +71,24 @@ export default function ScreeningPanel() {
dataIndex: 'name',
key: 'name',
width: 120,
+ render: (text) => (
+ {text}
+ ),
},
{
title: (
- 营收增速
+
+ 营收增速
+
),
dataIndex: 'revenue_growth',
key: 'revenue_growth',
align: 'right',
+ width: 100,
render: (val) => (
-
+ 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
{val?.toFixed(1)}%
),
@@ -75,14 +96,17 @@ export default function ScreeningPanel() {
{
title: (
- 利润增速
+
+ 利润增速
+
),
dataIndex: 'profit_growth',
key: 'profit_growth',
align: 'right',
+ width: 100,
render: (val) => (
-
+ 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
{val?.toFixed(1)}%
),
@@ -90,16 +114,17 @@ export default function ScreeningPanel() {
{
title: (
- ROE
+
+ ROE
+
),
dataIndex: 'roe',
key: 'roe',
align: 'right',
+ width: 80,
render: (val) => (
-
- {val?.toFixed(1)}%
-
+ {val?.toFixed(1)}%
),
},
{
@@ -107,31 +132,31 @@ export default function ScreeningPanel() {
dataIndex: 'current_price',
key: 'current_price',
align: 'right',
+ width: 100,
render: (val) => (
-
- ¥{val?.toFixed(2)}
-
+ ¥{val?.toFixed(2)}
),
},
{
title: (
- Vol比
+
+ Vol比
+
),
dataIndex: 'vol_ratio',
key: 'vol_ratio',
align: 'right',
+ width: 80,
render: (val) => (
-
- {val?.toFixed(2)}x
-
+ {val?.toFixed(2)}x
),
},
{
title: '操作',
key: 'action',
- width: 140,
+ width: 100,
render: (_, record) => (
{
- 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 */}
+ {/* Stats Row - Apple style */}
-
m.value === mode)?.label}
- />
+ 筛选模式
+
+ {SCREEN_MODES.find(m => m.value === mode)?.label}
+
-
+
通过数量
+
{stats.passed}
@@ -213,6 +213,7 @@ export default function ScreeningPanel() {
onChange={setMode}
options={SCREEN_MODES}
style={{ width: 200 }}
+ popupMatchSelectWidth={false}
/>
}
@@ -239,12 +240,10 @@ export default function ScreeningPanel() {
type="primary"
icon={
}
onClick={fetchResults}
- aria-label="重试"
>
重试
}
- style={{ border: '1px solid var(--color-sell)', borderRadius: 'var(--radius-md)' }}
/>
) : results.length === 0 ? (
@@ -265,6 +264,7 @@ export default function ScreeningPanel() {
rowKey="ticker"
pagination={{ pageSize: 10 }}
size="middle"
+ scroll={{ x: 700 }}
/>
)}
diff --git a/web_dashboard/frontend/src/services/portfolioApi.js b/web_dashboard/frontend/src/services/portfolioApi.js
new file mode 100644
index 00000000..2ee67b4c
--- /dev/null
+++ b/web_dashboard/frontend/src/services/portfolioApi.js
@@ -0,0 +1,66 @@
+const BASE = '/api/portfolio';
+const FETCH_TIMEOUT_MS = 15000; // 15s timeout per request
+
+async function req(method, path, body) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+ const opts = {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ signal: controller.signal,
+ };
+ if (body !== undefined) opts.body = JSON.stringify(body);
+ try {
+ const res = await fetch(`${BASE}${path}`, opts);
+ clearTimeout(timeout);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.detail || `请求失败: ${res.status}`);
+ }
+ if (res.status === 204) return null;
+ return res.json();
+ } catch (e) {
+ clearTimeout(timeout);
+ if (e.name === 'AbortError') throw new Error('请求超时,请检查网络连接');
+ throw e;
+ }
+}
+
+export const portfolioApi = {
+ // Watchlist
+ getWatchlist: () => req('GET', '/watchlist'),
+ addToWatchlist: (ticker, name) => req('POST', '/watchlist', { ticker, name }),
+ removeFromWatchlist: (ticker) => req('DELETE', `/watchlist/${ticker}`),
+
+ // Accounts
+ getAccounts: () => req('GET', '/accounts'),
+ createAccount: (name) => req('POST', '/accounts', { account_name: name }),
+ deleteAccount: (name) => req('DELETE', `/accounts/${name}`),
+
+ // Positions
+ getPositions: (account) => req('GET', `/positions${account ? `?account=${encodeURIComponent(account)}` : ''}`),
+ addPosition: (data) => req('POST', '/positions', data),
+ removePosition: (ticker, positionId, account) => {
+ const params = new URLSearchParams({ ticker });
+ if (positionId) params.set('position_id', positionId);
+ if (account) params.set('account', account);
+ return req('DELETE', `/positions/${ticker}?${params}`);
+ },
+ exportPositions: (account) => {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+ const url = `${BASE}/positions/export${account ? `?account=${encodeURIComponent(account)}` : ''}`;
+ return fetch(url, { signal: controller.signal })
+ .then(r => { clearTimeout(timeout); return r; })
+ .then(r => { if (!r.ok) throw new Error(`导出失败: ${r.status}`); return r.blob(); })
+ .catch(e => { clearTimeout(timeout); if (e.name === 'AbortError') throw new Error('请求超时'); throw e; });
+ },
+
+ // Recommendations
+ getRecommendations: (date) =>
+ req('GET', `/recommendations${date ? `?date=${date}` : ''}`),
+ getRecommendation: (date, ticker) => req('GET', `/recommendations/${date}/${ticker}`),
+
+ // Batch analysis
+ startAnalysis: () => req('POST', '/analyze'),
+};