TradingAgents/tradingagents/dataflows/discovery/ranker.py

703 lines
30 KiB
Python

import json
import re
from datetime import datetime
from typing import Any, Dict, List, Optional
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from tradingagents.dataflows.discovery.discovery_config import DiscoveryConfig
from tradingagents.dataflows.discovery.utils import append_llm_log, resolve_llm_name
from tradingagents.utils.logger import get_logger
logger = get_logger(__name__)
def extract_json_from_markdown(text: str) -> Optional[str]:
"""
Extract JSON from markdown code blocks.
Handles cases where LLMs return JSON wrapped in ```json...``` or just ```...```
"""
if not text:
return None
# Try to find JSON in markdown code blocks
patterns = [
r"```json\s*([\s\S]*?)\s*```", # ```json ... ```
r"```\s*([\s\S]*?)\s*```", # ``` ... ```
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
return match.group(1).strip()
# If no code blocks, check if the text itself is valid JSON
text = text.strip()
if text.startswith("{") or text.startswith("["):
return text
return None
class StockRanking(BaseModel):
"""Single stock ranking."""
rank: int = Field(description="Rank 1-N")
ticker: str = Field(description="Stock ticker symbol")
company_name: str = Field(description="Company name")
current_price: float = Field(description="Current stock price")
strategy_match: str = Field(description="Strategy that matched")
final_score: int = Field(description="Score 0-100")
confidence: int = Field(description="Confidence 1-10")
risk_level: str = Field(description="Risk level: low, moderate, high, or speculative")
reason: str = Field(
description="Detailed investment thesis (4-6 sentences) defending the trade with specific catalysts, risk/reward, and timing"
)
description: str = Field(description="Company description")
class RankingResponse(BaseModel):
"""LLM ranking response."""
rankings: List[StockRanking] = Field(description="List of ranked stocks")
class CandidateRanker:
"""
Handles ranking of filtered candidates using Deep Thinking LLM.
"""
def __init__(self, config: Dict[str, Any], llm: BaseChatModel, analytics: Any):
self.config = config
self.llm = llm
self.analytics = analytics
dc = DiscoveryConfig.from_config(config)
self.max_candidates_to_analyze = dc.ranker.max_candidates_to_analyze
self.final_recommendations = dc.ranker.final_recommendations
self.min_score_threshold = dc.ranker.min_score_threshold
self.return_target_pct = dc.ranker.return_target_pct
self.holding_period_days = dc.ranker.holding_period_days
# Truncation settings
self.truncate_context = dc.ranker.truncate_ranking_context
self.max_news_chars = dc.ranker.max_news_chars
self.max_insider_chars = dc.ranker.max_insider_chars
self.max_recommendations_chars = dc.ranker.max_recommendations_chars
# Prompt logging
self.log_prompts_console = dc.logging.log_prompts_console
def rank(self, state: Dict[str, Any]) -> Dict[str, Any]:
"""Rank all filtered candidates and select the top opportunities."""
candidates = state.get("candidate_metadata", [])
trade_date = state.get("trade_date", datetime.now().strftime("%Y-%m-%d"))
if len(candidates) == 0:
logger.warning("⚠️ No candidates to rank.")
return {
"opportunities": [],
"final_ranking": "[]",
"status": "complete",
"tool_logs": state.get("tool_logs", []),
}
# Limit candidates to prevent token overflow
max_candidates = min(self.max_candidates_to_analyze, 200)
if len(candidates) > max_candidates:
logger.warning(
f"⚠️ Too many candidates ({len(candidates)}), limiting to top {max_candidates} by priority"
)
candidates = candidates[:max_candidates]
logger.info(
f"🏆 Ranking {len(candidates)} candidates to select top {self.final_recommendations}..."
)
# Load historical performance statistics
historical_stats = self.analytics.load_historical_stats()
if historical_stats.get("available"):
logger.info(
f"📊 Loaded historical stats: {historical_stats.get('total_tracked', 0)} tracked recommendations"
)
# Build RICH context for each candidate
candidate_summaries = []
for cand in candidates:
ticker = cand.get("ticker", "UNKNOWN")
strategy = cand.get("strategy", "unknown")
priority = cand.get("priority", "unknown")
context = cand.get("context", "No context available")
all_sources = cand.get("all_sources", [cand.get("source", "unknown")])
technical_indicators = cand.get("technical_indicators", "")
avg_volume = cand.get("average_volume", "N/A")
intraday_change = cand.get("intraday_change_pct", "N/A")
current_price = cand.get("current_price")
# Formatting helpers
volume_str = (
f"{avg_volume:,.0f}" if isinstance(avg_volume, (int, float)) else str(avg_volume)
)
intraday_str = (
f"{intraday_change:+.1f}%"
if isinstance(intraday_change, (int, float))
else str(intraday_change)
)
price_str = f"${current_price:.2f}" if current_price else "N/A"
# Use fundamentals already fetched - pass more complete data
fund = cand.get("fundamentals", {})
fundamentals_summary = self._format_fundamentals_expanded(fund)
# Use full technical indicators instead of extracting only RSI
tech_summary = (
technical_indicators if technical_indicators else "No technical data available."
)
# Get options activity
options_activity = cand.get("options_activity", "")
# Get business description for context
business_description = cand.get("business_description", "")
# News summary - handle both batch news (string) and discovery news (list of dicts)
news_items = cand.get("news", [])
news_summary = ""
if isinstance(news_items, list) and news_items:
# List format from discovery scanner
headlines = []
for item in news_items[:3]:
if isinstance(item, dict):
# Discovery news format: {'news_title': '...', 'news_summary': '...', 'sentiment': '...', 'published_at': '...'}
title = item.get("news_title", item.get("title", ""))
summary = item.get("news_summary", "")
# Get timestamp from various possible fields
timestamp = item.get("published_at") or item.get("timestamp") or ""
# Format timestamp for display (extract date/time portion)
time_str = self._format_news_timestamp(timestamp)
if title:
if time_str:
headlines.append(
f"[{time_str}] {title}: {summary}"
if summary
else f"[{time_str}] {title}"
)
else:
headlines.append(f"{title}: {summary}" if summary else title)
elif isinstance(item, str):
headlines.append(item)
news_summary = "; ".join(headlines) if headlines else ""
elif isinstance(news_items, str):
news_summary = news_items
# Apply truncation if configured
if self.truncate_context and self.max_news_chars > 0:
if len(news_summary) > self.max_news_chars:
news_summary = news_summary[: self.max_news_chars] + "..."
source_str = (
", ".join(all_sources) if isinstance(all_sources, list) else str(all_sources)
)
# Format insider/analyst data
insider_text = cand.get("insider_transactions", "N/A")
recommendations_text = cand.get("recommendations", "N/A")
# Apply truncation if configured
if self.truncate_context:
if (
self.max_insider_chars > 0
and isinstance(insider_text, str)
and len(insider_text) > self.max_insider_chars
):
insider_text = insider_text[: self.max_insider_chars] + "..."
if (
self.max_recommendations_chars > 0
and isinstance(recommendations_text, str)
and len(recommendations_text) > self.max_recommendations_chars
):
recommendations_text = (
recommendations_text[: self.max_recommendations_chars] + "..."
)
# New enrichment fields
confluence_score = cand.get("confluence_score", 1)
quant_score = cand.get("quant_score", "N/A")
# ML prediction
ml_win_prob = cand.get("ml_win_probability")
ml_prediction = cand.get("ml_prediction")
if ml_win_prob is not None:
ml_str = f"{ml_win_prob:.1%} (Predicted: {ml_prediction})"
else:
ml_str = "N/A"
short_interest_pct = cand.get("short_interest_pct")
high_short = cand.get("high_short_interest", False)
short_str = f"{short_interest_pct:.1f}%" if short_interest_pct else "N/A"
if high_short:
short_str += " (HIGH)"
# Earnings estimate
if cand.get("has_upcoming_earnings"):
days = cand.get("days_to_earnings", "?")
eps_est = cand.get("eps_estimate")
rev_est = cand.get("revenue_estimate")
earnings_date = cand.get("earnings_date", "N/A")
eps_str = f"${eps_est:.2f}" if isinstance(eps_est, (int, float)) else "N/A"
rev_str = f"${rev_est:,.0f}" if isinstance(rev_est, (int, float)) else "N/A"
earnings_section = f"Earnings in {days} days ({earnings_date}): EPS Est {eps_str}, Rev Est {rev_str}"
else:
earnings_section = "No upcoming earnings within 30 days"
summary = f"""### {ticker} (Priority: {priority.upper()})
- **Strategy Match**: {strategy}
- **Sources**: {source_str} | **Confluence**: {confluence_score} source(s)
- **Quant Pre-Score**: {quant_score}/100 | **ML Win Probability**: {ml_str}
- **Price**: {price_str} | **Current Price (numeric)**: {current_price if isinstance(current_price, (int, float)) else "N/A"} | **Intraday**: {intraday_str} | **Avg Volume**: {volume_str}
- **Short Interest**: {short_str}
- **Discovery Context**: {context}
- **Business**: {business_description}
- **News**: {news_summary}
**Technical Analysis**:
{tech_summary}
**Fundamentals**: {fundamentals_summary}
**Insider Transactions**:
{insider_text}
**Analyst Recommendations**:
{recommendations_text}
**Options Activity**:
{options_activity if options_activity else "N/A"}
**Upcoming Earnings**: {earnings_section}
"""
candidate_summaries.append(summary)
combined_candidates_text = "\n".join(candidate_summaries)
# Build Prompt
prompt = f"""You are a professional stock analyst selecting the best short-term trading opportunities from a pre-filtered candidate list.
CURRENT DATE: {trade_date}
GOAL: Select UP TO {self.final_recommendations} stocks with the highest probability of generating >{self.return_target_pct}% returns within {self.holding_period_days} days. If fewer than {self.final_recommendations} candidates meet the quality bar, return only the ones that do. Quality over quantity — never pad the list with weak picks.
MINIMUM QUALITY BAR:
- Only include candidates where you have genuine conviction (final_score >= {self.min_score_threshold}).
- If a candidate lacks a clear catalyst or has contradictory signals, SKIP it.
- It is better to return 5 excellent picks than 15 mediocre ones.
STRATEGY-SPECIFIC EVALUATION CRITERIA:
Each candidate was discovered by a specific scanner. Evaluate them using the criteria most relevant to their strategy:
- **insider_buying**: Focus on insider transaction SIZE relative to market cap, insider ROLE (CEO/CFO > Director), number of distinct insiders buying, and whether the stock is near support. Large cluster buys are strongest.
- **options_flow**: Focus on put/call ratio, absolute call VOLUME vs open interest, premium size, and whether flow aligns with the technical trend. Unusually low P/C ratios (<0.1) with high volume are strongest.
- **momentum / technical_breakout**: Focus on volume confirmation (>2x average), trend alignment (above key SMAs), and whether momentum is accelerating or fading. Avoid chasing extended moves (RSI >80).
- **earnings_play**: Focus on short interest (squeeze potential), pre-earnings accumulation signals, analyst estimate trends, and historical earnings surprise rate. Binary risk must be acknowledged.
- **social_dd / social_hype**: Treat as SPECULATIVE. Require corroborating technical or fundamental evidence. Pure social sentiment without data backing should score low.
- **short_squeeze**: Focus on short interest %, days to cover, cost to borrow, and whether a catalyst exists to trigger covering. High SI alone is not enough.
- **contrarian_value**: Focus on oversold technicals (RSI <30), fundamental support (earnings stability), and a clear reason why the selloff is overdone.
- **news_catalyst**: Focus on the materiality of the news, whether it's already priced in (check intraday move), and the timeline of impact.
- **sector_rotation**: Focus on relative strength vs sector ETF, whether the stock is a laggard in an accelerating sector.
- **ml_signal**: Use the ML Win Probability as a strong quantitative signal. Scores above 65% deserve significant weight.
HISTORICAL INSIGHTS:
{json.dumps(historical_stats.get('summary', 'N/A'), indent=2)}
CANDIDATES FOR REVIEW:
{combined_candidates_text}
RANKING INSTRUCTIONS:
1. Evaluate each candidate through the lens of its specific strategy (see criteria above).
2. Cross-reference the strategy signal with Technicals, Fundamentals, and Options data for confirmation.
3. Use the Quantitative Pre-Score as an objective baseline — scores above 50 indicate strong multi-factor alignment.
4. The ML Win Probability is a trained model's estimate of hitting +{self.return_target_pct}% within 7 days. Treat >60% as strong confirmation, >70% as very strong.
5. Prioritize LEADING indicators (Insider Buying, Pre-Earnings Accumulation, Options Flow) over lagging ones (momentum chasing, social hype).
6. Penalize contradictory signals: e.g., bullish options but heavy insider SELLING, or strong momentum but overbought RSI with declining volume.
7. Use ONLY the information provided in the candidates section. Do NOT invent catalysts, prices, or metrics that are not explicitly stated.
8. If a data field is missing, note it as N/A — do not fabricate values.
9. Only rank tickers from the candidates list.
10. Each reason MUST cite at least two specific data points from the candidate context (e.g., "P/C ratio of 0.02", "Director purchased $5.2M").
OUTPUT FORMAT — JSON object with a 'rankings' list. Each item:
- rank: sequential from 1
- ticker: stock symbol (must be from candidate list)
- company_name: company name
- current_price: numeric price from candidate data
- strategy_match: the candidate's strategy (use the value from the candidate, do not change it)
- final_score: 0-100 (your holistic assessment: {self.min_score_threshold}+ = included, 80+ = high conviction, 90+ = exceptional)
- confidence: 1-10 (how confident are you in THIS specific trade)
- risk_level: one of "low", "moderate", "high", "speculative"
- reason: Investment thesis in 4-6 sentences. Structure: (1) What is the edge/catalyst, (2) Why NOW — what makes the timing urgent, (3) Risk/reward profile, (4) Key risk or what could invalidate the thesis. Cite specific numbers.
- description: One-sentence company description
IMPORTANT: Return ONLY valid JSON. No markdown wrapping, no commentary outside the JSON. All numeric fields must be numbers, not strings."""
# Invoke LLM with structured output
logger.info("🧠 Deep Thinking Ranker analyzing opportunities...")
logger.info(
f"Invoking ranking LLM with {len(candidates)} candidates, prompt length: {len(prompt)} chars"
)
if self.log_prompts_console:
logger.info(f"Full ranking prompt:\n{prompt}")
try:
# Use structured output with include_raw for debugging
structured_llm = self.llm.with_structured_output(RankingResponse, include_raw=True)
response = structured_llm.invoke([HumanMessage(content=prompt)])
tool_logs = state.get("tool_logs", [])
append_llm_log(
tool_logs,
node="ranker",
step="Rank candidates",
model=resolve_llm_name(self.llm),
prompt=prompt,
output=response,
)
state["tool_logs"] = tool_logs
# Handle the response (dict with raw, parsed, parsing_error)
if isinstance(response, dict):
result = response.get("parsed")
raw = response.get("raw")
parsing_error = response.get("parsing_error")
# Log debug info
logger.info(f"Structured output - parsed type: {type(result)}")
if parsing_error:
logger.error(f"Parsing error: {parsing_error}")
if raw and hasattr(raw, "content"):
logger.debug(f"Raw content preview: {str(raw.content)[:500]}...")
else:
# Direct RankingResponse (shouldn't happen with include_raw=True)
result = response
# Extract rankings - with fallback for markdown-wrapped JSON
if result is None:
logger.warning(
"Structured output parsing returned None - attempting fallback extraction"
)
# Try to extract JSON from raw response (handles ```json...``` wrapping)
raw_text = None
if raw and hasattr(raw, "content"):
content = raw.content
if isinstance(content, str):
raw_text = content
elif isinstance(content, list):
# Handle list of content blocks (e.g., [{'type': 'text', 'text': '...'}])
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
raw_text = block.get("text", "")
break
elif isinstance(block, str):
raw_text = block
break
if raw_text:
json_str = extract_json_from_markdown(raw_text)
if json_str:
try:
parsed_data = json.loads(json_str)
result = RankingResponse.model_validate(parsed_data)
logger.info(
"Successfully extracted JSON from markdown-wrapped response"
)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse extracted JSON: {e}")
except Exception as e:
logger.error(f"Failed to validate extracted JSON: {e}")
if result is None:
logger.error("Parsed result is None - check raw response for clues")
raise ValueError(
"LLM returned None. This may be due to content filtering or prompt length. "
"Check LOG_LEVEL=DEBUG for details."
)
if not hasattr(result, "rankings"):
logger.error(f"Result missing 'rankings'. Type: {type(result)}, Value: {result}")
raise ValueError(f"Unexpected result format: {type(result)}")
final_ranking_list = [ranking.model_dump() for ranking in result.rankings]
logger.info(f"✅ Selected {len(final_ranking_list)} top recommendations")
logger.info(
f"Successfully ranked {len(final_ranking_list)} opportunities: "
f"{[r['ticker'] for r in final_ranking_list]}"
)
# Update state with opportunities for downstream use (deep dive)
state_opportunities = []
for rank_dict in final_ranking_list:
ticker = rank_dict["ticker"].upper()
# Find original candidate metadata
meta = next((c for c in candidates if c.get("ticker") == ticker), {})
state_opportunities.append(
{
"ticker": ticker,
"strategy": rank_dict["strategy_match"],
"reason": rank_dict["reason"],
"score": rank_dict["final_score"],
"rank": rank_dict["rank"],
"metadata": meta,
}
)
return {
"final_ranking": final_ranking_list, # List of dicts
"opportunities": state_opportunities,
"status": "ranked",
}
except ValueError as e:
tool_logs = state.get("tool_logs", [])
append_llm_log(
tool_logs,
node="ranker",
step="Rank candidates",
model=resolve_llm_name(self.llm),
prompt=prompt,
output="",
error=str(e),
)
state["tool_logs"] = tool_logs
# Structured output validation failed
logger.error(f"❌ Error: {e}")
logger.error(f"Structured output validation error: {e}")
return {"final_ranking": [], "opportunities": [], "status": "ranking_failed"}
except Exception as e:
tool_logs = state.get("tool_logs", [])
append_llm_log(
tool_logs,
node="ranker",
step="Rank candidates",
model=resolve_llm_name(self.llm),
prompt=prompt,
output="",
error=str(e),
)
state["tool_logs"] = tool_logs
logger.error(f"❌ Error during ranking: {e}")
logger.exception(f"Unexpected error during ranking: {e}")
return {"final_ranking": [], "opportunities": [], "status": "error"}
def _format_news_timestamp(self, timestamp: str) -> str:
"""
Format news timestamp for display in ranking prompt.
Handles various timestamp formats:
- ISO-8601: 2026-01-31T14:30:00Z -> Jan 31 14:30
- Date only: 2026-01-31 -> Jan 31
- Already formatted strings pass through
"""
if not timestamp:
return ""
try:
# Try ISO-8601 format first
if "T" in timestamp:
# Parse ISO format: 2026-01-31T14:30:00Z or 2026-01-31T14:30:00+00:00
dt_str = timestamp.replace("Z", "+00:00")
# Handle timezone suffix
if "+" in dt_str:
dt_str = dt_str.split("+")[0]
elif dt_str.count("-") > 2:
# Handle negative timezone offset like -05:00
parts = dt_str.rsplit("-", 1)
if ":" in parts[-1]:
dt_str = parts[0]
dt = datetime.fromisoformat(dt_str)
return dt.strftime("%b %d %H:%M")
# Try date-only format
if len(timestamp) == 10 and timestamp.count("-") == 2:
dt = datetime.strptime(timestamp, "%Y-%m-%d")
return dt.strftime("%b %d")
# Try compact format from Alpha Vantage: 20260131T143000
if len(timestamp) >= 8 and timestamp[:8].isdigit():
dt = datetime.strptime(timestamp[:8], "%Y%m%d")
if len(timestamp) >= 15 and timestamp[8] == "T":
dt = datetime.strptime(timestamp[:15], "%Y%m%dT%H%M%S")
return dt.strftime("%b %d %H:%M")
return dt.strftime("%b %d")
# If it's already a short readable format, return as-is
if len(timestamp) <= 20:
return timestamp
except (ValueError, AttributeError):
# If parsing fails, return empty to avoid cluttering output
pass
return ""
def _format_fundamentals_expanded(self, fund: Dict[str, Any]) -> str:
"""Format fundamentals dictionary with comprehensive data for ranking LLM."""
if not fund:
return "N/A"
def fmt_pct(val):
if val == "N/A" or val is None:
return "N/A"
try:
return f"{float(val)*100:.1f}%"
except Exception:
return str(val)
def fmt_large(val, prefix="$"):
if val == "N/A" or val is None:
return "N/A"
try:
n = float(val)
if n >= 1e12:
return f"{prefix}{n/1e12:.2f}T"
if n >= 1e9:
return f"{prefix}{n/1e9:.2f}B"
if n >= 1e6:
return f"{prefix}{n/1e6:.1f}M"
return f"{prefix}{n:,.0f}"
except Exception:
return str(val)
def fmt_ratio(val):
if val == "N/A" or val is None:
return "N/A"
try:
return f"{float(val):.2f}"
except Exception:
return str(val)
parts = []
# Basic info
sector = fund.get("Sector", "N/A")
industry = fund.get("Industry", "N/A")
if sector != "N/A":
parts.append(f"Sector: {sector}")
if industry != "N/A":
parts.append(f"Industry: {industry}")
# Valuation
mc = fmt_large(fund.get("MarketCapitalization"))
pe = fmt_ratio(fund.get("PERatio"))
fwd_pe = fmt_ratio(fund.get("ForwardPE"))
peg = fmt_ratio(fund.get("PEGRatio"))
pb = fmt_ratio(fund.get("PriceToBookRatio"))
ps = fmt_ratio(fund.get("PriceToSalesRatioTTM"))
valuation_parts = []
if mc != "N/A":
valuation_parts.append(f"Cap: {mc}")
if pe != "N/A":
valuation_parts.append(f"P/E: {pe}")
if fwd_pe != "N/A":
valuation_parts.append(f"Fwd P/E: {fwd_pe}")
if peg != "N/A":
valuation_parts.append(f"PEG: {peg}")
if pb != "N/A":
valuation_parts.append(f"P/B: {pb}")
if ps != "N/A":
valuation_parts.append(f"P/S: {ps}")
if valuation_parts:
parts.append("Valuation: " + ", ".join(valuation_parts))
# Growth metrics
rev_growth = fmt_pct(fund.get("QuarterlyRevenueGrowthYOY"))
earnings_growth = fmt_pct(fund.get("QuarterlyEarningsGrowthYOY"))
growth_parts = []
if rev_growth != "N/A":
growth_parts.append(f"Rev Growth: {rev_growth}")
if earnings_growth != "N/A":
growth_parts.append(f"Earnings Growth: {earnings_growth}")
if growth_parts:
parts.append("Growth: " + ", ".join(growth_parts))
# Profitability
profit_margin = fmt_pct(fund.get("ProfitMargin"))
oper_margin = fmt_pct(fund.get("OperatingMarginTTM"))
roe = fmt_pct(fund.get("ReturnOnEquityTTM"))
roa = fmt_pct(fund.get("ReturnOnAssetsTTM"))
profit_parts = []
if profit_margin != "N/A":
profit_parts.append(f"Profit Margin: {profit_margin}")
if oper_margin != "N/A":
profit_parts.append(f"Oper Margin: {oper_margin}")
if roe != "N/A":
profit_parts.append(f"ROE: {roe}")
if roa != "N/A":
profit_parts.append(f"ROA: {roa}")
if profit_parts:
parts.append("Profitability: " + ", ".join(profit_parts))
# Dividend info
div_yield = fmt_pct(fund.get("DividendYield"))
if div_yield != "N/A" and div_yield != "0.0%":
parts.append(f"Dividend: {div_yield} yield")
# Financial health
current_ratio = fmt_ratio(fund.get("CurrentRatio"))
debt_to_equity = fmt_ratio(fund.get("DebtToEquity"))
if current_ratio != "N/A" or debt_to_equity != "N/A":
health_parts = []
if current_ratio != "N/A":
health_parts.append(f"Current Ratio: {current_ratio}")
if debt_to_equity != "N/A":
health_parts.append(f"D/E: {debt_to_equity}")
parts.append("Financial Health: " + ", ".join(health_parts))
# Analyst targets
target_high = fmt_large(fund.get("AnalystTargetPrice"))
if target_high != "N/A":
parts.append(f"Analyst Target: {target_high}")
# Earnings info
eps = fund.get("EPS", "N/A")
if eps != "N/A":
try:
eps = f"${float(eps):.2f}"
parts.append(f"EPS: {eps}")
except Exception:
pass
# Beta (volatility)
beta = fund.get("Beta", "N/A")
if beta != "N/A":
try:
beta = f"{float(beta):.2f}"
parts.append(f"Beta: {beta}")
except Exception:
pass
# 52-week range
week52_high = fund.get("52WeekHigh", "N/A")
week52_low = fund.get("52WeekLow", "N/A")
if week52_high != "N/A" and week52_low != "N/A":
try:
parts.append(f"52W Range: ${float(week52_low):.2f} - ${float(week52_high):.2f}")
except Exception:
pass
# Short interest
short_pct = fund.get("ShortPercentFloat", "N/A")
if short_pct != "N/A":
try:
parts.append(f"Short Interest: {float(short_pct)*100:.1f}%")
except Exception:
pass
return " | ".join(parts) if parts else "N/A"