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) => ( -
-
{record.ticker}
-
+ 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' && ( - + + )} {record.status === 'failed' && ( - )} @@ -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 ( +
+
+
+
自选股列表
+ + + + +
+
+ +
+
+ {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 ( +
+ +
+
+
账户
+
+ {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} + + )} + + +
+ {analyzing && progress && ( + + )} +
+ + {/* Date filter */} +
+
+ {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) => ( + + ), + }, + ] + + return ( +
+ {/* Search + Export */} +
+
+ setSearchText(e.target.value)} + prefix={} + size="large" + style={{ flex: 1 }} + /> + +
+
+ + {/* 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 ? ( + + + + + ) : 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.total}
- +
通过数量
+
{stats.passed}
@@ -213,6 +213,7 @@ export default function ScreeningPanel() { onChange={setMode} options={SCREEN_MODES} style={{ width: 200 }} + popupMatchSelectWidth={false} /> } - 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'), +};