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:
parent
3998984094
commit
c329ef2885
|
|
@ -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))
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'),
|
||||
};
|
||||
Loading…
Reference in New Issue