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>
This commit is contained in:
陈少杰 2026-04-07 00:49:10 +08:00
parent 3998984094
commit c329ef2885
6 changed files with 1001 additions and 1 deletions

View File

View File

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

View File

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

View File

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