feat(dashboard): web dashboard phase 1 - screening, analysis, portfolio (#2)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shaojie 2026-04-07 18:52:56 +08:00 committed by GitHub
parent 09ec174049
commit f19c1c012e
15 changed files with 3096 additions and 357 deletions

View File

@ -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

View File

@ -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}'")
raise RuntimeError(f"No available vendor for '{method}'")

View File

View File

@ -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))

View File

@ -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"}

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TradingAgents Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -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: <FundOutlined />, label: '筛选', key: '1' },
{ path: '/monitor', icon: <MonitorOutlined />, label: '监控', key: '2' },
{ path: '/reports', icon: <FileTextOutlined />, label: '报告', key: '3' },
{ path: '/batch', icon: <ClusterOutlined />, label: '批量', key: '4' },
{ path: '/portfolio', icon: <WalletOutlined />, 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 (
<div className="dashboard-layout">
{/* Sidebar - Apple Glass Navigation */}
{!isMobile && (
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="sidebar-logo">
{!collapsed && <span>TradingAgents</span>}
{collapsed && <span style={{ fontSize: 12, letterSpacing: '0.1em' }}>TA</span>}
</div>
<nav className="sidebar-nav">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`nav-item ${isActive ? 'active' : ''}`
}
end={item.path === '/'}
aria-label={`${item.label} (按${item.key}切换)`}
>
{item.icon}
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
<div style={{ padding: 'var(--space-2)' }}>
<button
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
className="sidebar-collapse-btn"
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
{!collapsed && <span>收起</span>}
</button>
</div>
</aside>
)}
{/* Main Content */}
<main className={`main-content ${collapsed && !isMobile ? 'sidebar-collapsed' : ''}`}>
{!isMobile && (
<header className="topbar">
<div className="topbar-title">{currentPage}</div>
<div className="topbar-date">
{new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</header>
)}
<div className="page-content">
{children}
</div>
</main>
{/* Mobile TabBar */}
{isMobile && (
<nav className="mobile-tabbar" aria-label="移动端导航">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`mobile-tab-item ${isActive ? 'active' : ''}`
}
end={item.path === '/'}
aria-label={item.label}
>
<span className="mobile-tab-icon">{item.icon}</span>
<span className="mobile-tab-label">{item.label}</span>
</NavLink>
))}
</nav>
)}
</div>
)
}
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 (
<Layout>
<Suspense fallback={
<div style={{ padding: 'var(--space-12)', textAlign: 'center' }}>
<div className="loading-pulse">加载中...</div>
</div>
}>
<Routes>
<Route path="/" element={<ScreeningPanel />} />
<Route path="/monitor" element={<AnalysisMonitor />} />
<Route path="/reports" element={<ReportsViewer />} />
<Route path="/batch" element={<BatchManager />} />
<Route path="/portfolio" element={<PortfolioPanel />} />
</Routes>
</Suspense>
</Layout>
)
}

View File

@ -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;
}

View File

@ -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(
<React.StrictMode>
<BrowserRouter>
<ConfigProvider theme={appleTheme}>
<App />
</ConfigProvider>
</BrowserRouter>
</React.StrictMode>
)

View File

