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 typing import Optional
|
||||||
from contextlib import asynccontextmanager
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
# Path to TradingAgents repo root
|
# Path to TradingAgents repo root
|
||||||
REPO_ROOT = Path(__file__).parent.parent.parent
|
REPO_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
|
@ -566,6 +567,239 @@ async def get_report(ticker: str, date: str):
|
||||||
return content
|
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("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "TradingAgents Web Dashboard API", "version": "0.1.0"}
|
return {"message": "TradingAgents Web Dashboard API", "version": "0.1.0"}
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,21 @@ import {
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
|
WalletOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
const ScreeningPanel = lazy(() => import('./pages/ScreeningPanel'))
|
const ScreeningPanel = lazy(() => import('./pages/ScreeningPanel'))
|
||||||
const AnalysisMonitor = lazy(() => import('./pages/AnalysisMonitor'))
|
const AnalysisMonitor = lazy(() => import('./pages/AnalysisMonitor'))
|
||||||
const ReportsViewer = lazy(() => import('./pages/ReportsViewer'))
|
const ReportsViewer = lazy(() => import('./pages/ReportsViewer'))
|
||||||
const BatchManager = lazy(() => import('./pages/BatchManager'))
|
const BatchManager = lazy(() => import('./pages/BatchManager'))
|
||||||
|
const PortfolioPanel = lazy(() => import('./pages/PortfolioPanel'))
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', icon: <FundOutlined />, label: '筛选', key: '1' },
|
{ path: '/', icon: <FundOutlined />, label: '筛选', key: '1' },
|
||||||
{ path: '/monitor', icon: <MonitorOutlined />, label: '监控', key: '2' },
|
{ path: '/monitor', icon: <MonitorOutlined />, label: '监控', key: '2' },
|
||||||
{ path: '/reports', icon: <FileTextOutlined />, label: '报告', key: '3' },
|
{ path: '/reports', icon: <FileTextOutlined />, label: '报告', key: '3' },
|
||||||
{ path: '/batch', icon: <ClusterOutlined />, label: '批量', key: '4' },
|
{ path: '/batch', icon: <ClusterOutlined />, label: '批量', key: '4' },
|
||||||
|
{ path: '/portfolio', icon: <WalletOutlined />, label: '组合', key: '5' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function Layout({ children }) {
|
function Layout({ children }) {
|
||||||
|
|
@ -139,6 +142,7 @@ export default function App() {
|
||||||
case '2': navigate('/monitor'); break
|
case '2': navigate('/monitor'); break
|
||||||
case '3': navigate('/reports'); break
|
case '3': navigate('/reports'); break
|
||||||
case '4': navigate('/batch'); break
|
case '4': navigate('/batch'); break
|
||||||
|
case '5': navigate('/portfolio'); break
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,6 +162,7 @@ export default function App() {
|
||||||
<Route path="/monitor" element={<AnalysisMonitor />} />
|
<Route path="/monitor" element={<AnalysisMonitor />} />
|
||||||
<Route path="/reports" element={<ReportsViewer />} />
|
<Route path="/reports" element={<ReportsViewer />} />
|
||||||
<Route path="/batch" element={<BatchManager />} />
|
<Route path="/batch" element={<BatchManager />} />
|
||||||
|
<Route path="/portfolio" element={<PortfolioPanel />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Layout>
|
</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