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>
This commit is contained in:
陈少杰 2026-04-07 18:17:03 +08:00
parent 0988b8c271
commit 15fadd8780
3 changed files with 111 additions and 40 deletions

View File

@ -135,6 +135,10 @@ def delete_account(account_name: str) -> bool:
# ============== Positions =============
# Semaphore to limit concurrent yfinance requests (avoid rate limiting)
_yfinance_semaphore: asyncio.Semaphore = asyncio.Semaphore(5)
def _fetch_price(ticker: str) -> float | None:
"""Fetch current price synchronously (called in thread executor)"""
try:
@ -145,10 +149,16 @@ def _fetch_price(ticker: str) -> float | None:
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 to avoid blocking the event loop on yfinance HTTP calls.
Uses asyncio executor with concurrency limit (max 5 simultaneous requests).
"""
accounts = get_accounts()
@ -169,9 +179,8 @@ async def get_positions(account: Optional[str] = None) -> list:
if not positions:
return []
loop = asyncio.get_event_loop()
tickers = [t for t, _ in positions]
prices = await asyncio.gather(*[loop.run_in_executor(None, _fetch_price, t) for t in tickers])
prices = await asyncio.gather(*[_fetch_price_throttled(t) for t in tickers])
result = []
for (ticker, pos), current_price in zip(positions, prices):

View File

@ -643,20 +643,48 @@ async def export_report_pdf(ticker: str, date: str):
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=20)
pdf.add_font("DejaVu", "", "/System/Library/Fonts/Supplemental/DejaVuSans.ttf", unicode=True)
pdf.add_font("DejaVu", "B", "/System/Library/Fonts/Supplemental/DejaVuSans-Bold.ttf", unicode=True)
# 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("DejaVu", "B", 18)
pdf.set_font(font_bold, "B", 18)
pdf.cell(0, 12, f"TradingAgents 分析报告", ln=True, align="C")
pdf.ln(5)
pdf.set_font("DejaVu", "", 11)
pdf.set_font(font_regular, "", 11)
pdf.cell(0, 8, f"股票: {ticker} 日期: {date}", ln=True)
pdf.ln(3)
# Decision badge
pdf.set_font("DejaVu", "B", 14)
pdf.set_font(font_bold, "B", 14)
if decision == "BUY":
pdf.set_text_color(34, 197, 94)
elif decision == "SELL":
@ -668,16 +696,16 @@ async def export_report_pdf(ticker: str, date: str):
pdf.ln(5)
# Summary
pdf.set_font("DejaVu", "B", 12)
pdf.set_font(font_bold, "B", 12)
pdf.cell(0, 8, "分析摘要", ln=True)
pdf.set_font("DejaVu", "", 10)
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("DejaVu", "B", 12)
pdf.set_font(font_bold, "B", 12)
pdf.cell(0, 8, "完整报告", ln=True)
pdf.set_font("DejaVu", "", 9)
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)
@ -855,31 +883,25 @@ async def start_portfolio_analysis():
await broadcast_progress(task_id, app.state.task_results[task_id])
async def run_portfolio_analysis():
try:
for i, stock in enumerate(watchlist):
ticker = stock["ticker"]
app.state.task_results[task_id]["current_ticker"] = ticker
app.state.task_results[task_id]["status"] = "running"
app.state.task_results[task_id]["completed"] = i
await broadcast_progress(task_id, app.state.task_results[task_id])
MAX_RETRIES = 2
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:
# Run analysis in subprocess (reuse existing script pattern)
script_path = Path(f"/tmp/analysis_{task_id}_{i}.py")
script_content = ANALYSIS_SCRIPT_TEMPLATE
script_path.write_text(script_content)
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"
fd, script_path_str = tempfile.mkstemp(suffix=".py", prefix=f"analysis_{task_id}_{i}_")
script_path = Path(script_path_str)
os.chmod(script_path, 0o600)
with os.fdopen(fd, "w") as f:
f.write(ANALYSIS_SCRIPT_TEMPLATE)
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,
@ -900,7 +922,6 @@ async def start_portfolio_analysis():
for line in output.splitlines():
if line.startswith("ANALYSIS_COMPLETE:"):
decision = line.split(":", 1)[1].strip()
app.state.task_results[task_id]["completed"] = i + 1
rec = {
"ticker": ticker,
"name": stock.get("name", ticker),
@ -909,11 +930,36 @@ async def start_portfolio_analysis():
"created_at": datetime.now().isoformat(),
}
save_recommendation(date, ticker, rec)
app.state.task_results[task_id]["results"].append(rec)
return True, decision, rec
else:
app.state.task_results[task_id]["failed"] += 1
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(2 ** 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])

View File

@ -1,18 +1,29 @@
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);
const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `请求失败: ${res.status}`);
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;
}
if (res.status === 204) return null;
return res.json();
}
export const portfolioApi = {
@ -36,8 +47,13 @@ export const portfolioApi = {
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).then(r => r.blob());
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