From b9fad4340480d76b72dc298ae3477c5e66b08e4c Mon Sep 17 00:00:00 2001 From: Shaojie <73728610+Shaojie66@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:52:56 +0800 Subject: [PATCH] feat(dashboard): web dashboard phase 1 - screening, analysis, portfolio (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): apply Apple design system to all 4 pages - Font: replace SF Pro with DM Sans (web-available) throughout - Typography: consistent DM Sans stack, monospace data display - ScreeningPanel: add horizontal scroll for mobile, fix stat card hover - AnalysisMonitor: Apple progress bar, stage pills, decision badge - BatchManager: add copy-to-clipboard for task IDs, fix error tooltip truncation, add CTA to empty state - ReportsViewer: Apple-styled modal, search bar consistency - Keyboard: add Escape to close modals - CSS: progress bar ease-out, sidebar collapse button icon-only mode Co-Authored-By: Claude Opus 4.6 * fix(dashboard): secure API key handling and add stage progress streaming - Pass ANTHROPIC_API_KEY via env dict instead of CLI args (P1 security fix) - Add monitor_subprocess() coroutine with fcntl non-blocking reads - Inject STAGE markers (analysts/research/trading/risk/portfolio) into script stdout - Update task stage state and broadcast WebSocket progress at each stage boundary - Add asyncio.Event for monitor cancellation on task completion/cancel Co-Authored-By: Claude Opus 4.6 * feat(dashboard): persist task state to disk for restart recovery - Add TASK_STATUS_DIR for task state JSON files - Lifespan startup: restore task states from disk - Task completion/failure: write state to disk - Task cancellation: delete persisted state Co-Authored-By: Claude Opus 4.6 * fix(dashboard): correct stage key mismatch, add created_at, persist cancelled tasks - Fix ANALYSIS_STAGES key 'trader' → 'trading' to match backend STAGE markers - Add created_at field to task state at creation, sort list_tasks by it - Persist task state before broadcast in cancel path (closes restart race) Co-Authored-By: Claude Opus 4.6 * feat(dashboard): add portfolio panel - watchlist, positions, and recommendations New backend: - api/portfolio.py: watchlist CRUD, positions with live P&L, recommendations - POST /api/portfolio/analyze: batch analysis of watchlist tickers - GET /api/portfolio/positions: live price from yfinance + unrealized P&L New frontend: - PortfolioPanel.jsx with 3 tabs: 自选股 / 持仓 / 今日建议 - portfolioApi.js service - Route /portfolio (keyboard shortcut: 5) Co-Authored-By: Claude Opus 4.6 * feat(dashboard): add CSV and PDF report export - GET /api/reports/export: CSV with ticker,date,decision,summary - GET /api/reports/{ticker}/{date}/pdf: PDF via fpdf2 with DejaVu fonts - ReportsViewer: CSV export button + PDF export in modal footer Co-Authored-By: Claude Opus 4.6 * fix(dashboard): address 4 critical issues found in pre-landing review 1. main.py: move API key validation before task state creation — prevents phantom "running" tasks when ANTHROPIC_API_KEY is missing 2. portfolio.py: make get_positions() async and fetch yfinance prices concurrently via run_in_executor — no longer blocks event loop 3. portfolio.py: add fcntl.LOCK_EX around all JSON read-modify-write operations on watchlist.json and positions.json — eliminates TOCTOU lost-write races under concurrent requests 4. main.py: use tempfile.mkstemp with mode 0o600 instead of world- readable /tmp/analysis_{task_id}.py — script content no longer exposed to other users on shared hosts Also: remove unused UploadFile/File imports, undefined _save_to_cache function, dead code in _delete_task_status, and unused get_or_create_default_account helper. Co-Authored-By: Claude Opus 4.6 * fix(dashboard): use secure temp file for batch analysis scripts Batch portfolio analysis was writing scripts to /tmp with default permissions (0o644), exposing the API key to other local users. Switch to tempfile.mkstemp + chmod 0o600, matching the single-analysis pattern. Also fix cancel_task cleanup to use glob patterns for tempfile-generated paths. Co-Authored-By: Claude Opus 4.6 * fix(dashboard): remove fake fallback data from ReportsViewer ReportsViewer showed fabricated Chinese text when a report failed to load, making fake data appear indistinguishable from real analysis. Now shows an error message instead. Co-Authored-By: Claude Opus 4.6 * fix(dashboard): reliability fixes - cross-platform PDF fonts, API timeouts, yfinance concurrency, retry logic - PDF: try multiple DejaVu font paths (macOS + Linux) instead of hardcoded macOS - Frontend: add 15s AbortController timeout to all API calls + proper error handling - yfinance: cap concurrent price fetches at 5 via asyncio.Semaphore - Batch analysis: retry failed stock analyses up to 2x with exponential backoff Co-Authored-By: Claude Opus 4.6 * fix: resolve 4 critical security/correctness bugs in web dashboard 1. Mass position deletion (portfolio.py): remove_position now rejects empty position_id — previously position_id="" matched all positions and deleted every holding for a ticker across ALL accounts. 2. Path traversal in get_recommendation (portfolio.py): added ticker/date validation (no ".." or path separators) + resolved-path check against RECOMMENDATIONS_DIR to prevent ../../etc/passwd attacks. 3. Path traversal in get_report_content (main.py): same ticker/date validation + resolved-path check against get_results_dir(). 4. china_data import stub (interface.py + new china_data.py): the actual akshare implementation lives in web_dashboard/backend/china_data.py (different package); tradingagents/dataflows/china_data.py was missing entirely, so _china_data_available was always False. Added stub file and AttributeError to the import exception handler so the module gracefully degrades instead of silently hiding the missing vendor. Magic numbers also extracted to named constants: - MAX_RETRY_COUNT, RETRY_BASE_DELAY_SECS (main.py) - MAX_CONCURRENT_YFINANCE_REQUESTS (portfolio.py) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- tradingagents/dataflows/china_data.py | 16 + tradingagents/dataflows/interface.py | 78 +- web_dashboard/backend/api/__init__.py | 0 web_dashboard/backend/api/portfolio.py | 325 +++++++ web_dashboard/backend/main.py | 595 +++++++++++- web_dashboard/frontend/index.html | 15 + web_dashboard/frontend/src/App.jsx | 170 ++++ web_dashboard/frontend/src/index.css | 879 ++++++++++++++++++ web_dashboard/frontend/src/main.jsx | 48 + .../frontend/src/pages/AnalysisMonitor.jsx | 207 ++--- .../frontend/src/pages/BatchManager.jsx | 252 +++-- .../frontend/src/pages/PortfolioPanel.jsx | 467 ++++++++++ .../frontend/src/pages/ReportsViewer.jsx | 225 +++++ .../frontend/src/pages/ScreeningPanel.jsx | 110 +-- .../frontend/src/services/portfolioApi.js | 66 ++ 15 files changed, 3096 insertions(+), 357 deletions(-) create mode 100644 tradingagents/dataflows/china_data.py create mode 100644 web_dashboard/backend/api/__init__.py create mode 100644 web_dashboard/backend/api/portfolio.py create mode 100644 web_dashboard/frontend/index.html create mode 100644 web_dashboard/frontend/src/App.jsx create mode 100644 web_dashboard/frontend/src/index.css create mode 100644 web_dashboard/frontend/src/main.jsx create mode 100644 web_dashboard/frontend/src/pages/PortfolioPanel.jsx create mode 100644 web_dashboard/frontend/src/pages/ReportsViewer.jsx create mode 100644 web_dashboard/frontend/src/services/portfolioApi.js 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'), +};