@ -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 <CheckCircleOutlined style={{ color: 'var(--color-buy)' }} />
return <CheckCircleOutlined style={{ color: 'var(--color-buy)', fontSize: 16 }} />
case 'running':
return <SyncOutlined spin style={{ color: 'var(--color-running)' }} />
return <SyncOutlined spin style={{ color: 'var(--color-running)', fontSize: 16 }} />
case 'failed':
return <CloseCircleOutlined style={{ color: 'var(--color-sell)' }} />
return <CloseCircleOutlined style={{ color: 'var(--color-sell)', fontSize: 16 }} />
default:
return <Badge status="default" />
return <span style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid rgba(0,0,0,0.12)', display: 'inline-block' }} />
}
}
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 <span className={badgeClass}>{decision}</span>
}
if (!taskId) {
return (
<Tag
color={colorMap[decision]}
style={{
fontFamily: 'var(--font-data)',
fontWeight: 600,
fontSize: 14,
padding: '4px 12px',
}}
>
{decision}
</Tag>
<div className="card">
<div className="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
<div className="empty-state-title">暂无分析任务</div>
<div className="empty-state-description">
在股票筛选页面选择股票并点击"分析"开始
</div>
<button
className="btn-primary"
style={{ marginTop: 'var(--space-4)' }}
onClick={() => window.location.href = '/'}
>
去筛选
</button>
</div>
</div>
)
}
@ -127,21 +136,25 @@ export default function AnalysisMonitor() {
style={{ marginBottom: 'var(--space-6)' }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span>当前分析任务</span>
<span style={{ fontFamily: 'var(--font-display)', fontSize: 17, fontWeight: 600 }}>
当前分析任务
</span>
<Badge
status={error ? 'error' : wsConnected ? 'success' : 'error'}
text={error ? '错误' : wsConnected ? '实时连接' : '未连接'}
status={error ? 'error' : wsConnected ? 'success' : 'default'}
text={
<span style={{ fontSize: 12, color: error ? 'var(--color-sell)' : wsConnected ? 'var(--color-buy)' : 'rgba(0,0,0,0.48)' }}>
{error ? '错误' : wsConnected ? '实时连接' : '连接中'}
</span>
}
/>
</div>
}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 'var(--space-12)' }}>
<div className="loading-pulse" style={{ color: 'var(--color-running)', fontSize: 16 }}>
连接中...
</div>
<div className="loading-pulse" style={{ fontSize: 16 }}>连接中...</div>
</div>
) : error ? (
) : error && !task ? (
<Result
status="error"
title="连接失败"
@ -153,7 +166,6 @@ export default function AnalysisMonitor() {
fetchInitialState()
connectWebSocket()
}}
aria-label="重新连接"
>
重新连接
</Button>
@ -164,128 +176,75 @@ export default function AnalysisMonitor() {
{/* Task Header */}
<div style={{ marginBottom: 'var(--space-6)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
<span style={{ fontSize: 24, fontWeight: 600 }}>{task.name}</span>
<span style={{ fontFamily: 'var(--font-data)', color: 'var(--color-text-muted)' }}>
<span style={{ fontFamily: 'var(--font-display)', fontSize: 28, fontWeight: 600, letterSpacing: 0.196, lineHeight: 1.14 }}>
{task.ticker}
</span>
{getDecisionBadge(task.decision)}
</div>
{/* Progress */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Progress
percent={task.progress}
status="active"
strokeColor="var(--color-buy)"
style={{ flex: 1 }}
/>
<span
style={{
fontFamily: 'var(--font-data)',
color: 'var(--color-text-muted)',
minWidth: 50,
}}
>
{formatTime(task.elapsed)}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
<div className="progress-bar" style={{ flex: 1, height: 6 }}>
<div className="progress-bar-fill" style={{ width: `${task.progress || 0}%` }} />
</div>
<span className="text-data" style={{ minWidth: 50, textAlign: 'right' }}>
{task.progress || 0}%
</span>
</div>
</div>
{/* Stages */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 24 }}>
{ANALYSIS_STAGES.map((stage, index) => (
<div
key={stage.key}
style={{
padding: '8px 16px',
background:
task.stages[index]?.status === 'running'
? 'rgba(168, 85, 247, 0.15)'
: task.stages[index]?.status === 'completed'
? 'rgba(34, 197, 94, 0.15)'
: 'var(--color-surface-elevated)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${
task.stages[index]?.status === 'running'
? 'var(--color-running)'
: task.stages[index]?.status === 'completed'
? 'var(--color-buy)'
: 'var(--color-border)'
}`,
opacity: task.stages[index]?.status === 'pending' ? 0.5 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{getStageStatusIcon(task.stages[index]?.status)}
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap', marginBottom: 'var(--space-6)' }}>
{ANALYSIS_STAGES.map((stage, index) => {
const stageState = task.stages?.[index]
const status = stageState?.status || 'pending'
return (
<div key={stage.key} className={`stage-pill ${status}`}>
{getStageIcon(status)}
<span>{stage.label}</span>
</div>
</div>
))}
)
})}
</div>
{/* Logs */}
<div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--color-text-muted)',
marginBottom: 12,
textTransform: 'uppercase',
}}
>
<div className="text-caption" style={{ marginBottom: 12, textTransform: 'uppercase', fontWeight: 600 }}>
实时日志
</div>
<div
aria-live="polite"
style={{
fontFamily: 'var(--font-data)',
fontSize: 12,
background: 'var(--color-bg)',
background: 'rgba(0,0,0,0.03)',
padding: 'var(--space-4)',
borderRadius: 'var(--radius-md)',
maxHeight: 300,
borderRadius: 'var(--radius-standard)',
maxHeight: 280,
overflow: 'auto',
}}
>
{task.logs.map((log, i) => (
<div key={i} style={{ marginBottom: 8 }}>
<span style={{ color: 'var(--color-text-muted)' }}>[{log.time}]</span>{' '}
<span style={{ color: 'var(--color-interactive)' }}>{log.stage}:</span>{' '}
<span>{log.message}</span>
{task.logs?.length > 0 ? (
task.logs.map((log, i) => (
<div key={i} style={{ marginBottom: 8, lineHeight: 1.4 }}>
<span style={{ color: 'rgba(0,0,0,0.48)' }}>[{log.time}]</span>{' '}
<span style={{ fontWeight: 500 }}>{log.stage}:</span>{' '}
<span>{log.message}</span>
</div>
))
) : (
<div style={{ color: 'rgba(0,0,0,0.48)', textAlign: 'center', padding: 'var(--space-4)' }}>
等待日志输出...
</div>
))}
)}
</div>
</div>
</>
) : (
<Empty description="暂无进行中的分析任务" image={
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 48, height: 48 }}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
} />
<div className="empty-state">
<div className="empty-state-title">暂无任务数据</div>
</div>
)}
</Card>
{/* No Active Task */}
{!task && (
<div className="card">
<div className="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
<div className="empty-state-title">暂无进行中的分析</div>
<div className="empty-state-description">
在股票筛选页面选择股票并点击"分析"开始
</div>
<Button type="primary" style={{ marginTop: 16 }} aria-label="去筛选股票">
去筛选股票
</Button>
</div>
</div>
)}
</div>
)
}

View File

@ -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 <CheckCircleOutlined style={{ color: 'var(--color-buy)' }} />
case 'running':
return <SyncOutlined spin style={{ color: 'var(--color-running)' }} />
return <CheckCircleOutlined style={{ color: 'var(--color-buy)', fontSize: 16 }} />
case 'failed':
return <CloseCircleOutlined style={{ color: 'var(--color-sell)' }} />
return <CloseCircleOutlined style={{ color: 'var(--color-sell)', fontSize: 16 }} />
case 'running':
return <SyncOutlined spin style={{ color: 'var(--color-running)', fontSize: 16 }} />
default:
return <PauseCircleOutlined style={{ color: 'var(--color-hold)' }} />
return <span style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid rgba(0,0,0,0.2)', display: 'inline-block' }} />
}
}
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 (
<span style={{ background: s.bg, color: s.color, padding: '2px 10px', borderRadius: 'var(--radius-pill)', fontSize: 12, fontWeight: 600 }}>
{s.text}
</span>
)
}
const getDecisionBadge = (decision) => {
if (!decision) return null
const colorMap = {
BUY: 'var(--color-buy)',
SELL: 'var(--color-sell)',
HOLD: 'var(--color-hold)',
}
return (
<Tag
color={colorMap[decision]}
style={{ fontFamily: 'var(--font-data)', fontWeight: 600 }}
>
{decision}
</Tag>
)
}
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 (
<Tag style={{ background: `${s.color}20`, color: s.color, border: 'none' }}>
{s.text}
</Tag>
)
const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
return <span className={cls}>{decision}</span>
}
const columns = [
{
title: '状态',
key: 'status',
width: 100,
width: 110,
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{getStatusIcon(record.status)}
{getStatusTag(record)}
{getStatusTag(record.status)}
</div>
),
},
{
title: '股票',
key: 'stock',
render: (_, record) => (
<div>
<div style={{ fontWeight: 500 }}>{record.ticker}</div>
</div>
dataIndex: 'ticker',
key: 'ticker',
render: (text) => (
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 15 }}>{text}</span>
),
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 150,
width: 140,
render: (val, record) =>
record.status === 'running' || record.status === 'pending' ? (
<Progress
percent={val}
percent={val || 0}
size="small"
strokeColor={
record.status === 'pending'
? 'var(--color-hold)'
: 'var(--color-running)'
}
strokeColor="var(--color-apple-blue)"
trailColor="rgba(0,0,0,0.08)"
/>
) : (
<span style={{ fontFamily: 'var(--font-data)' }}>{val}%</span>
<span className="text-data">{val || 0}%</span>
),
},
{
@ -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) => (
<span style={{ fontFamily: 'var(--font-data)', fontSize: 12, color: 'var(--color-text-muted)' }}>{text}</span>
<Tooltip title={text} placement="topLeft">
<span className="text-data" style={{ fontSize: 11, color: 'rgba(0,0,0,0.48)', cursor: 'default' }}>
{text.slice(0, 18)}...
</span>
<button
onClick={(e) => { e.stopPropagation(); handleCopyTaskId(text) }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'rgba(0,0,0,0.48)', display: 'inline-flex', alignItems: 'center' }}
title="复制任务ID"
>
<CopyOutlined style={{ fontSize: 12 }} />
</button>
</Tooltip>
),
},
{
title: '错误',
dataIndex: 'error',
key: 'error',
width: 180,
ellipsis: { showTitle: false },
render: (error) =>
error ? (
<span style={{ color: 'var(--color-sell)', fontSize: 12 }}>{error}</span>
<Tooltip title={error} placement="topLeft">
<span style={{ color: 'var(--color-sell)', fontSize: 12, display: 'block' }}>{error}</span>
</Tooltip>
) : null,
},
{
title: '操作',
key: 'action',
width: 150,
width: 120,
render: (_, record) => (
<div style={{ display: 'flex', gap: 8 }}>
{record.status === 'running' && (
<Button
size="small"
danger
icon={<PauseCircleOutlined />}
onClick={() => handleCancel(record.task_id)}
aria-label="取消"
<Popconfirm
title="确认取消此任务?"
onConfirm={() => handleCancel(record.task_id)}
okText="确认"
cancelText="取消"
>
取消
</Button>
<Button size="small" danger icon={<DeleteOutlined />}>
取消
</Button>
</Popconfirm>
)}
{record.status === 'failed' && (
<Button
size="small"
icon={<SyncOutlined />}
onClick={() => handleRetry(record.task_id)}
aria-label="重试"
>
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRetry(record.task_id)}>
重试
</Button>
)}
@ -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 (
<div>
{/* Stats */}
<div style={{ display: 'flex', gap: 16, marginBottom: 'var(--space-6)' }}>
<div style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: 'var(--space-6)' }}>
<Card size="small" className="card" style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600 }}>
{pendingCount}
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>等待中</div>
<div className="text-data" style={{ fontSize: 32, fontWeight: 600 }}>{pendingCount}</div>
<div className="text-caption">等待中</div>
</Card>
<Card size="small" className="card" style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-running)' }}>
{runningCount}
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>分析中</div>
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-running)' }}>{runningCount}</div>
<div className="text-caption">分析中</div>
</Card>
<Card size="small" className="card" style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-buy)' }}>
{completedCount}
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>已完成</div>
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-buy)' }}>{completedCount}</div>
<div className="text-caption">已完成</div>
</Card>
<Card size="small" className="card" style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-sell)' }}>
{failedCount}
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>失败</div>
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-sell)' }}>{failedCount}</div>
<div className="text-caption">失败</div>
</Card>
</div>
{/* Settings */}
<Card size="small" className="card" style={{ marginBottom: 'var(--space-6)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span>最大并发数:</span>
<InputNumber
min={1}
max={10}
value={maxConcurrent}
onChange={(val) => setMaxConcurrent(val)}
style={{ width: 80 }}
/>
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>
同时运行的分析任务数量
</span>
</div>
</Card>
{/* Tasks Table */}
<div className="card">
{loading ? (
<Skeleton active rows={5} />
) : error ? (
{loading && tasks.length === 0 ? (
<div style={{ padding: 'var(--space-8)', textAlign: 'center' }}>
<div className="loading-pulse">加载中...</div>
</div>
) : error && tasks.length === 0 ? (
<Result
status="error"
title="加载失败"
description="点击重试按钮重新加载数据"
subTitle={error}
extra={
<Button
type="primary"
onClick={() => {
fetchTasks()
}}
aria-label="重试"
>
<Button type="primary" onClick={fetchTasks}>
重试
</Button>
}
/>
) : tasks.length === 0 ? (
<Empty
description="暂无批量任务"
image={
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 48, height: 48 }}>
<rect x="4" y="4" width="6" height="6" rx="1" />
<rect x="14" y="4" width="6" height="6" rx="1" />
<rect x="4" y="14" width="6" height="6" rx="1" />
<rect x="14" y="14" width="6" height="6" rx="1" />
</svg>
}
/>
<div className="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="4" y="4" width="6" height="6" rx="1" />
<rect x="14" y="4" width="6" height="6" rx="1" />
<rect x="4" y="14" width="6" height="6" rx="1" />
<rect x="14" y="14" width="6" height="6" rx="1" />
</svg>
<div className="empty-state-title">暂无批量任务</div>
<div className="empty-state-description">
在股票筛选页面提交分析任务
</div>
<button
className="btn-primary"
style={{ marginTop: 'var(--space-4)' }}
onClick={() => window.location.href = '/'}
>
去筛选
</button>
</div>
) : (
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
rowKey="task_id"
pagination={false}
size="middle"
/>
)}
</div>

View File

@ -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 <span className={cls}>{decision}</span>
}
// ============== 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 => <span className="text-data">{t}</span> },
{ title: '名称', dataIndex: 'name', key: 'name', render: t => <span style={{ fontWeight: 500 }}>{t}</span> },
{ title: '添加日期', dataIndex: 'added_at', key: 'added_at', width: 120 },
{
title: '操作', key: 'action', width: 100,
render: (_, r) => (
<Popconfirm title="确认移除?" onConfirm={() => handleDelete(r.ticker)} okText="确认" cancelText="取消">
<Button size="small" danger icon={<DeleteOutlined />}>移除</Button>
</Popconfirm>
),
},
]
return (
<div>
<div className="card" style={{ marginBottom: 'var(--space-4)' }}>
<div className="card-header">
<div className="card-title">自选股列表</div>
<Space>
<Button icon={<PlusOutlined />} type="primary" onClick={() => setAddOpen(true)}>添加</Button>
<Button icon={<SyncOutlined />} onClick={fetch_} loading={loading}>刷新</Button>
</Space>
</div>
</div>
<div className="card">
<Table columns={columns} dataSource={data} rowKey="ticker" loading={loading} pagination={false} size="middle" />
{data.length === 0 && !loading && (
<div className="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<div className="empty-state-title">暂无自选股</div>
<div className="empty-state-description">点击上方"添加"将股票加入自选</div>
</div>
)}
</div>
<Modal title="添加自选股" open={addOpen} onCancel={() => { setAddOpen(false); form.resetFields() }} footer={null}>
<Form form={form} layout="vertical" onFinish={handleAdd}>
<Form.Item name="ticker" label="股票代码" rules={[{ required: true, message: '请输入股票代码' }]}>
<Input placeholder="如 300750.SZ" />
</Form.Item>
<Form.Item name="name" label="名称(可选)">
<Input placeholder="如 宁德时代" />
</Form.Item>
<Button type="primary" htmlType="submit" block>添加</Button>
</Form>
</Modal>
</div>
)
}
// ============== 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 => <span className="text-data">{t}</span> },
{ title: '账户', dataIndex: 'account', key: 'account', width: 100 },
{ title: '数量', dataIndex: 'shares', key: 'shares', align: 'right', width: 80,
render: v => <span className="text-data">{v}</span> },
{ title: '成本价', dataIndex: 'cost_price', key: 'cost_price', align: 'right', width: 90,
render: v => <span className="text-data">{formatMoney(v)}</span> },
{ title: '现价', dataIndex: 'current_price', key: 'current_price', align: 'right', width: 90,
render: v => <span className="text-data">{formatMoney(v)}</span> },
{
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 (
<span className="text-data" style={{ color }}>
{pnl == null ? '—' : `${pnl >= 0 ? '+' : ''}${formatMoney(pnl)}`}
<br />
<span style={{ fontSize: 11 }}>{pct == null ? '' : formatPct(pct)}</span>
</span>
)
},
},
{
title: '买入日期',
dataIndex: 'purchase_date',
key: 'purchase_date',
width: 100,
},
{
title: '操作', key: 'action', width: 80,
render: (_, r) => (
<Popconfirm title="确认平仓?" onConfirm={() => handleDelete(r.ticker, r.position_id)} okText="确认" cancelText="取消">
<Button size="small" danger icon={<DeleteOutlined />}>平仓</Button>
</Popconfirm>
),
},
]
return (
<div>
<Row gutter={16} style={{ marginBottom: 'var(--space-4)' }}>
<Col xs={24} sm={12}>
<div className="card">
<div className="text-caption">账户</div>
<Select
value={account || '全部'}
onChange={v => setAccount(v === '全部' ? null : v)}
style={{ width: '100%' }}
options={[{ value: '全部', label: '全部账户' }, ...accounts.map(a => ({ value: a, label: a }))]}
/>
</div>
</Col>
<Col xs={24} sm={12}>
<div className="card">
<div className="text-caption">总浮亏浮盈</div>
<div className="text-data" style={{ fontSize: 28, fontWeight: 600, color: totalPnl >= 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
{formatMoney(totalPnl)}
</div>
</div>
</Col>
</Row>
<div className="card" style={{ marginBottom: 'var(--space-4)' }}>
<div className="card-header">
<div className="card-title">持仓记录</div>
<Space>
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
<Button icon={<PlusOutlined />} type="primary" onClick={() => setAddOpen(true)}>添加持仓</Button>
<Button icon={<SyncOutlined />} onClick={fetchPositions} loading={loading}>刷新</Button>
</Space>
</div>
</div>
<div className="card">
<Table columns={columns} dataSource={data} rowKey="position_id" loading={loading} pagination={false} size="middle" scroll={{ x: 700 }} />
{data.length === 0 && !loading && (
<div className="empty-state">
<AccountBookOutlined style={{ fontSize: 40, color: 'rgba(0,0,0,0.2)' }} />
<div className="empty-state-title">暂无持仓</div>
<div className="empty-state-description">点击"添加持仓"录入您的股票仓位</div>
</div>
)}
</div>
<Modal title="添加持仓" open={addOpen} onCancel={() => { setAddOpen(false); form.resetFields() }} footer={null}>
<Form form={form} layout="vertical" onFinish={handleAdd}>
<Form.Item name="ticker" label="股票代码" rules={[{ required: true, message: '请输入' }]}>
<Input placeholder="300750.SZ" />
</Form.Item>
<Form.Item name="shares" label="数量" rules={[{ required: true, message: '请输入' }]}>
<Input type="number" placeholder="100" />
</Form.Item>
<Form.Item name="cost_price" label="成本价" rules={[{ required: true, message: '请输入' }]}>
<Input type="number" placeholder="180.50" />
</Form.Item>
<Form.Item name="purchase_date" label="买入日期">
<Input placeholder="2026-01-15" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea placeholder="可选备注" />
</Form.Item>
<Button type="primary" htmlType="submit" block>添加</Button>
</Form>
</Modal>
</div>
)
}
// ============== 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 => <span className="text-data">{t}</span> },
{ title: '名称', dataIndex: 'name', key: 'name', render: t => <span style={{ fontWeight: 500 }}>{t}</span> },
{
title: '决策', dataIndex: 'decision', key: 'decision', width: 80,
render: d => <DecisionBadge decision={d} />,
},
{ title: '分析日期', dataIndex: 'analysis_date', key: 'analysis_date', width: 120 },
]
return (
<div>
{/* Analysis card */}
<div className="card" style={{ marginBottom: 'var(--space-4)' }}>
<div className="card-header">
<div className="card-title">今日建议</div>
<Space>
{analyzing && progress && (
<span className="text-caption">
{wsConnected ? '🟢' : '🔴'}
{progress.completed || 0} / {progress.total || 0}
</span>
)}
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleAnalyze}
loading={analyzing}
disabled={analyzing}
>
{analyzing ? '分析中...' : '生成今日建议'}
</Button>
</Space>
</div>
{analyzing && progress && (
<Progress
percent={Math.round(((progress.completed || 0) / (progress.total || 1)) * 100)}
status="active"
strokeColor="var(--color-apple-blue)"
/>
)}
</div>
{/* Date filter */}
<div className="card" style={{ marginBottom: 'var(--space-4)' }}>
<Select
allowClear
placeholder="筛选日期"
style={{ width: 200 }}
value={selectedDate}
onChange={setSelectedDate}
options={dates.map(d => ({ value: d, label: d }))}
/>
</div>
{/* Recommendations list */}
<div className="card">
<Table columns={columns} dataSource={data} rowKey="ticker" loading={loading} pagination={{ pageSize: 10 }} size="middle" />
{data.length === 0 && !loading && (
<div className="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<div className="empty-state-title">暂无建议</div>
<div className="empty-state-description">点击上方"生成今日建议"开始批量分析</div>
</div>
)}
</div>
</div>
)
}
// ============== Main ==============
export default function PortfolioPanel() {
const [activeTab, setActiveTab] = useState('watchlist')
const items = [
{ key: 'watchlist', label: '自选股', children: <WatchlistTab /> },
{ key: 'positions', label: '持仓', children: <PositionsTab /> },
{ key: 'recommendations', label: '今日建议', children: <RecommendationsTab /> },
]
return (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={items}
size="large"
/>
)
}

View File

@ -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) => (
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 15 }}>{text}</span>
),
},
{
title: '日期',
dataIndex: 'date',
key: 'date',
width: 120,
render: (text) => (
<span className="text-data">{text}</span>
),
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => (
<Button
type="primary"
icon={<FileTextOutlined />}
size="small"
onClick={() => handleViewReport(record)}
>
查看
</Button>
),
},
]
return (
<div>
{/* Search + Export */}
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Search
placeholder="搜索股票代码或日期..."
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.48)' }} />}
size="large"
style={{ flex: 1 }}
/>
<Button icon={<DownloadOutlined />} onClick={handleExportCsv} disabled={reports.length === 0}>
导出CSV
</Button>
</div>
</div>
{/* Reports Table */}
<div className="card">
{loading ? (
<div style={{ padding: 'var(--space-8)' }}>
<Skeleton active rows={5} />
</div>
) : filteredReports.length === 0 ? (
<div className="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" />
<path d="M14 2v6h6" />
</svg>
<div className="empty-state-title">暂无历史报告</div>
<div className="empty-state-description">
在股票筛选页面提交分析任务后报告将显示在这里
</div>
</div>
) : (
<Table
columns={columns}
dataSource={filteredReports}
rowKey={(r) => `${r.ticker}-${r.date}`}
pagination={{ pageSize: 10 }}
size="middle"
/>
)}
</div>
{/* Report Modal */}
<Modal
title={
selectedReport ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontFamily: 'var(--font-display)', fontSize: 17, fontWeight: 600 }}>
{selectedReport.ticker}
</span>
<span style={{ color: 'rgba(0,0,0,0.48)', fontSize: 14 }}>{selectedReport.date}</span>
</div>
) : null
}
open={!!selectedReport}
onCancel={() => {
setSelectedReport(null)
setReportContent(null)
}}
footer={
selectedReport ? (
<Space>
<Button
icon={<DownloadOutlined />}
onClick={() => handleExportPdf(selectedReport.ticker, selectedReport.date)}
>
导出PDF
</Button>
<Button onClick={() => { setSelectedReport(null); setReportContent(null) }}>
关闭
</Button>
</Space>
) : null
}
width={800}
closeIcon={<CloseOutlined style={{ fontSize: 16 }} />}
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 ? (
<div style={{ padding: 'var(--space-8)' }}>
<Skeleton active />
</div>
) : reportContent ? (
<div
style={{
fontFamily: 'var(--font-text)',
lineHeight: 1.8,
fontSize: 15,
}}
>
<ReactMarkdown>{reportContent.report || 'No content'}</ReactMarkdown>
</div>
) : null}
</Modal>
</div>
)
}

View File

@ -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) => (
<span style={{ fontFamily: 'var(--font-data)' }}>{text}</span>
<span className="text-data">{text}</span>
),
},
{
@ -56,18 +71,24 @@ export default function ScreeningPanel() {
dataIndex: 'name',
key: 'name',
width: 120,
render: (text) => (
<span style={{ fontWeight: 500 }}>{text}</span>
),
},
{
title: (
<Tooltip title="营业收入同比增长率">
<span>营收增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
营收增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
</span>
</Tooltip>
),
dataIndex: 'revenue_growth',
key: 'revenue_growth',
align: 'right',
width: 100,
render: (val) => (
<span style={{ fontFamily: 'var(--font-data)' }}>
<span className="text-data" style={{ color: val > 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
{val?.toFixed(1)}%
</span>
),
@ -75,14 +96,17 @@ export default function ScreeningPanel() {
{
title: (
<Tooltip title="净利润同比增长率">
<span>利润增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
利润增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
</span>
</Tooltip>
),
dataIndex: 'profit_growth',
key: 'profit_growth',
align: 'right',
width: 100,
render: (val) => (
<span style={{ fontFamily: 'var(--font-data)' }}>
<span className="text-data" style={{ color: val > 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
{val?.toFixed(1)}%
</span>
),
@ -90,16 +114,17 @@ export default function ScreeningPanel() {
{
title: (
<Tooltip title="净资产收益率 = 净利润/净资产">
<span>ROE <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
ROE <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
</span>
</Tooltip>
),
dataIndex: 'roe',
key: 'roe',
align: 'right',
width: 80,
render: (val) => (
<span style={{ fontFamily: 'var(--font-data)' }}>
{val?.toFixed(1)}%
</span>
<span className="text-data">{val?.toFixed(1)}%</span>
),
},
{
@ -107,31 +132,31 @@ export default function ScreeningPanel() {
dataIndex: 'current_price',
key: 'current_price',
align: 'right',
width: 100,
render: (val) => (
<span style={{ fontFamily: 'var(--font-data)' }}>
¥{val?.toFixed(2)}
</span>
<span className="text-data">¥{val?.toFixed(2)}</span>
),
},
{
title: (
<Tooltip title="当前成交量/过去20日平均成交量">
<span>Vol比 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
Vol比 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
</span>
</Tooltip>
),
dataIndex: 'vol_ratio',
key: 'vol_ratio',
align: 'right',
width: 80,
render: (val) => (
<span style={{ fontFamily: 'var(--font-data)' }}>
{val?.toFixed(2)}x
</span>
<span className="text-data">{val?.toFixed(2)}x</span>
),
},
{
title: '操作',
key: 'action',
width: 140,
width: 100,
render: (_, record) => (
<Popconfirm
title={`确认分析 ${record.name} (${record.ticker})`}
@ -152,53 +177,28 @@ export default function ScreeningPanel() {
},
]
const handleStartAnalysis = async (stock) => {
try {
const res = await fetch('/api/analysis/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticker: stock.ticker }),
})
if (!res.ok) throw new Error('启动分析失败')
const data = await res.json()
message.success(`已提交分析任务: ${stock.name} (${stock.ticker})`)
navigate(`/monitor?task_id=${data.task_id}`)
} catch (err) {
message.error(err.message)
}
}
return (
<div>
{/* Stats Row */}
{/* Stats Row - Apple style */}
<Row gutter={16} style={{ marginBottom: 'var(--space-6)' }}>
<Col xs={24} sm={8}>
<div className="card">
<Statistic
title="筛选模式"
value={SCREEN_MODES.find(m => m.value === mode)?.label}
/>
<div className="text-caption" style={{ marginBottom: 4 }}>筛选模式</div>
<div style={{ fontFamily: 'var(--font-text)', fontSize: 15, fontWeight: 500 }}>
{SCREEN_MODES.find(m => m.value === mode)?.label}
</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="card">
<Statistic
title="股票总数"
value={stats.total}
valueStyle={{ fontFamily: 'var(--font-data)' }}
/>
<div className="text-caption" style={{ marginBottom: 4 }}>股票总数</div>
<div className="text-data" style={{ fontSize: 28, fontWeight: 600 }}>{stats.total}</div>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="card">
<Statistic
title="通过数量"
value={stats.passed}
valueStyle={{
fontFamily: 'var(--font-data)',
color: 'var(--color-buy)',
}}
/>
<div className="text-caption" style={{ marginBottom: 4 }}>通过数量</div>
<div className="text-data" style={{ fontSize: 28, fontWeight: 600, color: 'var(--color-buy)' }}>{stats.passed}</div>
</div>
</Col>
</Row>
@ -213,6 +213,7 @@ export default function ScreeningPanel() {
onChange={setMode}
options={SCREEN_MODES}
style={{ width: 200 }}
popupMatchSelectWidth={false}
/>
<Button
icon={<ReloadOutlined />}
@ -239,12 +240,10 @@ export default function ScreeningPanel() {
type="primary"
icon={<ReloadOutlined />}
onClick={fetchResults}
aria-label="重试"
>
重试
</Button>
}
style={{ border: '1px solid var(--color-sell)', borderRadius: 'var(--radius-md)' }}
/>
) : results.length === 0 ? (
<div className="empty-state">
@ -265,6 +264,7 @@ export default function ScreeningPanel() {
rowKey="ticker"
pagination={{ pageSize: 10 }}
size="middle"
scroll={{ x: 700 }}
/>
)}
</div>

View File

@ -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'),
};