feat(dashboard): web dashboard phase 1 - screening, analysis, portfolio (#2)
* feat(dashboard): apply Apple design system to all 4 pages
- Font: replace SF Pro with DM Sans (web-available) throughout
- Typography: consistent DM Sans stack, monospace data display
- ScreeningPanel: add horizontal scroll for mobile, fix stat card hover
- AnalysisMonitor: Apple progress bar, stage pills, decision badge
- BatchManager: add copy-to-clipboard for task IDs, fix error tooltip truncation, add CTA to empty state
- ReportsViewer: Apple-styled modal, search bar consistency
- Keyboard: add Escape to close modals
- CSS: progress bar ease-out, sidebar collapse button icon-only mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(dashboard): secure API key handling and add stage progress streaming
- Pass ANTHROPIC_API_KEY via env dict instead of CLI args (P1 security fix)
- Add monitor_subprocess() coroutine with fcntl non-blocking reads
- Inject STAGE markers (analysts/research/trading/risk/portfolio) into script stdout
- Update task stage state and broadcast WebSocket progress at each stage boundary
- Add asyncio.Event for monitor cancellation on task completion/cancel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(dashboard): persist task state to disk for restart recovery
- Add TASK_STATUS_DIR for task state JSON files
- Lifespan startup: restore task states from disk
- Task completion/failure: write state to disk
- Task cancellation: delete persisted state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(dashboard): correct stage key mismatch, add created_at, persist cancelled tasks
- Fix ANALYSIS_STAGES key 'trader' → 'trading' to match backend STAGE markers
- Add created_at field to task state at creation, sort list_tasks by it
- Persist task state before broadcast in cancel path (closes restart race)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* 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>
* feat(dashboard): add CSV and PDF report export
- GET /api/reports/export: CSV with ticker,date,decision,summary
- GET /api/reports/{ticker}/{date}/pdf: PDF via fpdf2 with DejaVu fonts
- ReportsViewer: CSV export button + PDF export in modal footer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(dashboard): address 4 critical issues found in pre-landing review
1. main.py: move API key validation before task state creation —
prevents phantom "running" tasks when ANTHROPIC_API_KEY is missing
2. portfolio.py: make get_positions() async and fetch yfinance prices
concurrently via run_in_executor — no longer blocks event loop
3. portfolio.py: add fcntl.LOCK_EX around all JSON read-modify-write
operations on watchlist.json and positions.json — eliminates TOCTOU
lost-write races under concurrent requests
4. main.py: use tempfile.mkstemp with mode 0o600 instead of world-
readable /tmp/analysis_{task_id}.py — script content no longer
exposed to other users on shared hosts
Also: remove unused UploadFile/File imports, undefined _save_to_cache
function, dead code in _delete_task_status, and unused
get_or_create_default_account helper.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(dashboard): use secure temp file for batch analysis scripts
Batch portfolio analysis was writing scripts to /tmp with default
permissions (0o644), exposing the API key to other local users.
Switch to tempfile.mkstemp + chmod 0o600, matching the single-analysis
pattern. Also fix cancel_task cleanup to use glob patterns for
tempfile-generated paths.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(dashboard): remove fake fallback data from ReportsViewer
ReportsViewer showed fabricated Chinese text when a report failed to load,
making fake data appear indistinguishable from real analysis. Now shows
an error message instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(dashboard): reliability fixes - cross-platform PDF fonts, API timeouts, yfinance concurrency, retry logic
- PDF: try multiple DejaVu font paths (macOS + Linux) instead of hardcoded macOS
- Frontend: add 15s AbortController timeout to all API calls + proper error handling
- yfinance: cap concurrent price fetches at 5 via asyncio.Semaphore
- Batch analysis: retry failed stock analyses up to 2x with exponential backoff
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve 4 critical security/correctness bugs in web dashboard
1. Mass position deletion (portfolio.py): remove_position now rejects
empty position_id — previously position_id="" matched all positions
and deleted every holding for a ticker across ALL accounts.
2. Path traversal in get_recommendation (portfolio.py): added ticker/date
validation (no ".." or path separators) + resolved-path check against
RECOMMENDATIONS_DIR to prevent ../../etc/passwd attacks.
3. Path traversal in get_report_content (main.py): same ticker/date
validation + resolved-path check against get_results_dir().
4. china_data import stub (interface.py + new china_data.py): the actual
akshare implementation lives in web_dashboard/backend/china_data.py
(different package); tradingagents/dataflows/china_data.py was missing
entirely, so _china_data_available was always False. Added stub file
and AttributeError to the import exception handler so the module
gracefully degrades instead of silently hiding the missing vendor.
Magic numbers also extracted to named constants:
- MAX_RETRY_COUNT, RETRY_BASE_DELAY_SECS (main.py)
- MAX_CONCURRENT_YFINANCE_REQUESTS (portfolio.py)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
09ec174049
commit
f19c1c012e
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
china_data vendor for TradingAgents dataflows.
|
||||
|
||||
NOTE: This stub exists because the actual china_data implementation (akshare-based)
|
||||
lives in web_dashboard/backend/china_data.py, not here. The tradingagents package
|
||||
does not currently ship with a china_data vendor implementation.
|
||||
|
||||
To use china_data functionality, run analysis through the web dashboard where
|
||||
akshare is available as a data source.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
def __getattr__(name: str):
|
||||
# Return None for all china_data imports so interface.py can handle them gracefully
|
||||
return None
|
||||
|
||||
|
|
@ -24,6 +24,42 @@ from .alpha_vantage import (
|
|||
)
|
||||
from .alpha_vantage_common import AlphaVantageRateLimitError
|
||||
|
||||
# Lazy china_data import — only fails at runtime if akshare is missing and china_data vendor is selected
|
||||
try:
|
||||
from .china_data import (
|
||||
get_china_data_online,
|
||||
get_indicators_china,
|
||||
get_china_stock_info,
|
||||
get_china_financials,
|
||||
get_china_news,
|
||||
get_china_market_news,
|
||||
# Wrappers matching caller signatures:
|
||||
get_china_fundamentals,
|
||||
get_china_balance_sheet,
|
||||
get_china_cashflow,
|
||||
get_china_income_statement,
|
||||
get_china_news_wrapper,
|
||||
get_china_global_news_wrapper,
|
||||
get_china_insider_transactions,
|
||||
)
|
||||
_china_data_available = True
|
||||
except (ImportError, AttributeError):
|
||||
_china_data_available = False
|
||||
get_china_data_online = None
|
||||
get_indicators_china = None
|
||||
get_china_stock_info = None
|
||||
get_china_financials = None
|
||||
get_china_news = None
|
||||
get_china_market_news = None
|
||||
get_china_fundamentals = None
|
||||
get_china_balance_sheet = None
|
||||
get_china_cashflow = None
|
||||
get_china_income_statement = None
|
||||
get_china_news_wrapper = None
|
||||
get_china_global_news_wrapper = None
|
||||
get_china_insider_transactions = None
|
||||
|
||||
|
||||
# Configuration and routing logic
|
||||
from .config import get_config
|
||||
|
||||
|
|
@ -31,15 +67,11 @@ from .config import get_config
|
|||
TOOLS_CATEGORIES = {
|
||||
"core_stock_apis": {
|
||||
"description": "OHLCV stock price data",
|
||||
"tools": [
|
||||
"get_stock_data"
|
||||
]
|
||||
"tools": ["get_stock_data"],
|
||||
},
|
||||
"technical_indicators": {
|
||||
"description": "Technical analysis indicators",
|
||||
"tools": [
|
||||
"get_indicators"
|
||||
]
|
||||
"tools": ["get_indicators"],
|
||||
},
|
||||
"fundamental_data": {
|
||||
"description": "Company fundamentals",
|
||||
|
|
@ -47,8 +79,8 @@ TOOLS_CATEGORIES = {
|
|||
"get_fundamentals",
|
||||
"get_balance_sheet",
|
||||
"get_cashflow",
|
||||
"get_income_statement"
|
||||
]
|
||||
"get_income_statement",
|
||||
],
|
||||
},
|
||||
"news_data": {
|
||||
"description": "News and insider data",
|
||||
|
|
@ -56,17 +88,19 @@ TOOLS_CATEGORIES = {
|
|||
"get_news",
|
||||
"get_global_news",
|
||||
"get_insider_transactions",
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
VENDOR_LIST = [
|
||||
"yfinance",
|
||||
"alpha_vantage",
|
||||
*(["china_data"] if _china_data_available else []),
|
||||
]
|
||||
|
||||
# Mapping of methods to their vendor-specific implementations
|
||||
VENDOR_METHODS = {
|
||||
# china_data entries are only present if akshare is installed (_china_data_available)
|
||||
_base_vendor_methods = {
|
||||
# core_stock_apis
|
||||
"get_stock_data": {
|
||||
"alpha_vantage": get_alpha_vantage_stock,
|
||||
|
|
@ -109,6 +143,22 @@ VENDOR_METHODS = {
|
|||
},
|
||||
}
|
||||
|
||||
# Conditionally add china_data vendor only if akshare is available
|
||||
if _china_data_available:
|
||||
_base_vendor_methods["get_stock_data"]["china_data"] = get_china_data_online
|
||||
_base_vendor_methods["get_indicators"]["china_data"] = get_indicators_china
|
||||
_base_vendor_methods["get_fundamentals"]["china_data"] = get_china_fundamentals
|
||||
_base_vendor_methods["get_balance_sheet"]["china_data"] = get_china_balance_sheet
|
||||
_base_vendor_methods["get_cashflow"]["china_data"] = get_china_cashflow
|
||||
_base_vendor_methods["get_income_statement"]["china_data"] = get_china_income_statement
|
||||
_base_vendor_methods["get_news"]["china_data"] = get_china_news_wrapper
|
||||
_base_vendor_methods["get_global_news"]["china_data"] = get_china_global_news_wrapper
|
||||
_base_vendor_methods["get_insider_transactions"]["china_data"] = get_china_insider_transactions
|
||||
|
||||
VENDOR_METHODS = _base_vendor_methods
|
||||
del _base_vendor_methods
|
||||
|
||||
|
||||
def get_category_for_method(method: str) -> str:
|
||||
"""Get the category that contains the specified method."""
|
||||
for category, info in TOOLS_CATEGORIES.items():
|
||||
|
|
@ -116,6 +166,7 @@ def get_category_for_method(method: str) -> str:
|
|||
return category
|
||||
raise ValueError(f"Method '{method}' not found in any category")
|
||||
|
||||
|
||||
def get_vendor(category: str, method: str = None) -> str:
|
||||
"""Get the configured vendor for a data category or specific tool method.
|
||||
Tool-level configuration takes precedence over category-level.
|
||||
|
|
@ -131,11 +182,12 @@ def get_vendor(category: str, method: str = None) -> str:
|
|||
# Fall back to category-level configuration
|
||||
return config.get("data_vendors", {}).get(category, "default")
|
||||
|
||||
|
||||
def route_to_vendor(method: str, *args, **kwargs):
|
||||
"""Route method calls to appropriate vendor implementation with fallback support."""
|
||||
category = get_category_for_method(method)
|
||||
vendor_config = get_vendor(category, method)
|
||||
primary_vendors = [v.strip() for v in vendor_config.split(',')]
|
||||
primary_vendors = [v.strip() for v in vendor_config.split(",")]
|
||||
|
||||
if method not in VENDOR_METHODS:
|
||||
raise ValueError(f"Method '{method}' not supported")
|
||||
|
|
@ -159,4 +211,4 @@ def route_to_vendor(method: str, *args, **kwargs):
|
|||
except AlphaVantageRateLimitError:
|
||||
continue # Only rate limits trigger fallback
|
||||
|
||||
raise RuntimeError(f"No available vendor for '{method}'")
|
||||
raise RuntimeError(f"No available vendor for '{method}'")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
"""
|
||||
Portfolio API — 自选股、持仓、每日建议
|
||||
"""
|
||||
import asyncio
|
||||
import fcntl
|
||||
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_LOCK = DATA_DIR / "watchlist.lock"
|
||||
POSITIONS_LOCK = DATA_DIR / "positions.lock"
|
||||
|
||||
|
||||
# ============== Watchlist ==============
|
||||
|
||||
def get_watchlist() -> list:
|
||||
if not WATCHLIST_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
with open(WATCHLIST_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
return json.loads(WATCHLIST_FILE.read_text()).get("watchlist", [])
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_watchlist(watchlist: list):
|
||||
with open(WATCHLIST_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
WATCHLIST_FILE.write_text(json.dumps({"watchlist": watchlist}, ensure_ascii=False, indent=2))
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def add_to_watchlist(ticker: str, name: str) -> dict:
|
||||
with open(WATCHLIST_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
watchlist = json.loads(WATCHLIST_FILE.read_text()).get("watchlist", []) if WATCHLIST_FILE.exists() else []
|
||||
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)
|
||||
WATCHLIST_FILE.write_text(json.dumps({"watchlist": watchlist}, ensure_ascii=False, indent=2))
|
||||
return entry
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def remove_from_watchlist(ticker: str) -> bool:
|
||||
with open(WATCHLIST_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
watchlist = json.loads(WATCHLIST_FILE.read_text()).get("watchlist", []) if WATCHLIST_FILE.exists() else []
|
||||
new_list = [s for s in watchlist if s["ticker"] != ticker]
|
||||
if len(new_list) == len(watchlist):
|
||||
return False
|
||||
WATCHLIST_FILE.write_text(json.dumps({"watchlist": new_list}, ensure_ascii=False, indent=2))
|
||||
return True
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
# ============== Accounts ==============
|
||||
|
||||
def get_accounts() -> dict:
|
||||
if not POSITIONS_FILE.exists():
|
||||
return {"accounts": {}}
|
||||
try:
|
||||
with open(POSITIONS_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
return json.loads(POSITIONS_FILE.read_text())
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
return {"accounts": {}}
|
||||
|
||||
|
||||
def _save_accounts(data: dict):
|
||||
with open(POSITIONS_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
POSITIONS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def create_account(account_name: str) -> dict:
|
||||
with open(POSITIONS_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
accounts = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {"accounts": {}}
|
||||
if account_name in accounts.get("accounts", {}):
|
||||
raise ValueError(f"账户 {account_name} 已存在")
|
||||
accounts["accounts"][account_name] = {"positions": {}}
|
||||
POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
|
||||
return {"account_name": account_name}
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def delete_account(account_name: str) -> bool:
|
||||
with open(POSITIONS_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
accounts = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {"accounts": {}}
|
||||
if account_name not in accounts.get("accounts", {}):
|
||||
return False
|
||||
del accounts["accounts"][account_name]
|
||||
POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
|
||||
return True
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
# ============== Positions =============
|
||||
|
||||
# Semaphore to limit concurrent yfinance requests (avoid rate limiting)
|
||||
MAX_CONCURRENT_YFINANCE_REQUESTS = 5
|
||||
_yfinance_semaphore: asyncio.Semaphore = asyncio.Semaphore(MAX_CONCURRENT_YFINANCE_REQUESTS)
|
||||
|
||||
|
||||
def _fetch_price(ticker: str) -> float | None:
|
||||
"""Fetch current price synchronously (called in thread executor)"""
|
||||
try:
|
||||
stock = yfinance.Ticker(ticker)
|
||||
info = stock.info or {}
|
||||
return info.get("currentPrice") or info.get("regularMarketPrice")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_price_throttled(ticker: str) -> float | None:
|
||||
"""Fetch price with semaphore throttling."""
|
||||
async with _yfinance_semaphore:
|
||||
return _fetch_price(ticker)
|
||||
|
||||
|
||||
async def get_positions(account: Optional[str] = None) -> list:
|
||||
"""
|
||||
Returns positions with live price from yfinance and computed P&L.
|
||||
Uses asyncio executor with concurrency limit (max 5 simultaneous requests).
|
||||
"""
|
||||
accounts = get_accounts()
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
if not positions:
|
||||
return []
|
||||
|
||||
tickers = [t for t, _ in positions]
|
||||
prices = await asyncio.gather(*[_fetch_price_throttled(t) for t in tickers])
|
||||
|
||||
result = []
|
||||
for (ticker, pos), current_price in zip(positions, prices):
|
||||
shares = pos.get("shares", 0)
|
||||
cost_price = pos.get("cost_price", 0)
|
||||
unrealized_pnl = None
|
||||
unrealized_pnl_pct = None
|
||||
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
|
||||
|
||||
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:
|
||||
with open(POSITIONS_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
accounts = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {"accounts": {}}
|
||||
acc = accounts.get("accounts", {}).get(account)
|
||||
if not acc:
|
||||
if "默认账户" not in accounts.get("accounts", {}):
|
||||
accounts["accounts"]["默认账户"] = {"positions": {}}
|
||||
acc = accounts["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)
|
||||
POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
|
||||
return position
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def remove_position(ticker: str, position_id: str, account: Optional[str]) -> bool:
|
||||
if not position_id:
|
||||
return False # Require explicit position_id to prevent mass deletion
|
||||
with open(POSITIONS_LOCK, "w") as lf:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
accounts = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {"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]
|
||||
POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
|
||||
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]
|
||||
POSITIONS_FILE.write_text(json.dumps(accounts, ensure_ascii=False, indent=2))
|
||||
return True
|
||||
return False
|
||||
finally:
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
# ============== 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]:
|
||||
# Validate inputs to prevent path traversal
|
||||
if ".." in ticker or "/" in ticker or "\\" in ticker:
|
||||
return None
|
||||
if ".." in date or "/" in date or "\\" in date:
|
||||
return None
|
||||
path = RECOMMENDATIONS_DIR / date / f"{ticker}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
# Ensure resolved path is within RECOMMENDATIONS_DIR (strict traversal check)
|
||||
try:
|
||||
path.resolve().relative_to(RECOMMENDATIONS_DIR.resolve())
|
||||
except ValueError:
|
||||
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))
|
||||
|
|
@ -3,10 +3,12 @@ TradingAgents Web Dashboard Backend
|
|||
FastAPI REST API + WebSocket for real-time analysis progress
|
||||
"""
|
||||
import asyncio
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
|
@ -17,11 +19,14 @@ from contextlib import asynccontextmanager
|
|||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query
|
||||
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
|
||||
# Use the currently running Python interpreter
|
||||
ANALYSIS_PYTHON = Path(sys.executable)
|
||||
# Task state persistence directory
|
||||
TASK_STATUS_DIR = Path(__file__).parent / "data" / "task_status"
|
||||
|
||||
|
||||
# ============== Lifespan ==============
|
||||
|
|
@ -32,6 +37,16 @@ async def lifespan(app: FastAPI):
|
|||
app.state.active_connections: dict[str, list[WebSocket]] = {}
|
||||
app.state.task_results: dict[str, dict] = {}
|
||||
app.state.analysis_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
# Restore persisted task states from disk
|
||||
TASK_STATUS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for f in TASK_STATUS_DIR.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
app.state.task_results[data["task_id"]] = data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
yield
|
||||
|
||||
|
||||
|
|
@ -46,7 +61,6 @@ app = FastAPI(
|
|||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
|
@ -65,6 +79,9 @@ class ScreenRequest(BaseModel):
|
|||
|
||||
CACHE_DIR = Path(__file__).parent.parent / "cache"
|
||||
CACHE_TTL_SECONDS = 300 # 5 minutes
|
||||
MAX_RETRY_COUNT = 2
|
||||
RETRY_BASE_DELAY_SECS = 1
|
||||
MAX_CONCURRENT_YFINANCE = 5
|
||||
|
||||
|
||||
def _get_cache_path(mode: str) -> Path:
|
||||
|
|
@ -86,6 +103,7 @@ def _load_from_cache(mode: str) -> Optional[dict]:
|
|||
|
||||
|
||||
def _save_to_cache(mode: str, data: dict):
|
||||
"""Save screening result to cache"""
|
||||
try:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_path = _get_cache_path(mode)
|
||||
|
|
@ -95,6 +113,23 @@ def _save_to_cache(mode: str, data: dict):
|
|||
pass
|
||||
|
||||
|
||||
def _save_task_status(task_id: str, data: dict):
|
||||
"""Persist task state to disk"""
|
||||
try:
|
||||
TASK_STATUS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(TASK_STATUS_DIR / f"{task_id}.json").write_text(json.dumps(data, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _delete_task_status(task_id: str):
|
||||
"""Remove persisted task state from disk"""
|
||||
try:
|
||||
(TASK_STATUS_DIR / f"{task_id}.json").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============== SEPA Screening ==============
|
||||
|
||||
def _run_sepa_screening(mode: str) -> dict:
|
||||
|
|
@ -130,17 +165,15 @@ async def screen_stocks(mode: str = Query("china_strict"), refresh: bool = Query
|
|||
# ============== Analysis Execution ==============
|
||||
|
||||
# Script template for subprocess-based analysis
|
||||
# ticker and date are passed as command-line args to avoid injection
|
||||
# api_key is passed via environment variable (not CLI) for security
|
||||
ANALYSIS_SCRIPT_TEMPLATE = """
|
||||
import sys
|
||||
import os
|
||||
ticker = sys.argv[1]
|
||||
date = sys.argv[2]
|
||||
repo_root = sys.argv[3]
|
||||
api_key = sys.argv[4]
|
||||
|
||||
sys.path.insert(0, repo_root)
|
||||
import os
|
||||
os.environ["ANTHROPIC_API_KEY"] = api_key
|
||||
os.environ["ANTHROPIC_BASE_URL"] = "https://api.minimaxi.com/anthropic"
|
||||
import py_mini_racer
|
||||
sys.modules["mini_racer"] = py_mini_racer
|
||||
|
|
@ -148,6 +181,8 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph
|
|||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
from pathlib import Path
|
||||
|
||||
print("STAGE:analysts", flush=True)
|
||||
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
config["llm_provider"] = "anthropic"
|
||||
config["deep_think_llm"] = "MiniMax-M2.7-highspeed"
|
||||
|
|
@ -156,9 +191,15 @@ config["backend_url"] = "https://api.minimaxi.com/anthropic"
|
|||
config["max_debate_rounds"] = 1
|
||||
config["max_risk_discuss_rounds"] = 1
|
||||
|
||||
print("STAGE:research", flush=True)
|
||||
|
||||
ta = TradingAgentsGraph(debug=False, config=config)
|
||||
print("STAGE:trading", flush=True)
|
||||
|
||||
final_state, decision = ta.propagate(ticker, date)
|
||||
|
||||
print("STAGE:risk", flush=True)
|
||||
|
||||
results_dir = Path(repo_root) / "results" / ticker / date
|
||||
results_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
@ -178,7 +219,8 @@ report_content = (
|
|||
report_path = results_dir / "complete_report.md"
|
||||
report_path.write_text(report_content)
|
||||
|
||||
print("ANALYSIS_COMPLETE:" + signal)
|
||||
print("STAGE:portfolio", flush=True)
|
||||
print("ANALYSIS_COMPLETE:" + signal, flush=True)
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -189,6 +231,11 @@ async def start_analysis(request: AnalysisRequest):
|
|||
task_id = f"{request.ticker}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
date = request.date or datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Validate API key before storing any task state
|
||||
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")
|
||||
|
||||
# Initialize task state
|
||||
app.state.task_results[task_id] = {
|
||||
"task_id": task_id,
|
||||
|
|
@ -197,6 +244,7 @@ async def start_analysis(request: AnalysisRequest):
|
|||
"status": "running",
|
||||
"progress": 0,
|
||||
"current_stage": "analysts",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"elapsed": 0,
|
||||
"stages": [
|
||||
{"status": "running", "completed_at": None},
|
||||
|
|
@ -209,22 +257,73 @@ async def start_analysis(request: AnalysisRequest):
|
|||
"decision": None,
|
||||
"error": None,
|
||||
}
|
||||
# Get API key - fail fast before storing a running task
|
||||
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])
|
||||
|
||||
# Write analysis script to temp file (avoids subprocess -c quoting issues)
|
||||
script_path = Path(f"/tmp/analysis_{task_id}.py")
|
||||
script_content = ANALYSIS_SCRIPT_TEMPLATE
|
||||
script_path.write_text(script_content)
|
||||
# Write analysis script to temp file with restrictive permissions (avoids subprocess -c quoting issues)
|
||||
fd, script_path_str = tempfile.mkstemp(suffix=".py", prefix=f"analysis_{task_id}_")
|
||||
script_path = Path(script_path_str)
|
||||
os.chmod(script_path, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(ANALYSIS_SCRIPT_TEMPLATE)
|
||||
|
||||
# Store process reference for cancellation
|
||||
app.state.processes = getattr(app.state, 'processes', {})
|
||||
app.state.processes[task_id] = None
|
||||
|
||||
# Cancellation event for the monitor coroutine
|
||||
cancel_event = asyncio.Event()
|
||||
|
||||
# Stage name to index mapping
|
||||
STAGE_NAMES = ["analysts", "research", "trading", "risk", "portfolio"]
|
||||
|
||||
def _update_task_stage(stage_name: str):
|
||||
"""Update task state for a completed stage and mark next as running."""
|
||||
try:
|
||||
idx = STAGE_NAMES.index(stage_name)
|
||||
except ValueError:
|
||||
return
|
||||
# Mark all previous stages as completed
|
||||
for i in range(idx):
|
||||
if app.state.task_results[task_id]["stages"][i]["status"] != "completed":
|
||||
app.state.task_results[task_id]["stages"][i]["status"] = "completed"
|
||||
app.state.task_results[task_id]["stages"][i]["completed_at"] = datetime.now().strftime("%H:%M:%S")
|
||||
# Mark current as completed
|
||||
if app.state.task_results[task_id]["stages"][idx]["status"] != "completed":
|
||||
app.state.task_results[task_id]["stages"][idx]["status"] = "completed"
|
||||
app.state.task_results[task_id]["stages"][idx]["completed_at"] = datetime.now().strftime("%H:%M:%S")
|
||||
# Mark next as running
|
||||
if idx + 1 < 5:
|
||||
if app.state.task_results[task_id]["stages"][idx + 1]["status"] == "pending":
|
||||
app.state.task_results[task_id]["stages"][idx + 1]["status"] = "running"
|
||||
# Update progress
|
||||
app.state.task_results[task_id]["progress"] = int((idx + 1) / 5 * 100)
|
||||
app.state.task_results[task_id]["current_stage"] = stage_name
|
||||
|
||||
async def monitor_subprocess(task_id: str, proc: asyncio.subprocess.Process, cancel_evt: asyncio.Event):
|
||||
"""Monitor subprocess stdout for stage markers and broadcast progress."""
|
||||
# Set stdout to non-blocking
|
||||
fd = proc.stdout.fileno()
|
||||
fl = fcntl.fcntl(fd, fcntl.GETFL)
|
||||
fcntl.fcntl(fd, fcntl.SETFL, fl | os.O_NONBLOCK)
|
||||
|
||||
while not cancel_evt.is_set():
|
||||
if proc.returncode is not None:
|
||||
break
|
||||
await asyncio.sleep(5)
|
||||
if cancel_evt.is_set():
|
||||
break
|
||||
try:
|
||||
chunk = os.read(fd, 32768)
|
||||
if chunk:
|
||||
for line in chunk.decode().splitlines():
|
||||
if line.startswith("STAGE:"):
|
||||
stage = line.split(":", 1)[1].strip()
|
||||
_update_task_stage(stage)
|
||||
await broadcast_progress(task_id, app.state.task_results[task_id])
|
||||
except (BlockingIOError, OSError):
|
||||
# No data available yet
|
||||
pass
|
||||
|
||||
async def run_analysis():
|
||||
"""Run analysis subprocess and broadcast progress"""
|
||||
try:
|
||||
|
|
@ -240,15 +339,24 @@ async def start_analysis(request: AnalysisRequest):
|
|||
request.ticker,
|
||||
date,
|
||||
str(REPO_ROOT),
|
||||
api_key,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=clean_env,
|
||||
)
|
||||
app.state.processes[task_id] = proc
|
||||
|
||||
# Start monitor coroutine alongside subprocess
|
||||
monitor_task = asyncio.create_task(monitor_subprocess(task_id, proc, cancel_event))
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
# Signal monitor to stop and wait for it
|
||||
cancel_event.set()
|
||||
try:
|
||||
await asyncio.wait_for(monitor_task, timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
monitor_task.cancel()
|
||||
|
||||
# Clean up script file
|
||||
try:
|
||||
script_path.unlink()
|
||||
|
|
@ -258,7 +366,7 @@ async def start_analysis(request: AnalysisRequest):
|
|||
if proc.returncode == 0:
|
||||
output = stdout.decode()
|
||||
decision = "HOLD"
|
||||
for line in output.split("\n"):
|
||||
for line in output.splitlines():
|
||||
if line.startswith("ANALYSIS_COMPLETE:"):
|
||||
decision = line.split(":", 1)[1].strip()
|
||||
|
||||
|
|
@ -268,13 +376,17 @@ async def start_analysis(request: AnalysisRequest):
|
|||
app.state.task_results[task_id]["current_stage"] = "portfolio"
|
||||
for i in range(5):
|
||||
app.state.task_results[task_id]["stages"][i]["status"] = "completed"
|
||||
app.state.task_results[task_id]["stages"][i]["completed_at"] = datetime.now().strftime("%H:%M:%S")
|
||||
if not app.state.task_results[task_id]["stages"][i].get("completed_at"):
|
||||
app.state.task_results[task_id]["stages"][i]["completed_at"] = datetime.now().strftime("%H:%M:%S")
|
||||
else:
|
||||
error_msg = stderr.decode()[-1000:] if stderr else "Unknown error"
|
||||
app.state.task_results[task_id]["status"] = "failed"
|
||||
app.state.task_results[task_id]["error"] = error_msg
|
||||
|
||||
_save_task_status(task_id, app.state.task_results[task_id])
|
||||
|
||||
except Exception as e:
|
||||
cancel_event.set()
|
||||
app.state.task_results[task_id]["status"] = "failed"
|
||||
app.state.task_results[task_id]["error"] = str(e)
|
||||
try:
|
||||
|
|
@ -282,6 +394,8 @@ async def start_analysis(request: AnalysisRequest):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
_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_analysis())
|
||||
|
|
@ -316,10 +430,10 @@ async def list_tasks():
|
|||
"progress": state.get("progress", 0),
|
||||
"decision": state.get("decision"),
|
||||
"error": state.get("error"),
|
||||
"created_at": state.get("stages", [{}])[0].get("completed_at") if state.get("stages") else None,
|
||||
"created_at": state.get("created_at"),
|
||||
})
|
||||
# Sort by task_id (which includes timestamp) descending
|
||||
tasks.sort(key=lambda x: x["task_id"], reverse=True)
|
||||
# Sort by created_at descending (most recent first)
|
||||
tasks.sort(key=lambda x: x.get("created_at") or "", reverse=True)
|
||||
return {"tasks": tasks, "total": len(tasks)}
|
||||
|
||||
|
||||
|
|
@ -343,14 +457,18 @@ async def cancel_task(task_id: str):
|
|||
task.cancel()
|
||||
app.state.task_results[task_id]["status"] = "failed"
|
||||
app.state.task_results[task_id]["error"] = "用户取消"
|
||||
_save_task_status(task_id, app.state.task_results[task_id])
|
||||
await broadcast_progress(task_id, app.state.task_results[task_id])
|
||||
|
||||
# Clean up temp script
|
||||
script_path = Path(f"/tmp/analysis_{task_id}.py")
|
||||
try:
|
||||
script_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
# Clean up temp script (may use tempfile.mkstemp with random suffix)
|
||||
for p in Path("/tmp").glob(f"analysis_{task_id}_*.py"):
|
||||
try:
|
||||
p.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove persisted task state
|
||||
_delete_task_status(task_id)
|
||||
|
||||
return {"task_id": task_id, "status": "cancelled"}
|
||||
|
||||
|
|
@ -430,7 +548,17 @@ def get_reports_list():
|
|||
|
||||
def get_report_content(ticker: str, date: str) -> Optional[dict]:
|
||||
"""Get report content for a specific ticker and date"""
|
||||
# Validate inputs to prevent path traversal
|
||||
if ".." in ticker or "/" in ticker or "\\" in ticker:
|
||||
return None
|
||||
if ".." in date or "/" in date or "\\" in date:
|
||||
return None
|
||||
report_dir = get_results_dir() / ticker / date
|
||||
# Strict traversal check: resolved path must be within get_results_dir()
|
||||
try:
|
||||
report_dir.resolve().relative_to(get_results_dir().resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
if not report_dir.exists():
|
||||
return None
|
||||
content = {}
|
||||
|
|
@ -458,6 +586,419 @@ async def get_report(ticker: str, date: str):
|
|||
return content
|
||||
|
||||
|
||||
# ============== Report Export ==============
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from fpdf import FPDF
|
||||
|
||||
|
||||
def _extract_decision(markdown_text: str) -> str:
|
||||
"""Extract BUY/SELL/HOLD from markdown bold text."""
|
||||
match = re.search(r'\*\*(BUY|SELL|HOLD)\*\*', markdown_text)
|
||||
return match.group(1) if match else 'UNKNOWN'
|
||||
|
||||
|
||||
def _extract_summary(markdown_text: str) -> str:
|
||||
"""Extract first ~200 chars after '## 分析摘要'."""
|
||||
match = re.search(r'## 分析摘要\s*\n+(.{0,300}?)(?=\n##|\Z)', markdown_text, re.DOTALL)
|
||||
if match:
|
||||
text = match.group(1).strip()
|
||||
# Strip markdown formatting
|
||||
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
|
||||
text = re.sub(r'\*(.*?)\*', r'\1', text)
|
||||
text = re.sub(r'[#\n]+', ' ', text)
|
||||
return text[:200].strip()
|
||||
return ''
|
||||
|
||||
|
||||
@app.get("/api/reports/export")
|
||||
async def export_reports_csv():
|
||||
"""Export all reports as CSV: ticker,date,decision,summary."""
|
||||
reports = get_reports_list()
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=["ticker", "date", "decision", "summary"])
|
||||
writer.writeheader()
|
||||
for r in reports:
|
||||
content = get_report_content(r["ticker"], r["date"])
|
||||
if content and content.get("report"):
|
||||
writer.writerow({
|
||||
"ticker": r["ticker"],
|
||||
"date": r["date"],
|
||||
"decision": _extract_decision(content["report"]),
|
||||
"summary": _extract_summary(content["report"]),
|
||||
})
|
||||
else:
|
||||
writer.writerow({
|
||||
"ticker": r["ticker"],
|
||||
"date": r["date"],
|
||||
"decision": "UNKNOWN",
|
||||
"summary": "",
|
||||
})
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=tradingagents_reports.csv"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/reports/{ticker}/{date}/pdf")
|
||||
async def export_report_pdf(ticker: str, date: str):
|
||||
"""Export a single report as PDF."""
|
||||
content = get_report_content(ticker, date)
|
||||
if not content or not content.get("report"):
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
markdown_text = content["report"]
|
||||
decision = _extract_decision(markdown_text)
|
||||
summary = _extract_summary(markdown_text)
|
||||
|
||||
pdf = FPDF()
|
||||
pdf.set_auto_page_break(auto=True, margin=20)
|
||||
|
||||
# Try multiple font paths for cross-platform support
|
||||
font_paths = [
|
||||
"/System/Library/Fonts/Supplemental/DejaVuSans.ttf",
|
||||
"/System/Library/Fonts/Supplemental/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
|
||||
str(Path.home() / ".local/share/fonts/DejaVuSans.ttf"),
|
||||
str(Path.home() / ".fonts/DejaVuSans.ttf"),
|
||||
]
|
||||
regular_font = None
|
||||
bold_font = None
|
||||
for p in font_paths:
|
||||
if Path(p).exists():
|
||||
if "Bold" in p and bold_font is None:
|
||||
bold_font = p
|
||||
elif regular_font is None and "Bold" not in p:
|
||||
regular_font = p
|
||||
|
||||
use_dejavu = bool(regular_font and bold_font)
|
||||
if use_dejavu:
|
||||
pdf.add_font("DejaVu", "", regular_font, unicode=True)
|
||||
pdf.add_font("DejaVu", "B", bold_font, unicode=True)
|
||||
font_regular = "DejaVu"
|
||||
font_bold = "DejaVu"
|
||||
else:
|
||||
font_regular = "Helvetica"
|
||||
font_bold = "Helvetica"
|
||||
|
||||
pdf.add_page()
|
||||
pdf.set_font(font_bold, "B", 18)
|
||||
pdf.cell(0, 12, f"TradingAgents 分析报告", ln=True, align="C")
|
||||
pdf.ln(5)
|
||||
|
||||
pdf.set_font(font_regular, "", 11)
|
||||
pdf.cell(0, 8, f"股票: {ticker} 日期: {date}", ln=True)
|
||||
pdf.ln(3)
|
||||
|
||||
# Decision badge
|
||||
pdf.set_font(font_bold, "B", 14)
|
||||
if decision == "BUY":
|
||||
pdf.set_text_color(34, 197, 94)
|
||||
elif decision == "SELL":
|
||||
pdf.set_text_color(220, 38, 38)
|
||||
else:
|
||||
pdf.set_text_color(245, 158, 11)
|
||||
pdf.cell(0, 10, f"决策: {decision}", ln=True)
|
||||
pdf.set_text_color(0, 0, 0)
|
||||
pdf.ln(5)
|
||||
|
||||
# Summary
|
||||
pdf.set_font(font_bold, "B", 12)
|
||||
pdf.cell(0, 8, "分析摘要", ln=True)
|
||||
pdf.set_font(font_regular, "", 10)
|
||||
pdf.multi_cell(0, 6, summary or "无")
|
||||
pdf.ln(5)
|
||||
|
||||
# Full report text (stripped of heavy markdown)
|
||||
pdf.set_font(font_bold, "B", 12)
|
||||
pdf.cell(0, 8, "完整报告", ln=True)
|
||||
pdf.set_font(font_regular, "", 9)
|
||||
# Split into lines, filter out very long lines
|
||||
for line in markdown_text.splitlines():
|
||||
line = re.sub(r'\*\*(.*?)\*\*', r'\1', line)
|
||||
line = re.sub(r'\*(.*?)\*', r'\1', line)
|
||||
line = re.sub(r'#{1,6} ', '', line)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
pdf.ln(2)
|
||||
continue
|
||||
if len(line) > 120:
|
||||
line = line[:120] + "..."
|
||||
try:
|
||||
pdf.multi_cell(0, 5, line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
content=pdf.output(),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={ticker}_{date}_report.pdf"},
|
||||
)
|
||||
|
||||
|
||||
# ============== 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():
|
||||
max_retries = MAX_RETRY_COUNT
|
||||
|
||||
async def run_single_analysis(ticker: str, stock: dict) -> tuple[bool, str, dict | None]:
|
||||
"""Run analysis for one ticker. Returns (success, decision, rec_or_error)."""
|
||||
last_error = None
|
||||
for attempt in range(max_retries + 1):
|
||||
script_path = None
|
||||
try:
|
||||
fd, script_path_str = tempfile.mkstemp(suffix=".py", prefix=f"analysis_{task_id}_{stock['_idx']}_")
|
||||
script_path = Path(script_path_str)
|
||||
os.chmod(script_path, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(ANALYSIS_SCRIPT_TEMPLATE)
|
||||
|
||||
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()
|
||||
rec = {
|
||||
"ticker": ticker,
|
||||
"name": stock.get("name", ticker),
|
||||
"analysis_date": date,
|
||||
"decision": decision,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
save_recommendation(date, ticker, rec)
|
||||
return True, decision, rec
|
||||
else:
|
||||
last_error = stderr.decode()[-500:] if stderr else f"exit {proc.returncode}"
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
finally:
|
||||
if script_path:
|
||||
try:
|
||||
script_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
if attempt < max_retries:
|
||||
await asyncio.sleep(RETRY_BASE_DELAY_SECS ** attempt) # exponential backoff: 1s, 2s
|
||||
|
||||
return False, "HOLD", None
|
||||
|
||||
try:
|
||||
for i, stock in enumerate(watchlist):
|
||||
stock["_idx"] = i # used in temp file name
|
||||
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])
|
||||
|
||||
success, decision, rec = await run_single_analysis(ticker, stock)
|
||||
if success:
|
||||
app.state.task_results[task_id]["completed"] = i + 1
|
||||
app.state.task_results[task_id]["results"].append(rec)
|
||||
else:
|
||||
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"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TradingAgents Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { useState, useEffect, lazy, Suspense } from 'react'
|
||||
import { Routes, Route, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
FundOutlined,
|
||||
MonitorOutlined,
|
||||
FileTextOutlined,
|
||||
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 }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
const currentPage = navItems.find(item =>
|
||||
item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.path)
|
||||
)?.label || 'TradingAgents'
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
{/* Sidebar - Apple Glass Navigation */}
|
||||
{!isMobile && (
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
{!collapsed && <span>TradingAgents</span>}
|
||||
{collapsed && <span style={{ fontSize: 12, letterSpacing: '0.1em' }}>TA</span>}
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`nav-item ${isActive ? 'active' : ''}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
aria-label={`${item.label} (按${item.key}切换)`}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: 'var(--space-2)' }}>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
className="sidebar-collapse-btn"
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
{!collapsed && <span>收起</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className={`main-content ${collapsed && !isMobile ? 'sidebar-collapsed' : ''}`}>
|
||||
{!isMobile && (
|
||||
<header className="topbar">
|
||||
<div className="topbar-title">{currentPage}</div>
|
||||
<div className="topbar-date">
|
||||
{new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="page-content">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile TabBar */}
|
||||
{isMobile && (
|
||||
<nav className="mobile-tabbar" aria-label="移动端导航">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`mobile-tab-item ${isActive ? 'active' : ''}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<span className="mobile-tab-icon">{item.icon}</span>
|
||||
<span className="mobile-tab-label">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
// Close modals on Escape
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelector('.ant-modal-wrap')?.click()
|
||||
return
|
||||
}
|
||||
// Navigation shortcuts
|
||||
switch (e.key) {
|
||||
case '1': navigate('/'); break
|
||||
case '2': navigate('/monitor'); break
|
||||
case '3': navigate('/reports'); break
|
||||
case '4': navigate('/batch'); break
|
||||
case '5': navigate('/portfolio'); break
|
||||
default: break
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense fallback={
|
||||
<div style={{ padding: 'var(--space-12)', textAlign: 'center' }}>
|
||||
<div className="loading-pulse">加载中...</div>
|
||||
</div>
|
||||
}>
|
||||
<Routes>
|
||||
<Route path="/" element={<ScreeningPanel />} />
|
||||
<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,879 @@
|
|||
/* TradingAgents Dashboard - Apple Design System */
|
||||
|
||||
:root {
|
||||
/* === Apple Color System === */
|
||||
/* Backgrounds */
|
||||
--color-black: #000000;
|
||||
--color-white: #ffffff;
|
||||
--color-light-gray: #f5f5f7;
|
||||
--color-near-black: #1d1d1f;
|
||||
|
||||
/* Interactive */
|
||||
--color-apple-blue: #0071e3;
|
||||
--color-link-blue: #0066cc;
|
||||
--color-link-blue-bright: #2997ff;
|
||||
|
||||
/* Text */
|
||||
--color-text-dark: rgba(0, 0, 0, 0.8);
|
||||
--color-text-secondary: rgba(0, 0, 0, 0.48);
|
||||
--color-text-white-80: rgba(255, 255, 255, 0.8);
|
||||
--color-text-white-48: rgba(255, 255, 255, 0.48);
|
||||
|
||||
/* Dark Surfaces */
|
||||
--color-dark-1: #272729;
|
||||
--color-dark-2: #262628;
|
||||
--color-dark-3: #28282a;
|
||||
--color-dark-4: #2a2a2d;
|
||||
--color-dark-5: #242426;
|
||||
|
||||
/* Buttons */
|
||||
--color-btn-active: #ededf2;
|
||||
--color-btn-light: #fafafc;
|
||||
--color-overlay: rgba(210, 210, 215, 0.64);
|
||||
--color-white-32: rgba(255, 255, 255, 0.32);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: rgba(0, 0, 0, 0.22) 3px 5px 30px 0px;
|
||||
|
||||
/* === Semantic Colors (kept for financial data) === */
|
||||
--color-buy: #22c55e;
|
||||
--color-sell: #dc2626;
|
||||
--color-hold: #f59e0b;
|
||||
--color-running: #a855f7;
|
||||
|
||||
/* === Spacing (Apple 8px base) === */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-7: 28px;
|
||||
--space-8: 32px;
|
||||
--space-9: 36px;
|
||||
--space-10: 40px;
|
||||
--space-11: 44px;
|
||||
--space-12: 48px;
|
||||
--space-14: 56px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* === Typography === */
|
||||
--font-display: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--font-text: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--font-data: 'DM Sans', 'JetBrains Mono', 'Menlo', monospace;
|
||||
|
||||
/* Apple type scale */
|
||||
--text-hero: 56px;
|
||||
--text-section: 40px;
|
||||
--text-tile: 28px;
|
||||
--text-card: 21px;
|
||||
--text-nav: 17px;
|
||||
--text-body: 17px;
|
||||
--text-button: 17px;
|
||||
--text-link: 14px;
|
||||
--text-caption: 12px;
|
||||
|
||||
/* === Border Radius === */
|
||||
--radius-micro: 5px;
|
||||
--radius-standard: 8px;
|
||||
--radius-comfortable: 11px;
|
||||
--radius-large: 12px;
|
||||
--radius-pill: 980px;
|
||||
--radius-circle: 50%;
|
||||
|
||||
/* === Transitions === */
|
||||
--transition-fast: 150ms ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-text);
|
||||
background-color: var(--color-light-gray);
|
||||
color: var(--color-near-black);
|
||||
line-height: 1.47;
|
||||
letter-spacing: -0.374px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: var(--radius-standard);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* === Layout === */
|
||||
.dashboard-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* === Sidebar (Apple Glass Nav) === */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transition: width var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: var(--space-4) var(--space-4);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--color-white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 48px;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-standard);
|
||||
color: var(--color-text-white-80);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-white-48);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
padding: var(--space-3) var(--space-3);
|
||||
border-radius: var(--radius-standard);
|
||||
transition: color var(--transition-fast);
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn:hover {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn:focus-visible {
|
||||
outline: 2px solid var(--color-apple-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Collapsed sidebar: hide button label, center icon */
|
||||
.sidebar.collapsed .sidebar-collapse-btn {
|
||||
justify-content: center;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.sidebar.collapsed .sidebar-collapse-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* === Main Content === */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
transition: margin-left var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar.collapsed ~ .main-content {
|
||||
margin-left: 64px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-6);
|
||||
background: var(--color-white);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-near-black);
|
||||
letter-spacing: -0.224px;
|
||||
}
|
||||
|
||||
.topbar-date {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* === Apple Cards === */
|
||||
.card {
|
||||
background: var(--color-white);
|
||||
border: none;
|
||||
border-radius: var(--radius-standard);
|
||||
padding: var(--space-6);
|
||||
box-shadow: none;
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.231px;
|
||||
line-height: 1.19;
|
||||
color: var(--color-near-black);
|
||||
}
|
||||
|
||||
/* === Apple Section === */
|
||||
.section-dark {
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.section-light {
|
||||
background: var(--color-light-gray);
|
||||
color: var(--color-near-black);
|
||||
}
|
||||
|
||||
.section-full {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* === Typography === */
|
||||
.text-hero {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-hero);
|
||||
font-weight: 600;
|
||||
line-height: 1.07;
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
.text-section-heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-section);
|
||||
font-weight: 600;
|
||||
line-height: 1.10;
|
||||
}
|
||||
|
||||
.text-tile-heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-tile);
|
||||
font-weight: 400;
|
||||
line-height: 1.14;
|
||||
letter-spacing: 0.196px;
|
||||
}
|
||||
|
||||
.text-card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-card);
|
||||
font-weight: 700;
|
||||
line-height: 1.19;
|
||||
letter-spacing: 0.231px;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
font-family: var(--font-text);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 400;
|
||||
line-height: 1.47;
|
||||
letter-spacing: -0.374px;
|
||||
}
|
||||
|
||||
.text-emphasis {
|
||||
font-family: var(--font-text);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
line-height: 1.24;
|
||||
letter-spacing: -0.374px;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
font-family: var(--font-text);
|
||||
font-size: var(--text-link);
|
||||
font-weight: 400;
|
||||
line-height: 1.43;
|
||||
letter-spacing: -0.224px;
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-family: var(--font-text);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 400;
|
||||
line-height: 1.29;
|
||||
letter-spacing: -0.224px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.text-data {
|
||||
font-family: var(--font-data);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* === Apple Buttons === */
|
||||
.btn-primary {
|
||||
background: var(--color-apple-blue);
|
||||
color: var(--color-white);
|
||||
border: none;
|
||||
border-radius: var(--radius-standard);
|
||||
padding: 8px 15px;
|
||||
font-family: var(--font-text);
|
||||
font-size: var(--text-button);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0077ED;
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
background: var(--color-btn-active);
|
||||
}
|
||||
|
||||
.btn-primary:focus-visible {
|
||||
outline: 2px solid var(--color-apple-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-near-black);
|
||||
color: var(--color-white);
|
||||
border: none;
|
||||
border-radius: var(--radius-standard);
|
||||
padding: 8px 15px;
|
||||
font-family: var(--font-text);
|
||||
font-size: var(--text-button);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
background: var(--color-dark-1);
|
||||
}
|
||||
|
||||
.btn-secondary:focus-visible {
|
||||
outline: 2px solid var(--color-apple-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-link-blue);
|
||||
border: 1px solid var(--color-link-blue);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 6px 14px;
|
||||
font-family: var(--font-text);
|
||||
font-size: var(--text-link);
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-ghost:focus-visible {
|
||||
outline: 2px solid var(--color-apple-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-filter {
|
||||
background: var(--color-btn-light);
|
||||
color: var(--color-text-dark);
|
||||
border: none;
|
||||
border-radius: var(--radius-comfortable);
|
||||
padding: 0px 14px;
|
||||
height: 32px;
|
||||
font-family: var(--font-text);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-filter:hover {
|
||||
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.btn-filter:active {
|
||||
background: var(--color-btn-active);
|
||||
}
|
||||
|
||||
.btn-filter:focus-visible {
|
||||
outline: 2px solid var(--color-apple-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* === Decision Badges === */
|
||||
.badge-buy {
|
||||
background: var(--color-buy);
|
||||
color: var(--color-white);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-sell {
|
||||
background: var(--color-sell);
|
||||
color: var(--color-white);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-hold {
|
||||
background: var(--color-hold);
|
||||
color: var(--color-white);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-running {
|
||||
background: var(--color-running);
|
||||
color: var(--color-white);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === Stage Pills === */
|
||||
.stage-pill {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-standard);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.stage-pill.completed {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--color-buy);
|
||||
}
|
||||
|
||||
.stage-pill.running {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--color-running);
|
||||
}
|
||||
|
||||
.stage-pill.pending {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stage-pill.failed {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
color: var(--color-sell);
|
||||
}
|
||||
|
||||
/* === Empty States === */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-16);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.231px;
|
||||
color: var(--color-near-black);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* === Progress Bar === */
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-apple-blue);
|
||||
border-radius: 2px;
|
||||
transition: width 300ms ease-out;
|
||||
}
|
||||
|
||||
/* === Status Dot === */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--color-buy);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--color-sell);
|
||||
}
|
||||
|
||||
/* === Data Table === */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-family: var(--font-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: left;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
letter-spacing: 0.024px;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
font-size: 14px;
|
||||
color: var(--color-near-black);
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.data-table .numeric {
|
||||
font-family: var(--font-data);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* === Loading Pulse === */
|
||||
@keyframes apple-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.loading-pulse {
|
||||
animation: apple-pulse 2s ease-in-out infinite;
|
||||
color: var(--color-apple-blue);
|
||||
}
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 64px;
|
||||
}
|
||||
.sidebar-logo span,
|
||||
.nav-item span,
|
||||
.sidebar-collapse-btn span:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
.topbar {
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
.page-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Ant Design Overrides === */
|
||||
.ant-table {
|
||||
background: transparent !important;
|
||||
font-family: var(--font-text) !important;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
color: var(--color-text-secondary) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.024px !important;
|
||||
padding: var(--space-3) var(--space-4) !important;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
padding: var(--space-4) !important;
|
||||
color: var(--color-near-black) !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: rgba(0, 0, 0, 0.02) !important;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: var(--radius-comfortable) !important;
|
||||
background: var(--color-btn-light) !important;
|
||||
border: none !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important;
|
||||
font-family: var(--font-text) !important;
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
border-radius: var(--radius-standard) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
}
|
||||
|
||||
.ant-popover-inner {
|
||||
border-radius: var(--radius-standard) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
}
|
||||
|
||||
.ant-popover-title {
|
||||
font-family: var(--font-display) !important;
|
||||
font-weight: 600 !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary {
|
||||
background: var(--color-apple-blue) !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-standard) !important;
|
||||
font-family: var(--font-text) !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 400 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover {
|
||||
background: #0077ED !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary:active {
|
||||
background: var(--color-btn-active) !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary:focus-visible {
|
||||
outline: 2px solid var(--color-apple-blue) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
.ant-btn-default {
|
||||
border-radius: var(--radius-standard) !important;
|
||||
border: none !important;
|
||||
background: var(--color-btn-light) !important;
|
||||
color: var(--color-text-dark) !important;
|
||||
font-family: var(--font-text) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important;
|
||||
}
|
||||
|
||||
.ant-btn-default:hover {
|
||||
background: var(--color-btn-active) !important;
|
||||
}
|
||||
|
||||
.ant-skeleton {
|
||||
padding: var(--space-4) !important;
|
||||
}
|
||||
|
||||
.ant-result-title {
|
||||
font-family: var(--font-display) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.ant-statistic-title {
|
||||
font-family: var(--font-text) !important;
|
||||
font-size: 12px !important;
|
||||
color: var(--color-text-secondary) !important;
|
||||
letter-spacing: 0.024px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-family: var(--font-data) !important;
|
||||
font-size: 28px !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--color-near-black) !important;
|
||||
}
|
||||
|
||||
.ant-progress-inner {
|
||||
background: rgba(0, 0, 0, 0.08) !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
.ant-progress-bg {
|
||||
background: var(--color-apple-blue) !important;
|
||||
}
|
||||
|
||||
.ant-tag {
|
||||
border-radius: var(--radius-pill) !important;
|
||||
font-family: var(--font-text) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-input-number {
|
||||
border-radius: var(--radius-comfortable) !important;
|
||||
border: none !important;
|
||||
background: var(--color-btn-light) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04) !important;
|
||||
font-family: var(--font-text) !important;
|
||||
}
|
||||
|
||||
.ant-input-number-input {
|
||||
font-family: var(--font-text) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
font-family: var(--font-text) !important;
|
||||
font-size: 14px !important;
|
||||
color: var(--color-text-secondary) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--color-near-black) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ConfigProvider, theme } from 'antd'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
// Apple Design System Ant Design configuration
|
||||
const appleTheme = {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#0071e3',
|
||||
colorSuccess: '#22c55e',
|
||||
colorError: '#dc2626',
|
||||
colorWarning: '#f59e0b',
|
||||
colorInfo: '#0071e3',
|
||||
colorBgBase: '#ffffff',
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgElevated: '#f5f5f7',
|
||||
colorBorder: 'rgba(0, 0, 0, 0.08)',
|
||||
colorText: '#1d1d1f',
|
||||
colorTextSecondary: 'rgba(0, 0, 0, 0.48)',
|
||||
borderRadius: 8,
|
||||
fontFamily: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif',
|
||||
wireframe: false,
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
Select: {
|
||||
borderRadius: 11,
|
||||
},
|
||||
Table: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider theme={appleTheme}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Card, Progress, Timeline, Badge, Empty, Button, Tag, Result, message } from 'antd'
|
||||
import { Card, Progress, Badge, Empty, Button, Result, message } from 'antd'
|
||||
import { CheckCircleOutlined, SyncOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||
|
||||
const ANALYSIS_STAGES = [
|
||||
{ key: 'analysts', label: '分析师团队', description: 'Market / Social / News / Fundamentals' },
|
||||
{ key: 'research', label: '研究员辩论', description: 'Bull vs Bear Researcher debate' },
|
||||
{ key: 'trader', label: '交易员', description: 'Compose investment plan' },
|
||||
{ key: 'risk', label: '风险管理', description: 'Aggressive vs Conservative vs Neutral' },
|
||||
{ key: 'portfolio', label: '组合经理', description: 'Final BUY/HOLD/SELL decision' },
|
||||
{ key: 'analysts', label: '分析师团队' },
|
||||
{ key: 'research', label: '研究员辩论' },
|
||||
{ key: 'trading', label: '交易员' },
|
||||
{ key: 'risk', label: '风险管理' },
|
||||
{ key: 'portfolio', label: '组合经理' },
|
||||
]
|
||||
|
||||
export default function AnalysisMonitor() {
|
||||
|
|
@ -21,6 +21,7 @@ export default function AnalysisMonitor() {
|
|||
const wsRef = useRef(null)
|
||||
|
||||
const fetchInitialState = useCallback(async () => {
|
||||
if (!taskId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/analysis/status/${taskId}`)
|
||||
|
|
@ -53,7 +54,7 @@ export default function AnalysisMonitor() {
|
|||
setTask(taskData)
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,38 +85,46 @@ export default function AnalysisMonitor() {
|
|||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getStageStatusIcon = (status) => {
|
||||
const getStageIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircleOutlined style={{ color: 'var(--color-buy)' }} />
|
||||
return <CheckCircleOutlined style={{ color: 'var(--color-buy)', fontSize: 16 }} />
|
||||
case 'running':
|
||||
return <SyncOutlined spin style={{ color: 'var(--color-running)' }} />
|
||||
return <SyncOutlined spin style={{ color: 'var(--color-running)', fontSize: 16 }} />
|
||||
case 'failed':
|
||||
return <CloseCircleOutlined style={{ color: 'var(--color-sell)' }} />
|
||||
return <CloseCircleOutlined style={{ color: 'var(--color-sell)', fontSize: 16 }} />
|
||||
default:
|
||||
return <Badge status="default" />
|
||||
return <span style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid rgba(0,0,0,0.12)', display: 'inline-block' }} />
|
||||
}
|
||||
}
|
||||
|
||||
const getDecisionBadge = (decision) => {
|
||||
if (!decision) return null
|
||||
const colorMap = {
|
||||
BUY: 'var(--color-buy)',
|
||||
SELL: 'var(--color-sell)',
|
||||
HOLD: 'var(--color-hold)',
|
||||
}
|
||||
const badgeClass = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
|
||||
return <span className={badgeClass}>{decision}</span>
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<Tag
|
||||
color={colorMap[decision]}
|
||||
style={{
|
||||
fontFamily: 'var(--font-data)',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
padding: '4px 12px',
|
||||
}}
|
||||
>
|
||||
{decision}
|
||||
</Tag>
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无分析任务</div>
|
||||
<div className="empty-state-description">
|
||||
在股票筛选页面选择股票并点击"分析"开始
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ marginTop: 'var(--space-4)' }}
|
||||
onClick={() => window.location.href = '/'}
|
||||
>
|
||||
去筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -127,21 +136,25 @@ export default function AnalysisMonitor() {
|
|||
style={{ marginBottom: 'var(--space-6)' }}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span>当前分析任务</span>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 17, fontWeight: 600 }}>
|
||||
当前分析任务
|
||||
</span>
|
||||
<Badge
|
||||
status={error ? 'error' : wsConnected ? 'success' : 'error'}
|
||||
text={error ? '错误' : wsConnected ? '实时连接' : '未连接'}
|
||||
status={error ? 'error' : wsConnected ? 'success' : 'default'}
|
||||
text={
|
||||
<span style={{ fontSize: 12, color: error ? 'var(--color-sell)' : wsConnected ? 'var(--color-buy)' : 'rgba(0,0,0,0.48)' }}>
|
||||
{error ? '错误' : wsConnected ? '实时连接' : '连接中'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-12)' }}>
|
||||
<div className="loading-pulse" style={{ color: 'var(--color-running)', fontSize: 16 }}>
|
||||
连接中...
|
||||
</div>
|
||||
<div className="loading-pulse" style={{ fontSize: 16 }}>连接中...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
) : error && !task ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="连接失败"
|
||||
|
|
@ -153,7 +166,6 @@ export default function AnalysisMonitor() {
|
|||
fetchInitialState()
|
||||
connectWebSocket()
|
||||
}}
|
||||
aria-label="重新连接"
|
||||
>
|
||||
重新连接
|
||||
</Button>
|
||||
|
|
@ -164,128 +176,75 @@ export default function AnalysisMonitor() {
|
|||
{/* Task Header */}
|
||||
<div style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 24, fontWeight: 600 }}>{task.name}</span>
|
||||
<span style={{ fontFamily: 'var(--font-data)', color: 'var(--color-text-muted)' }}>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 28, fontWeight: 600, letterSpacing: 0.196, lineHeight: 1.14 }}>
|
||||
{task.ticker}
|
||||
</span>
|
||||
{getDecisionBadge(task.decision)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Progress
|
||||
percent={task.progress}
|
||||
status="active"
|
||||
strokeColor="var(--color-buy)"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-data)',
|
||||
color: 'var(--color-text-muted)',
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
{formatTime(task.elapsed)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<div className="progress-bar" style={{ flex: 1, height: 6 }}>
|
||||
<div className="progress-bar-fill" style={{ width: `${task.progress || 0}%` }} />
|
||||
</div>
|
||||
<span className="text-data" style={{ minWidth: 50, textAlign: 'right' }}>
|
||||
{task.progress || 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stages */}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 24 }}>
|
||||
{ANALYSIS_STAGES.map((stage, index) => (
|
||||
<div
|
||||
key={stage.key}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background:
|
||||
task.stages[index]?.status === 'running'
|
||||
? 'rgba(168, 85, 247, 0.15)'
|
||||
: task.stages[index]?.status === 'completed'
|
||||
? 'rgba(34, 197, 94, 0.15)'
|
||||
: 'var(--color-surface-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: `1px solid ${
|
||||
task.stages[index]?.status === 'running'
|
||||
? 'var(--color-running)'
|
||||
: task.stages[index]?.status === 'completed'
|
||||
? 'var(--color-buy)'
|
||||
: 'var(--color-border)'
|
||||
}`,
|
||||
opacity: task.stages[index]?.status === 'pending' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{getStageStatusIcon(task.stages[index]?.status)}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap', marginBottom: 'var(--space-6)' }}>
|
||||
{ANALYSIS_STAGES.map((stage, index) => {
|
||||
const stageState = task.stages?.[index]
|
||||
const status = stageState?.status || 'pending'
|
||||
return (
|
||||
<div key={stage.key} className={`stage-pill ${status}`}>
|
||||
{getStageIcon(status)}
|
||||
<span>{stage.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<div className="text-caption" style={{ marginBottom: 12, textTransform: 'uppercase', fontWeight: 600 }}>
|
||||
实时日志
|
||||
</div>
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{
|
||||
fontFamily: 'var(--font-data)',
|
||||
fontSize: 12,
|
||||
background: 'var(--color-bg)',
|
||||
background: 'rgba(0,0,0,0.03)',
|
||||
padding: 'var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
maxHeight: 300,
|
||||
borderRadius: 'var(--radius-standard)',
|
||||
maxHeight: 280,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{task.logs.map((log, i) => (
|
||||
<div key={i} style={{ marginBottom: 8 }}>
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>[{log.time}]</span>{' '}
|
||||
<span style={{ color: 'var(--color-interactive)' }}>{log.stage}:</span>{' '}
|
||||
<span>{log.message}</span>
|
||||
{task.logs?.length > 0 ? (
|
||||
task.logs.map((log, i) => (
|
||||
<div key={i} style={{ marginBottom: 8, lineHeight: 1.4 }}>
|
||||
<span style={{ color: 'rgba(0,0,0,0.48)' }}>[{log.time}]</span>{' '}
|
||||
<span style={{ fontWeight: 500 }}>{log.stage}:</span>{' '}
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: 'rgba(0,0,0,0.48)', textAlign: 'center', padding: 'var(--space-4)' }}>
|
||||
等待日志输出...
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty description="暂无进行中的分析任务" image={
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 48, height: 48 }}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
} />
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-title">暂无任务数据</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* No Active Task */}
|
||||
{!task && (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无进行中的分析</div>
|
||||
<div className="empty-state-description">
|
||||
在股票筛选页面选择股票并点击"分析"开始
|
||||
</div>
|
||||
<Button type="primary" style={{ marginTop: 16 }} aria-label="去筛选股票">
|
||||
去筛选股票
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Table, Button, Tag, Progress, Result, Empty, Tabs, InputNumber, Card, Skeleton, message } from 'antd'
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Table, Button, Progress, Result, Empty, Card, message, Popconfirm, Tooltip } from 'antd'
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, SyncOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
|
||||
const MAX_CONCURRENT = 3
|
||||
|
||||
export default function BatchManager() {
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(MAX_CONCURRENT)
|
||||
const [maxConcurrent] = useState(MAX_CONCURRENT)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
|
|
@ -66,90 +59,83 @@ export default function BatchManager() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleCopyTaskId = (taskId) => {
|
||||
navigator.clipboard.writeText(taskId).then(() => {
|
||||
message.success('已复制任务ID')
|
||||
}).catch(() => {
|
||||
message.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircleOutlined style={{ color: 'var(--color-buy)' }} />
|
||||
case 'running':
|
||||
return <SyncOutlined spin style={{ color: 'var(--color-running)' }} />
|
||||
return <CheckCircleOutlined style={{ color: 'var(--color-buy)', fontSize: 16 }} />
|
||||
case 'failed':
|
||||
return <CloseCircleOutlined style={{ color: 'var(--color-sell)' }} />
|
||||
return <CloseCircleOutlined style={{ color: 'var(--color-sell)', fontSize: 16 }} />
|
||||
case 'running':
|
||||
return <SyncOutlined spin style={{ color: 'var(--color-running)', fontSize: 16 }} />
|
||||
default:
|
||||
return <PauseCircleOutlined style={{ color: 'var(--color-hold)' }} />
|
||||
return <span style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid rgba(0,0,0,0.2)', display: 'inline-block' }} />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusTag = (status) => {
|
||||
const map = {
|
||||
pending: { text: '等待', bg: 'rgba(0,0,0,0.06)', color: 'rgba(0,0,0,0.48)' },
|
||||
running: { text: '分析中', bg: 'rgba(168,85,247,0.12)', color: 'var(--color-running)' },
|
||||
completed: { text: '完成', bg: 'rgba(34,197,94,0.12)', color: 'var(--color-buy)' },
|
||||
failed: { text: '失败', bg: 'rgba(220,38,38,0.12)', color: 'var(--color-sell)' },
|
||||
}
|
||||
const s = map[status] || map.pending
|
||||
return (
|
||||
<span style={{ background: s.bg, color: s.color, padding: '2px 10px', borderRadius: 'var(--radius-pill)', fontSize: 12, fontWeight: 600 }}>
|
||||
{s.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getDecisionBadge = (decision) => {
|
||||
if (!decision) return null
|
||||
const colorMap = {
|
||||
BUY: 'var(--color-buy)',
|
||||
SELL: 'var(--color-sell)',
|
||||
HOLD: 'var(--color-hold)',
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
color={colorMap[decision]}
|
||||
style={{ fontFamily: 'var(--font-data)', fontWeight: 600 }}
|
||||
>
|
||||
{decision}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusTag = (task) => {
|
||||
const statusMap = {
|
||||
pending: { text: '等待', color: 'var(--color-hold)' },
|
||||
running: { text: '分析中', color: 'var(--color-running)' },
|
||||
completed: { text: '完成', color: 'var(--color-buy)' },
|
||||
failed: { text: '失败', color: 'var(--color-sell)' },
|
||||
}
|
||||
const s = statusMap[task.status]
|
||||
return (
|
||||
<Tag style={{ background: `${s.color}20`, color: s.color, border: 'none' }}>
|
||||
{s.text}
|
||||
</Tag>
|
||||
)
|
||||
const cls = decision === 'BUY' ? 'badge-buy' : decision === 'SELL' ? 'badge-sell' : 'badge-hold'
|
||||
return <span className={cls}>{decision}</span>
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
width: 110,
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{getStatusIcon(record.status)}
|
||||
{getStatusTag(record)}
|
||||
{getStatusTag(record.status)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '股票',
|
||||
key: 'stock',
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{record.ticker}</div>
|
||||
</div>
|
||||
dataIndex: 'ticker',
|
||||
key: 'ticker',
|
||||
render: (text) => (
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 15 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 150,
|
||||
width: 140,
|
||||
render: (val, record) =>
|
||||
record.status === 'running' || record.status === 'pending' ? (
|
||||
<Progress
|
||||
percent={val}
|
||||
percent={val || 0}
|
||||
size="small"
|
||||
strokeColor={
|
||||
record.status === 'pending'
|
||||
? 'var(--color-hold)'
|
||||
: 'var(--color-running)'
|
||||
}
|
||||
strokeColor="var(--color-apple-blue)"
|
||||
trailColor="rgba(0,0,0,0.08)"
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>{val}%</span>
|
||||
<span className="text-data">{val || 0}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -157,50 +143,61 @@ export default function BatchManager() {
|
|||
dataIndex: 'decision',
|
||||
key: 'decision',
|
||||
width: 80,
|
||||
render: (decision) => getDecisionBadge(decision),
|
||||
render: getDecisionBadge,
|
||||
},
|
||||
{
|
||||
title: '任务ID',
|
||||
dataIndex: 'task_id',
|
||||
key: 'task_id',
|
||||
width: 200,
|
||||
width: 220,
|
||||
render: (text) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)', fontSize: 12, color: 'var(--color-text-muted)' }}>{text}</span>
|
||||
<Tooltip title={text} placement="topLeft">
|
||||
<span className="text-data" style={{ fontSize: 11, color: 'rgba(0,0,0,0.48)', cursor: 'default' }}>
|
||||
{text.slice(0, 18)}...
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleCopyTaskId(text) }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'rgba(0,0,0,0.48)', display: 'inline-flex', alignItems: 'center' }}
|
||||
title="复制任务ID"
|
||||
>
|
||||
<CopyOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '错误',
|
||||
dataIndex: 'error',
|
||||
key: 'error',
|
||||
width: 180,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (error) =>
|
||||
error ? (
|
||||
<span style={{ color: 'var(--color-sell)', fontSize: 12 }}>{error}</span>
|
||||
<Tooltip title={error} placement="topLeft">
|
||||
<span style={{ color: 'var(--color-sell)', fontSize: 12, display: 'block' }}>{error}</span>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{record.status === 'running' && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => handleCancel(record.task_id)}
|
||||
aria-label="取消"
|
||||
<Popconfirm
|
||||
title="确认取消此任务?"
|
||||
onConfirm={() => handleCancel(record.task_id)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
取消
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{record.status === 'failed' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => handleRetry(record.task_id)}
|
||||
aria-label="重试"
|
||||
>
|
||||
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRetry(record.task_id)}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -209,98 +206,77 @@ export default function BatchManager() {
|
|||
},
|
||||
]
|
||||
|
||||
const pendingCount = tasks.filter((t) => t.status === 'pending').length
|
||||
const runningCount = tasks.filter((t) => t.status === 'running').length
|
||||
const completedCount = tasks.filter((t) => t.status === 'completed').length
|
||||
const failedCount = tasks.filter((t) => t.status === 'failed').length
|
||||
const pendingCount = tasks.filter(t => t.status === 'pending').length
|
||||
const runningCount = tasks.filter(t => t.status === 'running').length
|
||||
const completedCount = tasks.filter(t => t.status === 'completed').length
|
||||
const failedCount = tasks.filter(t => t.status === 'failed').length
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 'var(--space-6)' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-4)', marginBottom: 'var(--space-6)' }}>
|
||||
<Card size="small" className="card" style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600 }}>
|
||||
{pendingCount}
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>等待中</div>
|
||||
<div className="text-data" style={{ fontSize: 32, fontWeight: 600 }}>{pendingCount}</div>
|
||||
<div className="text-caption">等待中</div>
|
||||
</Card>
|
||||
<Card size="small" className="card" style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-running)' }}>
|
||||
{runningCount}
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>分析中</div>
|
||||
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-running)' }}>{runningCount}</div>
|
||||
<div className="text-caption">分析中</div>
|
||||
</Card>
|
||||
<Card size="small" className="card" style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-buy)' }}>
|
||||
{completedCount}
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>已完成</div>
|
||||
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-buy)' }}>{completedCount}</div>
|
||||
<div className="text-caption">已完成</div>
|
||||
</Card>
|
||||
<Card size="small" className="card" style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-data)', fontSize: 24, fontWeight: 600, color: 'var(--color-sell)' }}>
|
||||
{failedCount}
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>失败</div>
|
||||
<div className="text-data" style={{ fontSize: 32, fontWeight: 600, color: 'var(--color-sell)' }}>{failedCount}</div>
|
||||
<div className="text-caption">失败</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<Card size="small" className="card" style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<span>最大并发数:</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxConcurrent}
|
||||
onChange={(val) => setMaxConcurrent(val)}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>
|
||||
同时运行的分析任务数量
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tasks Table */}
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<Skeleton active rows={5} />
|
||||
) : error ? (
|
||||
{loading && tasks.length === 0 ? (
|
||||
<div style={{ padding: 'var(--space-8)', textAlign: 'center' }}>
|
||||
<div className="loading-pulse">加载中...</div>
|
||||
</div>
|
||||
) : error && tasks.length === 0 ? (
|
||||
<Result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
description="点击重试按钮重新加载数据"
|
||||
subTitle={error}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
fetchTasks()
|
||||
}}
|
||||
aria-label="重试"
|
||||
>
|
||||
<Button type="primary" onClick={fetchTasks}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : tasks.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无批量任务"
|
||||
image={
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 48, height: 48 }}>
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无批量任务</div>
|
||||
<div className="empty-state-description">
|
||||
在股票筛选页面提交分析任务
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ marginTop: 'var(--space-4)' }}
|
||||
onClick={() => window.location.href = '/'}
|
||||
>
|
||||
去筛选
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
rowKey="task_id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,225 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Table, Input, Modal, Skeleton, Button, Space, message } from 'antd'
|
||||
import { FileTextOutlined, SearchOutlined, CloseOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
const { Search } = Input
|
||||
|
||||
export default function ReportsViewer() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [reports, setReports] = useState([])
|
||||
const [selectedReport, setSelectedReport] = useState(null)
|
||||
const [reportContent, setReportContent] = useState(null)
|
||||
const [loadingContent, setLoadingContent] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports()
|
||||
}, [])
|
||||
|
||||
const fetchReports = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/reports/list')
|
||||
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
|
||||
const data = await res.json()
|
||||
setReports(data)
|
||||
} catch {
|
||||
setReports([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportCsv = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/reports/export')
|
||||
if (!res.ok) throw new Error('导出失败')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = 'tradingagents_reports.csv'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPdf = async (ticker, date) => {
|
||||
try {
|
||||
const res = await fetch(`/api/reports/${ticker}/${date}/pdf`)
|
||||
if (!res.ok) throw new Error('导出失败')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = `${ticker}_${date}_report.pdf`; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewReport = async (record) => {
|
||||
setSelectedReport(record)
|
||||
setLoadingContent(true)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/${record.ticker}/${record.date}`)
|
||||
if (!res.ok) throw new Error(`加载失败: ${res.status}`)
|
||||
const data = await res.json()
|
||||
setReportContent(data)
|
||||
} catch (err) {
|
||||
setReportContent({ report: `# 加载失败\n\n无法加载报告: ${err.message}` })
|
||||
} finally {
|
||||
setLoadingContent(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredReports = reports.filter(
|
||||
(r) =>
|
||||
r.ticker.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
r.date.includes(searchText)
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '代码',
|
||||
dataIndex: 'ticker',
|
||||
key: 'ticker',
|
||||
width: 120,
|
||||
render: (text) => (
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 15 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
width: 120,
|
||||
render: (text) => (
|
||||
<span className="text-data">{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FileTextOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleViewReport(record)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Search + Export */}
|
||||
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Search
|
||||
placeholder="搜索股票代码或日期..."
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.48)' }} />}
|
||||
size="large"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExportCsv} disabled={reports.length === 0}>
|
||||
导出CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reports Table */}
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div style={{ padding: 'var(--space-8)' }}>
|
||||
<Skeleton active rows={5} />
|
||||
</div>
|
||||
) : filteredReports.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
<div className="empty-state-title">暂无历史报告</div>
|
||||
<div className="empty-state-description">
|
||||
在股票筛选页面提交分析任务后,报告将显示在这里
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredReports}
|
||||
rowKey={(r) => `${r.ticker}-${r.date}`}
|
||||
pagination={{ pageSize: 10 }}
|
||||
size="middle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Report Modal */}
|
||||
<Modal
|
||||
title={
|
||||
selectedReport ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontSize: 17, fontWeight: 600 }}>
|
||||
{selectedReport.ticker}
|
||||
</span>
|
||||
<span style={{ color: 'rgba(0,0,0,0.48)', fontSize: 14 }}>{selectedReport.date}</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
open={!!selectedReport}
|
||||
onCancel={() => {
|
||||
setSelectedReport(null)
|
||||
setReportContent(null)
|
||||
}}
|
||||
footer={
|
||||
selectedReport ? (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => handleExportPdf(selectedReport.ticker, selectedReport.date)}
|
||||
>
|
||||
导出PDF
|
||||
</Button>
|
||||
<Button onClick={() => { setSelectedReport(null); setReportContent(null) }}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
width={800}
|
||||
closeIcon={<CloseOutlined style={{ fontSize: 16 }} />}
|
||||
styles={{
|
||||
wrapper: { maxWidth: '95vw' },
|
||||
body: { maxHeight: '70vh', overflow: 'auto', padding: 'var(--space-6)' },
|
||||
header: { padding: 'var(--space-4) var(--space-6)', borderBottom: '1px solid rgba(0,0,0,0.08)' },
|
||||
}}
|
||||
>
|
||||
{loadingContent ? (
|
||||
<div style={{ padding: 'var(--space-8)' }}>
|
||||
<Skeleton active />
|
||||
</div>
|
||||
) : reportContent ? (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-text)',
|
||||
lineHeight: 1.8,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown>{reportContent.report || 'No content'}</ReactMarkdown>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Table, Button, Select, Input, Space, Statistic, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd'
|
||||
import { Table, Button, Select, Space, Row, Col, Skeleton, Result, message, Popconfirm, Tooltip } from 'antd'
|
||||
import { PlayCircleOutlined, ReloadOutlined, QuestionCircleOutlined } from '@ant-design/icons'
|
||||
|
||||
const SCREEN_MODES = [
|
||||
|
|
@ -15,7 +15,6 @@ export default function ScreeningPanel() {
|
|||
const navigate = useNavigate()
|
||||
const [mode, setMode] = useState('china_strict')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [screening, setScreening] = useState(false)
|
||||
const [results, setResults] = useState([])
|
||||
const [stats, setStats] = useState({ total: 0, passed: 0 })
|
||||
const [error, setError] = useState(null)
|
||||
|
|
@ -41,6 +40,22 @@ export default function ScreeningPanel() {
|
|||
fetchResults()
|
||||
}, [mode])
|
||||
|
||||
const handleStartAnalysis = async (stock) => {
|
||||
try {
|
||||
const res = await fetch('/api/analysis/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ticker: stock.ticker }),
|
||||
})
|
||||
if (!res.ok) throw new Error('启动分析失败')
|
||||
const data = await res.json()
|
||||
message.success(`已提交分析任务: ${stock.name} (${stock.ticker})`)
|
||||
navigate(`/monitor?task_id=${data.task_id}`)
|
||||
} catch (err) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '代码',
|
||||
|
|
@ -48,7 +63,7 @@ export default function ScreeningPanel() {
|
|||
key: 'ticker',
|
||||
width: 120,
|
||||
render: (text) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>{text}</span>
|
||||
<span className="text-data">{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -56,18 +71,24 @@ export default function ScreeningPanel() {
|
|||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120,
|
||||
render: (text) => (
|
||||
<span style={{ fontWeight: 500 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="营业收入同比增长率">
|
||||
<span>营收增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
营收增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'revenue_growth',
|
||||
key: 'revenue_growth',
|
||||
align: 'right',
|
||||
width: 100,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
<span className="text-data" style={{ color: val > 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
|
||||
{val?.toFixed(1)}%
|
||||
</span>
|
||||
),
|
||||
|
|
@ -75,14 +96,17 @@ export default function ScreeningPanel() {
|
|||
{
|
||||
title: (
|
||||
<Tooltip title="净利润同比增长率">
|
||||
<span>利润增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
利润增速 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'profit_growth',
|
||||
key: 'profit_growth',
|
||||
align: 'right',
|
||||
width: 100,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
<span className="text-data" style={{ color: val > 0 ? 'var(--color-buy)' : 'var(--color-sell)' }}>
|
||||
{val?.toFixed(1)}%
|
||||
</span>
|
||||
),
|
||||
|
|
@ -90,16 +114,17 @@ export default function ScreeningPanel() {
|
|||
{
|
||||
title: (
|
||||
<Tooltip title="净资产收益率 = 净利润/净资产">
|
||||
<span>ROE <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
ROE <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'roe',
|
||||
key: 'roe',
|
||||
align: 'right',
|
||||
width: 80,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
{val?.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-data">{val?.toFixed(1)}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -107,31 +132,31 @@ export default function ScreeningPanel() {
|
|||
dataIndex: 'current_price',
|
||||
key: 'current_price',
|
||||
align: 'right',
|
||||
width: 100,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
¥{val?.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-data">¥{val?.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="当前成交量/过去20日平均成交量">
|
||||
<span>Vol比 <QuestionCircleOutlined style={{ fontSize: 10, color: 'var(--color-text-muted)' }} /></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
Vol比 <QuestionCircleOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.48)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'vol_ratio',
|
||||
key: 'vol_ratio',
|
||||
align: 'right',
|
||||
width: 80,
|
||||
render: (val) => (
|
||||
<span style={{ fontFamily: 'var(--font-data)' }}>
|
||||
{val?.toFixed(2)}x
|
||||
</span>
|
||||
<span className="text-data">{val?.toFixed(2)}x</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={`确认分析 ${record.name} (${record.ticker})?`}
|
||||
|
|
@ -152,53 +177,28 @@ export default function ScreeningPanel() {
|
|||
},
|
||||
]
|
||||
|
||||
const handleStartAnalysis = async (stock) => {
|
||||
try {
|
||||
const res = await fetch('/api/analysis/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ticker: stock.ticker }),
|
||||
})
|
||||
if (!res.ok) throw new Error('启动分析失败')
|
||||
const data = await res.json()
|
||||
message.success(`已提交分析任务: ${stock.name} (${stock.ticker})`)
|
||||
navigate(`/monitor?task_id=${data.task_id}`)
|
||||
} catch (err) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Row */}
|
||||
{/* Stats Row - Apple style */}
|
||||
<Row gutter={16} style={{ marginBottom: 'var(--space-6)' }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="card">
|
||||
<Statistic
|
||||
title="筛选模式"
|
||||
value={SCREEN_MODES.find(m => m.value === mode)?.label}
|
||||
/>
|
||||
<div className="text-caption" style={{ marginBottom: 4 }}>筛选模式</div>
|
||||
<div style={{ fontFamily: 'var(--font-text)', fontSize: 15, fontWeight: 500 }}>
|
||||
{SCREEN_MODES.find(m => m.value === mode)?.label}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="card">
|
||||
<Statistic
|
||||
title="股票总数"
|
||||
value={stats.total}
|
||||
valueStyle={{ fontFamily: 'var(--font-data)' }}
|
||||
/>
|
||||
<div className="text-caption" style={{ marginBottom: 4 }}>股票总数</div>
|
||||
<div className="text-data" style={{ fontSize: 28, fontWeight: 600 }}>{stats.total}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="card">
|
||||
<Statistic
|
||||
title="通过数量"
|
||||
value={stats.passed}
|
||||
valueStyle={{
|
||||
fontFamily: 'var(--font-data)',
|
||||
color: 'var(--color-buy)',
|
||||
}}
|
||||
/>
|
||||
<div className="text-caption" style={{ marginBottom: 4 }}>通过数量</div>
|
||||
<div className="text-data" style={{ fontSize: 28, fontWeight: 600, color: 'var(--color-buy)' }}>{stats.passed}</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -213,6 +213,7 @@ export default function ScreeningPanel() {
|
|||
onChange={setMode}
|
||||
options={SCREEN_MODES}
|
||||
style={{ width: 200 }}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
|
|
@ -239,12 +240,10 @@ export default function ScreeningPanel() {
|
|||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchResults}
|
||||
aria-label="重试"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
style={{ border: '1px solid var(--color-sell)', borderRadius: 'var(--radius-md)' }}
|
||||
/>
|
||||
) : results.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
|
|
@ -265,6 +264,7 @@ export default function ScreeningPanel() {
|
|||
rowKey="ticker"
|
||||
pagination={{ pageSize: 10 }}
|
||||
size="middle"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
const BASE = '/api/portfolio';
|
||||
const FETCH_TIMEOUT_MS = 15000; // 15s timeout per request
|
||||
|
||||
async function req(method, path, body) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
try {
|
||||
const res = await fetch(`${BASE}${path}`, opts);
|
||||
clearTimeout(timeout);
|
||||
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();
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
if (e.name === 'AbortError') throw new Error('请求超时,请检查网络连接');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
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 controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const url = `${BASE}/positions/export${account ? `?account=${encodeURIComponent(account)}` : ''}`;
|
||||
return fetch(url, { signal: controller.signal })
|
||||
.then(r => { clearTimeout(timeout); return r; })
|
||||
.then(r => { if (!r.ok) throw new Error(`导出失败: ${r.status}`); return r.blob(); })
|
||||
.catch(e => { clearTimeout(timeout); if (e.name === 'AbortError') throw new Error('请求超时'); throw e; });
|
||||
},
|
||||
|
||||
// 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