diff --git a/web_dashboard/backend/api/__init__.py b/web_dashboard/backend/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/web_dashboard/backend/api/portfolio.py b/web_dashboard/backend/api/portfolio.py
new file mode 100644
index 00000000..c081c7dd
--- /dev/null
+++ b/web_dashboard/backend/api/portfolio.py
@@ -0,0 +1,244 @@
+"""
+Portfolio API — 自选股、持仓、每日建议
+"""
+import asyncio
+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 ==============
+
+def get_watchlist() -> list:
+ if not WATCHLIST_FILE.exists():
+ return []
+ try:
+ return json.loads(WATCHLIST_FILE.read_text()).get("watchlist", [])
+ except Exception:
+ return []
+
+
+def _save_watchlist(watchlist: list):
+ WATCHLIST_FILE.write_text(json.dumps({"watchlist": watchlist}, ensure_ascii=False, indent=2))
+
+
+def add_to_watchlist(ticker: str, name: str) -> dict:
+ watchlist = get_watchlist()
+ 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)
+ _save_watchlist(watchlist)
+ return entry
+
+
+def remove_from_watchlist(ticker: str) -> bool:
+ watchlist = get_watchlist()
+ new_list = [s for s in watchlist if s["ticker"] != ticker]
+ if len(new_list) == len(watchlist):
+ return False
+ _save_watchlist(new_list)
+ return True
+
+
+# ============== Accounts ==============
+
+def get_accounts() -> dict:
+ if not POSITIONS_FILE.exists():
+ return {"accounts": {}}
+ try:
+ return json.loads(POSITIONS_FILE.read_text())
+ except Exception:
+ return {"accounts": {}}
+
+
+def _save_accounts(data: dict):
+ POSITIONS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
+
+
+def get_or_create_default_account(accounts: dict) -> dict:
+ if "默认账户" not in accounts.get("accounts", {}):
+ accounts["accounts"]["默认账户"] = {"positions": {}}
+ return accounts["accounts"]["默认账户"]
+
+
+def create_account(account_name: str) -> dict:
+ accounts = get_accounts()
+ if account_name in accounts.get("accounts", {}):
+ raise ValueError(f"账户 {account_name} 已存在")
+ accounts["accounts"][account_name] = {"positions": {}}
+ _save_accounts(accounts)
+ return {"account_name": account_name}
+
+
+def delete_account(account_name: str) -> bool:
+ accounts = get_accounts()
+ if account_name not in accounts.get("accounts", {}):
+ return False
+ del accounts["accounts"][account_name]
+ _save_accounts(accounts)
+ return True
+
+
+# ============== Positions ==============
+
+def get_positions(account: Optional[str] = None) -> list:
+ """
+ Returns positions with live price from yfinance and computed P&L.
+ """
+ accounts = get_accounts()
+ result = []
+
+ 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
+ ]
+
+ for ticker, pos in positions:
+ try:
+ stock = yfinance.Ticker(ticker)
+ info = stock.info or {}
+ current_price = info.get("currentPrice") or info.get("regularMarketPrice")
+ except Exception:
+ current_price = None
+
+ shares = pos.get("shares", 0)
+ cost_price = pos.get("cost_price", 0)
+ 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
+ else:
+ unrealized_pnl = None
+ unrealized_pnl_pct = None
+
+ 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:
+ accounts = get_accounts()
+ acc = accounts.get("accounts", {}).get(account)
+ if not acc:
+ acc = get_or_create_default_account(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)
+ _save_accounts(accounts)
+ return position
+
+
+def remove_position(ticker: str, position_id: str, account: Optional[str]) -> bool:
+ accounts = get_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]
+ _save_accounts(accounts)
+ 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]
+ _save_accounts(accounts)
+ return True
+ return False
+
+
+# ============== 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]:
+ path = RECOMMENDATIONS_DIR / date / f"{ticker}.json"
+ if not path.exists():
+ return None
+ return json.loads(path.read_text())
+
+
+def save_recommendation(date: str, ticker: str, data: dict):
+ date_dir = RECOMMENDATIONS_DIR / date
+ date_dir.mkdir(parents=True, exist_ok=True)
+ (date_dir / f"{ticker}.json").write_text(json.dumps(data, ensure_ascii=False, indent=2))
diff --git a/web_dashboard/backend/main.py b/web_dashboard/backend/main.py
index 2bdeda79..6025feca 100644
--- a/web_dashboard/backend/main.py
+++ b/web_dashboard/backend/main.py
@@ -15,9 +15,10 @@ from pathlib import Path
from typing import Optional
from contextlib import asynccontextmanager
-from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query
+from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query, UploadFile, File
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
@@ -566,6 +567,239 @@ async def get_report(ticker: str, date: str):
return content
+# ============== 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():
+ try:
+ for i, stock in enumerate(watchlist):
+ 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])
+
+ try:
+ # Run analysis in subprocess (reuse existing script pattern)
+ script_path = Path(f"/tmp/analysis_{task_id}_{i}.py")
+ script_content = ANALYSIS_SCRIPT_TEMPLATE
+ script_path.write_text(script_content)
+
+ 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()
+ app.state.task_results[task_id]["completed"] = i + 1
+ rec = {
+ "ticker": ticker,
+ "name": stock.get("name", ticker),
+ "analysis_date": date,
+ "decision": decision,
+ "created_at": datetime.now().isoformat(),
+ }
+ save_recommendation(date, ticker, rec)
+ app.state.task_results[task_id]["results"].append(rec)
+ else:
+ app.state.task_results[task_id]["failed"] += 1
+
+ except Exception as e:
+ app.state.task_results[task_id]["failed"] += 1
+
+ await broadcast_progress(task_id, app.state.task_results[task_id])
+
+ app.state.task_results[task_id]["status"] = "completed"
+ app.state.task_results[task_id]["current_ticker"] = None
+ _save_task_status(task_id, app.state.task_results[task_id])
+
+ except Exception as e:
+ app.state.task_results[task_id]["status"] = "failed"
+ app.state.task_results[task_id]["error"] = str(e)
+ _save_task_status(task_id, app.state.task_results[task_id])
+
+ await broadcast_progress(task_id, app.state.task_results[task_id])
+
+ task = asyncio.create_task(run_portfolio_analysis())
+ app.state.analysis_tasks[task_id] = task
+
+ return {
+ "task_id": task_id,
+ "total": total,
+ "status": "running",
+ }
+
+
+
@app.get("/")
async def root():
return {"message": "TradingAgents Web Dashboard API", "version": "0.1.0"}
diff --git a/web_dashboard/frontend/src/App.jsx b/web_dashboard/frontend/src/App.jsx
index 997a0280..374ec0bb 100644
--- a/web_dashboard/frontend/src/App.jsx
+++ b/web_dashboard/frontend/src/App.jsx
@@ -7,18 +7,21 @@ import {
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: