From c329ef2885d35f4d2dc0dffd19b30b29205f37fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=B0=91=E6=9D=B0?= Date: Tue, 7 Apr 2026 00:49:10 +0800 Subject: [PATCH] feat(dashboard): add portfolio panel - watchlist, positions, and recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web_dashboard/backend/api/__init__.py | 0 web_dashboard/backend/api/portfolio.py | 244 +++++++++ web_dashboard/backend/main.py | 236 ++++++++- web_dashboard/frontend/src/App.jsx | 5 + .../frontend/src/pages/PortfolioPanel.jsx | 467 ++++++++++++++++++ .../frontend/src/services/portfolioApi.js | 50 ++ 6 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 web_dashboard/backend/api/__init__.py create mode 100644 web_dashboard/backend/api/portfolio.py create mode 100644 web_dashboard/frontend/src/pages/PortfolioPanel.jsx create mode 100644 web_dashboard/frontend/src/services/portfolioApi.js 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: , label: '筛选', key: '1' }, { path: '/monitor', icon: , label: '监控', key: '2' }, { path: '/reports', icon: , label: '报告', key: '3' }, { path: '/batch', icon: , label: '批量', key: '4' }, + { path: '/portfolio', icon: , label: '组合', key: '5' }, ] function Layout({ children }) { @@ -139,6 +142,7 @@ export default function App() { case '2': navigate('/monitor'); break case '3': navigate('/reports'); break case '4': navigate('/batch'); break + case '5': navigate('/portfolio'); break default: break } } @@ -158,6 +162,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/web_dashboard/frontend/src/pages/PortfolioPanel.jsx b/web_dashboard/frontend/src/pages/PortfolioPanel.jsx new file mode 100644 index 00000000..def6a728 --- /dev/null +++ b/web_dashboard/frontend/src/pages/PortfolioPanel.jsx @@ -0,0 +1,467 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Table, Button, Input, Select, Space, Row, Col, Card, Progress, Result, + message, Popconfirm, Modal, Tabs, Tag, Tooltip, Upload, Form, Typography, +} from 'antd' +import { + PlusOutlined, DeleteOutlined, PlayCircleOutlined, UploadOutlined, + DownloadOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, + AccountBookOutlined, +} from '@ant-design/icons' +import { portfolioApi } from '../services/portfolioApi' + +const { Text } = Typography + +// ============== Helpers ============== + +const formatMoney = (v) => + v == null ? '—' : `¥${v.toFixed(2)}`; + +const formatPct = (v) => + v == null ? '—' : `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`; + +const DecisionBadge = ({ decision }) => { + if (!decision) return null + const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold' + return {decision} +} + +// ============== Tab 1: Watchlist ============== + +function WatchlistTab() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [addOpen, setAddOpen] = useState(false) + const [form] = Form.useForm() + + const fetch_ = useCallback(async () => { + setLoading(true) + try { + const res = await portfolioApi.getWatchlist() + setData(res.watchlist || []) + } catch { + message.error('加载失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetch_() }, [fetch_]) + + const handleAdd = async (vals) => { + try { + await portfolioApi.addToWatchlist(vals.ticker, vals.name || vals.ticker) + message.success('已添加') + setAddOpen(false) + form.resetFields() + fetch_() + } catch (e) { + message.error(e.message) + } + } + + const handleDelete = async (ticker) => { + try { + await portfolioApi.removeFromWatchlist(ticker) + message.success('已移除') + fetch_() + } catch (e) { + message.error(e.message) + } + } + + const columns = [ + { title: '代码', dataIndex: 'ticker', key: 'ticker', width: 120, + render: t => {t} }, + { title: '名称', dataIndex: 'name', key: 'name', render: t => {t} }, + { title: '添加日期', dataIndex: 'added_at', key: 'added_at', width: 120 }, + { + title: '操作', key: 'action', width: 100, + render: (_, r) => ( + handleDelete(r.ticker)} okText="确认" cancelText="取消"> + + + ), + }, + ] + + return ( +
+
+
+
自选股列表
+ + + + +
+
+ +
+ + {data.length === 0 && !loading && ( +
+ + + +
暂无自选股
+
点击上方"添加"将股票加入自选
+
+ )} + + + { setAddOpen(false); form.resetFields() }} footer={null}> +
+ + + + + + + + +
+ + ) +} + +// ============== Tab 2: Positions ============== + +function PositionsTab() { + const [data, setData] = useState([]) + const [accounts, setAccounts] = useState(['默认账户']) + const [account, setAccount] = useState(null) + const [loading, setLoading] = useState(true) + const [addOpen, setAddOpen] = useState(false) + const [form] = Form.useForm() + + const fetchPositions = useCallback(async () => { + setLoading(true) + try { + const [posRes, accRes] = await Promise.all([ + portfolioApi.getPositions(account), + portfolioApi.getAccounts(), + ]) + setData(posRes.positions || []) + setAccounts(accRes.accounts || ['默认账户']) + } catch { + message.error('加载失败') + } finally { + setLoading(false) + } + }, [account]) + + useEffect(() => { fetchPositions() }, [fetchPositions]) + + const handleAdd = async (vals) => { + try { + await portfolioApi.addPosition({ ...vals, account: account || '默认账户' }) + message.success('已添加') + setAddOpen(false) + form.resetFields() + fetchPositions() + } catch (e) { + message.error(e.message) + } + } + + const handleDelete = async (ticker, positionId) => { + try { + await portfolioApi.removePosition(ticker, positionId, account) + message.success('已移除') + fetchPositions() + } catch (e) { + message.error(e.message) + } + } + + const handleExport = async () => { + try { + const blob = await portfolioApi.exportPositions(account) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url; a.download = 'positions.csv'; a.click() + URL.revokeObjectURL(url) + } catch (e) { + message.error(e.message) + } + } + + const totalPnl = data.reduce((s, p) => s + (p.unrealized_pnl || 0), 0) + + const columns = [ + { title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110, + render: t => {t} }, + { title: '账户', dataIndex: 'account', key: 'account', width: 100 }, + { title: '数量', dataIndex: 'shares', key: 'shares', align: 'right', width: 80, + render: v => {v} }, + { title: '成本价', dataIndex: 'cost_price', key: 'cost_price', align: 'right', width: 90, + render: v => {formatMoney(v)} }, + { title: '现价', dataIndex: 'current_price', key: 'current_price', align: 'right', width: 90, + render: v => {formatMoney(v)} }, + { + title: '浮亏浮盈', + key: 'pnl', + align: 'right', + width: 110, + render: (_, r) => { + const pnl = r.unrealized_pnl + const pct = r.unrealized_pnl_pct + const color = pnl == null ? undefined : pnl >= 0 ? 'var(--color-buy)' : 'var(--color-sell)' + return ( + + {pnl == null ? '—' : `${pnl >= 0 ? '+' : ''}${formatMoney(pnl)}`} +
+ {pct == null ? '' : formatPct(pct)} +
+ ) + }, + }, + { + title: '买入日期', + dataIndex: 'purchase_date', + key: 'purchase_date', + width: 100, + }, + { + title: '操作', key: 'action', width: 80, + render: (_, r) => ( + handleDelete(r.ticker, r.position_id)} okText="确认" cancelText="取消"> + + + ), + }, + ] + + return ( +
+ +
+
+
账户
+
+ {data.length === 0 && !loading && ( +
+ +
暂无持仓
+
点击"添加持仓"录入您的股票仓位
+
+ )} + + + { setAddOpen(false); form.resetFields() }} footer={null}> +
+ + + + + + + + + + + + + + + + + +
+ + ) +} + +// ============== Tab 3: Recommendations ============== + +function RecommendationsTab() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [analyzing, setAnalyzing] = useState(false) + const [taskId, setTaskId] = useState(null) + const [wsConnected, setWsConnected] = useState(false) + const [progress, setProgress] = useState(null) + const [selectedDate, setSelectedDate] = useState(null) + const [dates, setDates] = useState([]) + const wsRef = useRef(null) + + const fetchRecs = useCallback(async (date) => { + setLoading(true) + try { + const res = await portfolioApi.getRecommendations(date) + setData(res.recommendations || []) + if (!date) { + const d = [...new Set((res.recommendations || []).map(r => r.analysis_date))].sort().reverse() + setDates(d) + } + } catch { + message.error('加载失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchRecs(selectedDate) }, [fetchRecs, selectedDate]) + + const connectWs = useCallback((tid) => { + if (wsRef.current) wsRef.current.close() + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const ws = new WebSocket(`${protocol}//${host}/ws/analysis/${tid}`) + ws.onopen = () => setWsConnected(true) + ws.onmessage = (e) => { + const d = JSON.parse(e.data) + if (d.type === 'progress') setProgress(d) + } + ws.onclose = () => setWsConnected(false) + wsRef.current = ws + }, []) + + const handleAnalyze = async () => { + try { + const res = await portfolioApi.startAnalysis() + setTaskId(res.task_id) + setAnalyzing(true) + setProgress({ completed: 0, total: res.total, status: 'running' }) + connectWs(res.task_id) + message.info('开始批量分析...') + } catch (e) { + message.error(e.message) + } + } + + useEffect(() => { + if (progress?.status === 'completed' || progress?.status === 'failed') { + setAnalyzing(false) + setTaskId(null) + setProgress(null) + fetchRecs(selectedDate) + } + }, [progress?.status]) + + useEffect(() => () => { if (wsRef.current) wsRef.current.close() }, []) + + const columns = [ + { title: '代码', dataIndex: 'ticker', key: 'ticker', width: 110, + render: t => {t} }, + { title: '名称', dataIndex: 'name', key: 'name', render: t => {t} }, + { + title: '决策', dataIndex: 'decision', key: 'decision', width: 80, + render: d => , + }, + { title: '分析日期', dataIndex: 'analysis_date', key: 'analysis_date', width: 120 }, + ] + + return ( +
+ {/* Analysis card */} +
+
+
今日建议
+ + {analyzing && progress && ( + + {wsConnected ? '🟢' : '🔴'} + {progress.completed || 0} / {progress.total || 0} + + )} + + +
+ {analyzing && progress && ( + + )} +
+ + {/* Date filter */} +
+
+ {data.length === 0 && !loading && ( +
+ + + +
暂无建议
+
点击上方"生成今日建议"开始批量分析
+
+ )} + + + ) +} + +// ============== Main ============== + +export default function PortfolioPanel() { + const [activeTab, setActiveTab] = useState('watchlist') + + const items = [ + { key: 'watchlist', label: '自选股', children: }, + { key: 'positions', label: '持仓', children: }, + { key: 'recommendations', label: '今日建议', children: }, + ] + + return ( + + ) +} diff --git a/web_dashboard/frontend/src/services/portfolioApi.js b/web_dashboard/frontend/src/services/portfolioApi.js new file mode 100644 index 00000000..455d9617 --- /dev/null +++ b/web_dashboard/frontend/src/services/portfolioApi.js @@ -0,0 +1,50 @@ +const BASE = '/api/portfolio'; + +async function req(method, path, body) { + const opts = { + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (body !== undefined) opts.body = JSON.stringify(body); + const res = await fetch(`${BASE}${path}`, opts); + 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(); +} + +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 url = `${BASE}/positions/export${account ? `?account=${encodeURIComponent(account)}` : ''}`; + return fetch(url).then(r => r.blob()); + }, + + // Recommendations + getRecommendations: (date) => + req('GET', `/recommendations${date ? `?date=${date}` : ''}`), + getRecommendation: (date, ticker) => req('GET', `/recommendations/${date}/${ticker}`), + + // Batch analysis + startAnalysis: () => req('POST', '/analyze'), +};