diff --git a/tradingagents/agents/structured/portfolio.py b/tradingagents/agents/structured/portfolio.py index 7e62654b..d295c349 100644 --- a/tradingagents/agents/structured/portfolio.py +++ b/tradingagents/agents/structured/portfolio.py @@ -11,7 +11,7 @@ import json import logging from typing import Any, Dict, List -import yfinance as yf +from tradingagents.dataflows.fmp import get_ticker_info from tradingagents.models import ( PositionReplacementOutput, @@ -24,11 +24,11 @@ logger = logging.getLogger(__name__) def _fetch_peer_basics(tickers: List[str]) -> List[dict]: - """Fetch basic yfinance data for a list of peer tickers.""" + """Fetch basic FMP data for a list of peer tickers.""" peers = [] for sym in tickers[:8]: # cap at 8 to keep prompt manageable try: - info = yf.Ticker(sym.upper()).info or {} + info = get_ticker_info(sym) or {} peers.append({ "ticker": sym.upper(), "company_name": info.get("longName") or info.get("shortName") or sym, @@ -94,10 +94,9 @@ def create_theme_substitution_node(llm): summary = _summarize_for_theme(state) master_score = state.get("master_score", 0) - # Use yfinance to find peers in the same industry + # Use FMP to find peers in the same industry try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} + info = get_ticker_info(ticker) or {} industry = info.get("industry", "") sector = info.get("sector", "") except Exception: diff --git a/tradingagents/agents/structured/tier1.py b/tradingagents/agents/structured/tier1.py index 707a152b..08be8108 100644 --- a/tradingagents/agents/structured/tier1.py +++ b/tradingagents/agents/structured/tier1.py @@ -10,7 +10,7 @@ import json import logging from typing import Any, Dict -import yfinance as yf +from tradingagents.dataflows.fmp import get_ticker_info from tradingagents.models import ( CompanyCard, @@ -41,18 +41,17 @@ def _fmt_num(val): def _fetch_yf_info(ticker: str) -> dict: - """Fetch yfinance info dict for a ticker.""" + """Fetch FMP-backed ticker info dict (yfinance.Ticker.info-shaped).""" try: - t = yf.Ticker(ticker.upper()) - return t.info or {} + return get_ticker_info(ticker) or {} except Exception as e: - logger.warning("yfinance fetch failed for %s: %s", ticker, e) + logger.warning("FMP ticker info fetch failed for %s: %s", ticker, e) return {} def _fetch_macro_data() -> dict: - """Fetch macro indicators via yfinance.""" - from tradingagents.dataflows.y_finance import get_macro_indicators + """Fetch macro indicators via FMP.""" + from tradingagents.dataflows.fmp import get_macro_indicators try: raw = get_macro_indicators() @@ -170,14 +169,14 @@ def create_macro_node(llm): Ticker: {ticker} | Sector: {sector} -MACRO DATA (source: yfinance): -- VIX: {macro_data.get('vix_level', 'N/A')} (source: yfinance) -- 10Y Yield: {macro_data.get('ten_year_yield', 'N/A')}% (source: yfinance) -- Dollar 1M: {macro_data.get('dollar_1m_return', 'N/A')}% (source: yfinance) -- Credit Spreads: {macro_data.get('credit_spread_direction', 'N/A')} (source: yfinance) -- SPY 1M: {spy_perf.get('return_1m', 'N/A')}% (source: yfinance) +MACRO DATA (source: FMP): +- VIX: {macro_data.get('vix_level', 'N/A')} (source: FMP) +- 10Y Yield: {macro_data.get('ten_year_yield', 'N/A')}% (source: FMP) +- Dollar 1M: {macro_data.get('dollar_1m_return', 'N/A')}% (source: FMP) +- Credit Spreads: {macro_data.get('credit_spread_direction', 'N/A')} (source: FMP) +- SPY 1M: {spy_perf.get('return_1m', 'N/A')}% (source: FMP) -SECTOR PERFORMANCE (1M, source: yfinance): +SECTOR PERFORMANCE (1M, source: FMP): {chr(10).join(sector_lines[:12]) or 'N/A'} NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing. @@ -246,11 +245,11 @@ def create_liquidity_node(llm): Ticker: {ticker} | Sector: {card.get('sector', 'Unknown')} -AVAILABLE DATA (source: yfinance macro API): -- VIX: {macro_data.get('vix_level', 'N/A')} (source: yfinance) -- 10Y Yield: {macro_data.get('ten_year_yield', 'N/A')}% (source: yfinance) -- Credit Spreads: {macro_data.get('credit_spread_direction', 'N/A')} (source: yfinance) -- Dollar Strength: {macro_data.get('dollar_strength', 'N/A')} (source: yfinance) +AVAILABLE DATA (source: FMP macro API): +- VIX: {macro_data.get('vix_level', 'N/A')} (source: FMP) +- 10Y Yield: {macro_data.get('ten_year_yield', 'N/A')}% (source: FMP) +- Credit Spreads: {macro_data.get('credit_spread_direction', 'N/A')} (source: FMP) +- Dollar Strength: {macro_data.get('dollar_strength', 'N/A')} (source: FMP) NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing. diff --git a/tradingagents/agents/structured/tier2.py b/tradingagents/agents/structured/tier2.py index 9bb04fed..fd4cd497 100644 --- a/tradingagents/agents/structured/tier2.py +++ b/tradingagents/agents/structured/tier2.py @@ -1,6 +1,6 @@ """Tier 2 agents: Deep analysis that runs only on Tier 1 survivors. -Each agent fetches its own data via yfinance, calls the LLM once with +Each agent fetches its own data via FMP, calls the LLM once with structured output, and returns a typed result into PipelineState. """ @@ -10,7 +10,7 @@ import json import logging from typing import Any, Dict -import yfinance as yf +from tradingagents.dataflows.fmp import get_ticker_info from tradingagents.models import ( ArchetypeOutput, @@ -49,8 +49,7 @@ def create_business_quality_node(llm): card = state.get("company_card") or {} try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} + info = get_ticker_info(ticker) or {} except Exception: info = {} @@ -59,15 +58,15 @@ def create_business_quality_node(llm): Ticker: {ticker} | Sector: {card.get('sector', 'Unknown')} | Industry: {card.get('industry', 'Unknown')} Market Cap: {card.get('market_cap_formatted', 'N/A')} -FINANCIALS (source: yfinance): -- Revenue Growth: {_pct(_safe(info, 'revenueGrowth'))} (source: yfinance) -- Profit Margins: {_pct(_safe(info, 'profitMargins'))} (source: yfinance) -- Operating Margins: {_pct(_safe(info, 'operatingMargins'))} (source: yfinance) -- ROE: {_pct(_safe(info, 'returnOnEquity'))} (source: yfinance) -- ROA: {_pct(_safe(info, 'returnOnAssets'))} (source: yfinance) -- Debt/Equity: {_safe(info, 'debtToEquity', 'N/A')} (source: yfinance) -- Free Cash Flow: {_safe(info, 'freeCashflow', 'N/A')} (source: yfinance) -- Current Ratio: {_safe(info, 'currentRatio', 'N/A')} (source: yfinance) +FINANCIALS (source: FMP): +- Revenue Growth: {_pct(_safe(info, 'revenueGrowth'))} (source: FMP) +- Profit Margins: {_pct(_safe(info, 'profitMargins'))} (source: FMP) +- Operating Margins: {_pct(_safe(info, 'operatingMargins'))} (source: FMP) +- ROE: {_pct(_safe(info, 'returnOnEquity'))} (source: FMP) +- ROA: {_pct(_safe(info, 'returnOnAssets'))} (source: FMP) +- Debt/Equity: {_safe(info, 'debtToEquity', 'N/A')} (source: FMP) +- Free Cash Flow: {_safe(info, 'freeCashflow', 'N/A')} (source: FMP) +- Current Ratio: {_safe(info, 'currentRatio', 'N/A')} (source: FMP) NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing. @@ -114,7 +113,7 @@ def create_institutional_flow_node(llm): def node(state: Dict[str, Any]) -> Dict[str, Any]: ticker = state["ticker"] - from tradingagents.dataflows.y_finance import get_institutional_flow + from tradingagents.dataflows.fmp import get_institutional_flow try: raw = get_institutional_flow(ticker) data = json.loads(raw) if isinstance(raw, str) else raw @@ -135,23 +134,23 @@ Your job: track real smart-money movement — not just static ownership percenta Ticker: {ticker} -OWNERSHIP & VOLUME (source: yfinance): -- Institutional Ownership: {data.get('held_percent_institutions', 'N/A')}% (source: yfinance) -- Insider Ownership: {data.get('held_percent_insiders', 'N/A')}% (source: yfinance) -- Volume Ratio (10d/avg): {data.get('volume_ratio', 'N/A')} (source: yfinance) -- Short % of Float: {data.get('short_pct_of_float', 'N/A')}% (source: yfinance) -- Short Ratio (days): {data.get('short_ratio', 'N/A')} (source: yfinance) -- Float Turnover 5d: {data.get('float_turnover_5d_pct', 'N/A')}% (source: yfinance) +OWNERSHIP & VOLUME (source: FMP): +- Institutional Ownership: {data.get('held_percent_institutions', 'N/A')}% (source: FMP) +- Insider Ownership: {data.get('held_percent_insiders', 'N/A')}% (source: FMP) +- Volume Ratio (10d/avg): {data.get('volume_ratio', 'N/A')} (source: FMP) +- Short % of Float: {data.get('short_pct_of_float', 'N/A')}% (source: FMP) +- Short Ratio (days): {data.get('short_ratio', 'N/A')} (source: FMP) +- Float Turnover 5d: {data.get('float_turnover_5d_pct', 'N/A')}% (source: FMP) -SHORT INTEREST TREND (source: yfinance): +SHORT INTEREST TREND (source: FMP): - Short Interest Change (vs prior month): {data.get('short_interest_change_pct', 'N/A')}% - Short Interest Trend: {data.get('short_interest_trend', 'N/A')} -TOP INSTITUTIONAL HOLDERS (13F, source: yfinance): +TOP INSTITUTIONAL HOLDERS (13F, source: FMP): {chr(10).join(holder_lines) or ' No data available'} - Total top holders tracked: {data.get('top_holders_count', 'N/A')} -INSIDER TRANSACTIONS (recent, source: yfinance): +INSIDER TRANSACTIONS (recent, source: FMP): - Insider Buys: {data.get('insider_buys_recent', 'N/A')} - Insider Sells: {data.get('insider_sells_recent', 'N/A')} - Insider Signal: {data.get('insider_transaction_signal', 'N/A')} @@ -213,7 +212,7 @@ def create_valuation_node(llm): def node(state: Dict[str, Any]) -> Dict[str, Any]: ticker = state["ticker"] - from tradingagents.dataflows.y_finance import get_valuation_peers + from tradingagents.dataflows.fmp import get_valuation_peers try: raw = get_valuation_peers(ticker) data = json.loads(raw) if isinstance(raw, str) else raw @@ -224,16 +223,16 @@ def create_valuation_node(llm): Ticker: {ticker} -VALUATION METRICS (source: yfinance): -- Trailing P/E: {data.get('trailing_pe', 'N/A')} (source: yfinance) -- Forward P/E: {data.get('forward_pe', 'N/A')} (source: yfinance) -- PEG Ratio: {data.get('peg_ratio', 'N/A')} (source: yfinance) -- P/B: {data.get('price_to_book', 'N/A')} (source: yfinance) -- EV/EBITDA: {data.get('ev_to_ebitda', 'N/A')} (source: yfinance) -- P/S: {data.get('price_to_sales', 'N/A')} (source: yfinance) -- 52W Range Position: {data.get('vs_52w_range_pct', 'N/A')}% (source: yfinance) -- Revenue Growth: {data.get('revenue_growth', 'N/A')} (source: yfinance) -- Earnings Growth: {data.get('earnings_growth', 'N/A')} (source: yfinance) +VALUATION METRICS (source: FMP): +- Trailing P/E: {data.get('trailing_pe', 'N/A')} (source: FMP) +- Forward P/E: {data.get('forward_pe', 'N/A')} (source: FMP) +- PEG Ratio: {data.get('peg_ratio', 'N/A')} (source: FMP) +- P/B: {data.get('price_to_book', 'N/A')} (source: FMP) +- EV/EBITDA: {data.get('ev_to_ebitda', 'N/A')} (source: FMP) +- P/S: {data.get('price_to_sales', 'N/A')} (source: FMP) +- 52W Range Position: {data.get('vs_52w_range_pct', 'N/A')}% (source: FMP) +- Revenue Growth: {data.get('revenue_growth', 'N/A')} (source: FMP) +- Earnings Growth: {data.get('earnings_growth', 'N/A')} (source: FMP) NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing. @@ -296,11 +295,10 @@ def create_entry_timing_node(llm): except Exception as e: logger.debug("Alpaca MAs failed for %s: %s", ticker, e) - # Fallback: yfinance info + # Fallback: FMP ticker info if price is None: try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} + info = get_ticker_info(ticker) or {} except Exception: info = {} @@ -317,7 +315,7 @@ def create_entry_timing_node(llm): if ma50 and ma200: ma_rel = "above" if ma50 > ma200 else "below" - _timing_source = "Alpaca" if price is not None and ma50 is not None else "yfinance" + _timing_source = "Alpaca" if price is not None and ma50 is not None else "FMP" prompt = f"""You are an Entry Timing Analyst in a structured equity ranking pipeline. @@ -374,7 +372,7 @@ def create_earnings_revisions_node(llm): def node(state: Dict[str, Any]) -> Dict[str, Any]: ticker = state["ticker"] - from tradingagents.dataflows.y_finance import get_earnings_estimates + from tradingagents.dataflows.fmp import get_earnings_estimates try: raw = get_earnings_estimates(ticker) data = json.loads(raw) if isinstance(raw, str) else raw @@ -389,12 +387,12 @@ def create_earnings_revisions_node(llm): Ticker: {ticker} -EARNINGS DATA (source: yfinance): -- Trailing EPS: {data.get('trailing_eps', 'N/A')} (source: yfinance) -- Forward EPS: {data.get('forward_eps', 'N/A')} (source: yfinance) -- Price Target Upside: {upside or 'N/A'}% (source: yfinance) -- Price Targets: {json.dumps(targets)[:300] if targets else 'N/A'} (source: yfinance) -- Recent Recommendations: {len(recs)} entries (source: yfinance) +EARNINGS DATA (source: FMP): +- Trailing EPS: {data.get('trailing_eps', 'N/A')} (source: FMP) +- Forward EPS: {data.get('forward_eps', 'N/A')} (source: FMP) +- Price Target Upside: {upside or 'N/A'}% (source: FMP) +- Price Targets: {json.dumps(targets)[:300] if targets else 'N/A'} (source: FMP) +- Recent Recommendations: {len(recs)} entries (source: FMP) NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing. @@ -438,7 +436,7 @@ def create_sector_rotation_node(llm): def node(state: Dict[str, Any]) -> Dict[str, Any]: ticker = state["ticker"] - from tradingagents.dataflows.y_finance import get_sector_rotation + from tradingagents.dataflows.fmp import get_sector_rotation try: raw = get_sector_rotation(ticker) data = json.loads(raw) if isinstance(raw, str) else raw @@ -449,10 +447,10 @@ def create_sector_rotation_node(llm): Ticker: {ticker} | Sector: {data.get('sector', 'Unknown')} | Sector ETF: {data.get('sector_etf', 'N/A')} -SECTOR DATA (source: yfinance): -- Sector vs SPY 1M: {data.get('stock_sector_vs_spy_1m', 'N/A')}% (source: yfinance) -- Sector vs SPY 3M: {data.get('stock_sector_vs_spy_3m', 'N/A')}% (source: yfinance) -- Sector Rank: {data.get('stock_sector_rank', 'N/A')} / {data.get('total_sectors', 11)} (source: yfinance) +SECTOR DATA (source: FMP): +- Sector vs SPY 1M: {data.get('stock_sector_vs_spy_1m', 'N/A')}% (source: FMP) +- Sector vs SPY 3M: {data.get('stock_sector_vs_spy_3m', 'N/A')}% (source: FMP) +- Sector Rank: {data.get('stock_sector_rank', 'N/A')} / {data.get('total_sectors', 11)} (source: FMP) NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing. @@ -499,10 +497,9 @@ def create_backlog_node(llm): sector = card.get("sector", "Unknown") industry = card.get("industry", "Unknown") - # Backlog data is limited via yfinance — use revenue trajectory as proxy + # Backlog data is limited via FMP — use revenue trajectory as proxy try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} + info = get_ticker_info(ticker) or {} except Exception: info = {} @@ -510,10 +507,10 @@ def create_backlog_node(llm): Ticker: {ticker} | Sector: {sector} | Industry: {industry} -AVAILABLE DATA (source: yfinance): -- Revenue Growth: {_pct(_safe(info, 'revenueGrowth'))} (source: yfinance) -- Earnings Growth: {_pct(_safe(info, 'earningsGrowth'))} (source: yfinance) -- Revenue: {_safe(info, 'totalRevenue', 'N/A')} (source: yfinance) +AVAILABLE DATA (source: FMP): +- Revenue Growth: {_pct(_safe(info, 'revenueGrowth'))} (source: FMP) +- Earnings Growth: {_pct(_safe(info, 'earningsGrowth'))} (source: FMP) +- Revenue: {_safe(info, 'totalRevenue', 'N/A')} (source: FMP) NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing. @@ -554,8 +551,7 @@ def create_crowding_node(llm): card = state.get("company_card") or {} try: - t = yf.Ticker(ticker.upper()) - info = t.info or {} + info = get_ticker_info(ticker) or {} except Exception: info = {} @@ -570,9 +566,9 @@ def create_crowding_node(llm): Ticker: {ticker} | Company: {card.get('company_name', 'Unknown')} Market Cap Category: {card.get('market_cap_category', 'unknown')} -DATA (source: yfinance): -- Short % of Float: {short_pct or 'N/A'}% (source: yfinance) -- Short Ratio (days): {_safe(info, 'shortRatio', 'N/A')} (source: yfinance) +DATA (source: FMP): +- Short % of Float: {short_pct or 'N/A'}% (source: FMP) +- Short Ratio (days): {_safe(info, 'shortRatio', 'N/A')} (source: FMP) - Analyst Coverage: implied from market cap ({card.get('market_cap_category', 'unknown')}) NOTE: If a metric shows 'N/A' or 'unknown', say 'data unavailable' rather than guessing. diff --git a/tradingagents/agents/utils/macro_data_tools.py b/tradingagents/agents/utils/macro_data_tools.py deleted file mode 100644 index e4448189..00000000 --- a/tradingagents/agents/utils/macro_data_tools.py +++ /dev/null @@ -1,594 +0,0 @@ -"""Macro-aware data tools for the structured equity ranking engine. - -These tools fetch company profile, macro regime, sector rotation, -institutional flow, earnings estimates, and valuation data via yfinance. -They are used directly by analyst agents (not routed through interface.py). -""" - -from langchain_core.tools import tool -from typing import Annotated -from datetime import datetime -from dateutil.relativedelta import relativedelta -import json - - -def _safe_get(info, key, default=None): - """Safely get a value from yfinance info dict.""" - val = info.get(key) - if val is None: - return default - return val - - -def _fmt_large_number(val): - """Format large numbers for readability.""" - if val is None: - return None - if abs(val) >= 1e12: - return f"${val/1e12:.2f}T" - if abs(val) >= 1e9: - return f"${val/1e9:.2f}B" - if abs(val) >= 1e6: - return f"${val/1e6:.2f}M" - return f"${val:,.0f}" - - -def _market_cap_category(market_cap): - """Classify market cap size.""" - if market_cap is None: - return "unknown" - if market_cap >= 10e9: - return "large_cap" - if market_cap >= 2e9: - return "mid_cap" - if market_cap >= 300e6: - return "small_cap" - return "micro_cap" - - -# Sector to ETF mapping -SECTOR_ETF_MAP = { - "Technology": "XLK", - "Information Technology": "XLK", - "Communication Services": "XLC", - "Healthcare": "XLV", - "Health Care": "XLV", - "Financials": "XLF", - "Financial Services": "XLF", - "Consumer Discretionary": "XLY", - "Consumer Cyclical": "XLY", - "Consumer Staples": "XLP", - "Consumer Defensive": "XLP", - "Industrials": "XLI", - "Energy": "XLE", - "Utilities": "XLU", - "Materials": "XLB", - "Basic Materials": "XLB", - "Real Estate": "XLRE", -} - -ALL_SECTOR_ETFS = ["XLK", "XLC", "XLV", "XLF", "XLY", "XLP", "XLI", "XLE", "XLU", "XLB", "XLRE"] - - -def _get_period_return(ticker_obj, period_months, ref_date=None): - """Calculate return over a given period ending at ref_date.""" - import yfinance as yf - import pandas as pd - - try: - if ref_date: - end_dt = pd.to_datetime(ref_date) - else: - end_dt = pd.Timestamp.today() - - start_dt = end_dt - pd.DateOffset(months=period_months) - data = ticker_obj.history( - start=start_dt.strftime("%Y-%m-%d"), - end=end_dt.strftime("%Y-%m-%d"), - ) - if data.empty or len(data) < 2: - return None - return ((data["Close"].iloc[-1] / data["Close"].iloc[0]) - 1) * 100 - except Exception: - return None - - -@tool -def get_company_profile( - ticker: Annotated[str, "Ticker symbol of the company"], -) -> str: - """Fetch company profile: name, sector, industry, description, market cap, business model. - Returns structured text with all fields for the Company Intelligence Analyst. - """ - import yfinance as yf - - try: - t = yf.Ticker(ticker.upper()) - info = t.info - - if not info or not info.get("longName"): - return json.dumps({"error": f"No company data found for {ticker}", "ticker": ticker}) - - market_cap = _safe_get(info, "marketCap") - - profile = { - "company_name": _safe_get(info, "longName", "Unknown"), - "ticker": ticker.upper(), - "sector": _safe_get(info, "sector", "Unknown"), - "industry": _safe_get(info, "industry", "Unknown"), - "description": _safe_get(info, "longBusinessSummary", "No description available"), - "market_cap": market_cap, - "market_cap_formatted": _fmt_large_number(market_cap), - "market_cap_category": _market_cap_category(market_cap), - "trailing_pe": _safe_get(info, "trailingPE"), - "forward_pe": _safe_get(info, "forwardPE"), - "peg_ratio": _safe_get(info, "pegRatio"), - "price_to_book": _safe_get(info, "priceToBook"), - "dividend_yield": _safe_get(info, "dividendYield"), - "beta": _safe_get(info, "beta"), - "trailing_eps": _safe_get(info, "trailingEps"), - "forward_eps": _safe_get(info, "forwardEps"), - "revenue": _safe_get(info, "totalRevenue"), - "revenue_formatted": _fmt_large_number(_safe_get(info, "totalRevenue")), - "gross_profits": _safe_get(info, "grossProfits"), - "ebitda": _safe_get(info, "ebitda"), - "net_income": _safe_get(info, "netIncomeToCommon"), - "profit_margins": _safe_get(info, "profitMargins"), - "operating_margins": _safe_get(info, "operatingMargins"), - "return_on_equity": _safe_get(info, "returnOnEquity"), - "return_on_assets": _safe_get(info, "returnOnAssets"), - "debt_to_equity": _safe_get(info, "debtToEquity"), - "current_ratio": _safe_get(info, "currentRatio"), - "book_value": _safe_get(info, "bookValue"), - "free_cashflow": _safe_get(info, "freeCashflow"), - "fifty_two_week_high": _safe_get(info, "fiftyTwoWeekHigh"), - "fifty_two_week_low": _safe_get(info, "fiftyTwoWeekLow"), - "fifty_day_average": _safe_get(info, "fiftyDayAverage"), - "two_hundred_day_average": _safe_get(info, "twoHundredDayAverage"), - "average_volume": _safe_get(info, "averageVolume"), - "average_volume_10d": _safe_get(info, "averageVolume10days"), - "shares_outstanding": _safe_get(info, "sharesOutstanding"), - "float_shares": _safe_get(info, "floatShares"), - "shares_short": _safe_get(info, "sharesShort"), - "short_ratio": _safe_get(info, "shortRatio"), - "held_percent_insiders": _safe_get(info, "heldPercentInsiders"), - "held_percent_institutions": _safe_get(info, "heldPercentInstitutions"), - "current_price": _safe_get(info, "currentPrice") or _safe_get(info, "regularMarketPrice"), - } - - return json.dumps(profile, default=str) - - except Exception as e: - return json.dumps({"error": f"Error fetching company profile for {ticker}: {str(e)}", "ticker": ticker}) - - -@tool -def get_macro_indicators( - curr_date: Annotated[str, "Current trading date in yyyy-mm-dd format"], -) -> str: - """Fetch macro regime indicators: VIX, 10Y yield, dollar strength, credit spreads, sector ETF performance. - Returns structured text for the Company Intelligence and Macro Regime Analyst. - """ - import yfinance as yf - import pandas as pd - - try: - results = {} - - # VIX - try: - vix = yf.Ticker("^VIX") - vix_data = vix.history(period="5d") - if not vix_data.empty: - results["vix_level"] = round(vix_data["Close"].iloc[-1], 2) - if results["vix_level"] < 15: - results["vix_regime"] = "low" - elif results["vix_level"] < 20: - results["vix_regime"] = "moderate" - elif results["vix_level"] < 30: - results["vix_regime"] = "elevated" - else: - results["vix_regime"] = "stressed" - except Exception: - results["vix_level"] = None - results["vix_regime"] = "unknown" - - # 10Y yield - try: - tnx = yf.Ticker("^TNX") - tnx_data = tnx.history(period="5d") - if not tnx_data.empty: - results["ten_year_yield"] = round(tnx_data["Close"].iloc[-1], 3) - except Exception: - results["ten_year_yield"] = None - - # Dollar strength (UUP as proxy) - try: - uup = yf.Ticker("UUP") - uup_1m = _get_period_return(uup, 1) - uup_3m = _get_period_return(uup, 3) - results["dollar_1m_return"] = round(uup_1m, 2) if uup_1m is not None else None - results["dollar_3m_return"] = round(uup_3m, 2) if uup_3m is not None else None - if uup_1m is not None: - if uup_1m > 1: - results["dollar_strength"] = "strong" - elif uup_1m < -1: - results["dollar_strength"] = "weak" - else: - results["dollar_strength"] = "neutral" - except Exception: - results["dollar_strength"] = "unknown" - - # Credit spreads: HYG vs LQD - try: - hyg = yf.Ticker("HYG") - lqd = yf.Ticker("LQD") - hyg_1m = _get_period_return(hyg, 1) - lqd_1m = _get_period_return(lqd, 1) - if hyg_1m is not None and lqd_1m is not None: - spread_change = hyg_1m - lqd_1m - results["hyg_1m_return"] = round(hyg_1m, 2) - results["lqd_1m_return"] = round(lqd_1m, 2) - results["credit_spread_change"] = round(spread_change, 2) - if spread_change > 0.5: - results["credit_spread_direction"] = "tightening" - elif spread_change < -0.5: - results["credit_spread_direction"] = "widening" - else: - results["credit_spread_direction"] = "stable" - except Exception: - results["credit_spread_direction"] = "unknown" - - # SPY and sector ETF performance - sector_etfs = { - "SPY": "S&P 500", - "XLK": "Technology", - "XLC": "Communication Services", - "XLV": "Healthcare", - "XLF": "Financials", - "XLY": "Consumer Discretionary", - "XLP": "Consumer Staples", - "XLI": "Industrials", - "XLE": "Energy", - "XLU": "Utilities", - "XLB": "Materials", - "XLRE": "Real Estate", - } - - sector_performance = {} - for etf_ticker, sector_name in sector_etfs.items(): - try: - etf = yf.Ticker(etf_ticker) - ret_1m = _get_period_return(etf, 1) - ret_3m = _get_period_return(etf, 3) - sector_performance[etf_ticker] = { - "name": sector_name, - "return_1m": round(ret_1m, 2) if ret_1m is not None else None, - "return_3m": round(ret_3m, 2) if ret_3m is not None else None, - } - except Exception: - sector_performance[etf_ticker] = { - "name": sector_name, - "return_1m": None, - "return_3m": None, - } - - results["sector_performance"] = sector_performance - - return json.dumps(results, default=str) - - except Exception as e: - return json.dumps({"error": f"Error fetching macro indicators: {str(e)}"}) - - -@tool -def get_sector_rotation( - ticker: Annotated[str, "Ticker symbol of the company"], - curr_date: Annotated[str, "Current trading date in yyyy-mm-dd format"], -) -> str: - """Fetch sector rotation data: sector ETF relative strength vs SPY over 1M/3M/6M, breadth indicators. - Returns structured text for the Sector Rotation and Institutional Flow Analyst. - """ - import yfinance as yf - - try: - # Get the company's sector - t = yf.Ticker(ticker.upper()) - info = t.info - sector = _safe_get(info, "sector", "Unknown") - - # Map sector to ETF - sector_etf = SECTOR_ETF_MAP.get(sector, None) - - # Get SPY returns - spy = yf.Ticker("SPY") - spy_1m = _get_period_return(spy, 1) - spy_3m = _get_period_return(spy, 3) - spy_6m = _get_period_return(spy, 6) - - # Get all sector ETF returns for ranking - sector_returns = {} - for etf_sym in ALL_SECTOR_ETFS: - try: - etf = yf.Ticker(etf_sym) - ret_1m = _get_period_return(etf, 1) - ret_3m = _get_period_return(etf, 3) - ret_6m = _get_period_return(etf, 6) - sector_returns[etf_sym] = { - "return_1m": round(ret_1m, 2) if ret_1m is not None else None, - "return_3m": round(ret_3m, 2) if ret_3m is not None else None, - "return_6m": round(ret_6m, 2) if ret_6m is not None else None, - "vs_spy_1m": round(ret_1m - spy_1m, 2) if (ret_1m is not None and spy_1m is not None) else None, - "vs_spy_3m": round(ret_3m - spy_3m, 2) if (ret_3m is not None and spy_3m is not None) else None, - "vs_spy_6m": round(ret_6m - spy_6m, 2) if (ret_6m is not None and spy_6m is not None) else None, - } - except Exception: - sector_returns[etf_sym] = { - "return_1m": None, "return_3m": None, "return_6m": None, - "vs_spy_1m": None, "vs_spy_3m": None, "vs_spy_6m": None, - } - - # Rank sectors by 1M relative strength - ranked = sorted( - [(sym, data) for sym, data in sector_returns.items() if data["vs_spy_1m"] is not None], - key=lambda x: x[1]["vs_spy_1m"], - reverse=True, - ) - rank_map = {sym: i + 1 for i, (sym, _) in enumerate(ranked)} - - # Stock's sector data - stock_sector_data = {} - stock_sector_rank = None - if sector_etf and sector_etf in sector_returns: - stock_sector_data = sector_returns[sector_etf] - stock_sector_rank = rank_map.get(sector_etf) - - result = { - "ticker": ticker.upper(), - "sector": sector, - "sector_etf": sector_etf, - "stock_sector_vs_spy_1m": stock_sector_data.get("vs_spy_1m"), - "stock_sector_vs_spy_3m": stock_sector_data.get("vs_spy_3m"), - "stock_sector_vs_spy_6m": stock_sector_data.get("vs_spy_6m"), - "stock_sector_rank": stock_sector_rank, - "total_sectors": len(ranked), - "spy_1m_return": round(spy_1m, 2) if spy_1m is not None else None, - "spy_3m_return": round(spy_3m, 2) if spy_3m is not None else None, - "spy_6m_return": round(spy_6m, 2) if spy_6m is not None else None, - "all_sector_returns": sector_returns, - "sector_rankings_1m": [{"etf": sym, "vs_spy_1m": data["vs_spy_1m"]} for sym, data in ranked], - } - - return json.dumps(result, default=str) - - except Exception as e: - return json.dumps({"error": f"Error fetching sector rotation data for {ticker}: {str(e)}"}) - - -@tool -def get_institutional_flow( - ticker: Annotated[str, "Ticker symbol of the company"], -) -> str: - """Fetch institutional flow data: volume ratios, float turnover, short interest, institutional ownership. - Returns structured text for the Sector Rotation and Institutional Flow Analyst. - """ - import yfinance as yf - - try: - t = yf.Ticker(ticker.upper()) - info = t.info - - avg_vol = _safe_get(info, "averageVolume") - avg_vol_10d = _safe_get(info, "averageVolume10days") - shares_outstanding = _safe_get(info, "sharesOutstanding") - float_shares = _safe_get(info, "floatShares") - shares_short = _safe_get(info, "sharesShort") - short_ratio = _safe_get(info, "shortRatio") - held_institutions = _safe_get(info, "heldPercentInstitutions") - held_insiders = _safe_get(info, "heldPercentInsiders") - - # Compute derived metrics - volume_ratio = None - if avg_vol and avg_vol_10d and avg_vol > 0: - volume_ratio = round(avg_vol_10d / avg_vol, 2) - - float_turnover_5d = None - float_turnover_20d = None - if float_shares and float_shares > 0: - if avg_vol_10d: - float_turnover_5d = round((avg_vol_10d * 5) / float_shares * 100, 2) - if avg_vol: - float_turnover_20d = round((avg_vol * 20) / float_shares * 100, 2) - - short_pct_of_float = None - if shares_short and float_shares and float_shares > 0: - short_pct_of_float = round(shares_short / float_shares * 100, 2) - - result = { - "ticker": ticker.upper(), - "average_volume": avg_vol, - "average_volume_10d": avg_vol_10d, - "volume_ratio": volume_ratio, - "shares_outstanding": shares_outstanding, - "float_shares": float_shares, - "shares_short": shares_short, - "short_ratio": short_ratio, - "short_pct_of_float": short_pct_of_float, - "float_turnover_5d_pct": float_turnover_5d, - "float_turnover_20d_pct": float_turnover_20d, - "held_percent_institutions": round(held_institutions * 100, 2) if held_institutions else None, - "held_percent_insiders": round(held_insiders * 100, 2) if held_insiders else None, - } - - return json.dumps(result, default=str) - - except Exception as e: - return json.dumps({"error": f"Error fetching institutional flow data for {ticker}: {str(e)}"}) - - -@tool -def get_earnings_estimates( - ticker: Annotated[str, "Ticker symbol of the company"], -) -> str: - """Fetch earnings revision data: analyst recommendations, price targets, EPS estimates. - Returns structured text for the Earnings Revision and News Catalyst Analyst. - """ - import yfinance as yf - - try: - t = yf.Ticker(ticker.upper()) - info = t.info - - result = { - "ticker": ticker.upper(), - "current_price": _safe_get(info, "currentPrice") or _safe_get(info, "regularMarketPrice"), - "trailing_eps": _safe_get(info, "trailingEps"), - "forward_eps": _safe_get(info, "forwardEps"), - } - - # Analyst recommendations - try: - recs = t.recommendations - if recs is not None and not recs.empty: - # Get the most recent recommendations - recent_recs = recs.tail(20) - rec_list = [] - for _, row in recent_recs.iterrows(): - rec_entry = {} - for col in recent_recs.columns: - val = row[col] - if hasattr(val, 'item'): - val = val.item() - rec_entry[col] = val - rec_list.append(rec_entry) - result["recent_recommendations"] = rec_list - else: - result["recent_recommendations"] = [] - except Exception: - result["recent_recommendations"] = [] - - # Analyst price targets - try: - targets = t.analyst_price_targets - if targets is not None: - target_dict = {} - if hasattr(targets, 'items'): - for k, v in targets.items(): - if hasattr(v, 'item'): - target_dict[k] = v.item() - else: - target_dict[k] = v - elif isinstance(targets, dict): - target_dict = targets - result["price_targets"] = target_dict - - # Calculate upside - current = result.get("current_price") - mean_target = target_dict.get("mean") or target_dict.get("current") - if current and mean_target and current > 0: - result["price_target_upside_pct"] = round(((mean_target / current) - 1) * 100, 2) - else: - result["price_targets"] = {} - except Exception: - result["price_targets"] = {} - - # Earnings estimates if available - try: - earnings_est = t.earnings_estimate - if earnings_est is not None and not earnings_est.empty: - est_dict = {} - for col in earnings_est.columns: - est_dict[str(col)] = {} - for idx in earnings_est.index: - val = earnings_est.loc[idx, col] - if hasattr(val, 'item'): - val = val.item() - est_dict[str(col)][str(idx)] = val - result["earnings_estimates"] = est_dict - else: - result["earnings_estimates"] = {} - except Exception: - result["earnings_estimates"] = {} - - # Revenue estimates if available - try: - rev_est = t.revenue_estimate - if rev_est is not None and not rev_est.empty: - rev_dict = {} - for col in rev_est.columns: - rev_dict[str(col)] = {} - for idx in rev_est.index: - val = rev_est.loc[idx, col] - if hasattr(val, 'item'): - val = val.item() - rev_dict[str(col)][str(idx)] = val - result["revenue_estimates"] = rev_dict - else: - result["revenue_estimates"] = {} - except Exception: - result["revenue_estimates"] = {} - - return json.dumps(result, default=str) - - except Exception as e: - return json.dumps({"error": f"Error fetching earnings estimates for {ticker}: {str(e)}"}) - - -@tool -def get_valuation_peers( - ticker: Annotated[str, "Ticker symbol of the company"], -) -> str: - """Fetch valuation metrics and peer comparison data. - Returns structured text for the Business Quality, Valuation, and Entry Timing Analyst. - """ - import yfinance as yf - - try: - t = yf.Ticker(ticker.upper()) - info = t.info - - current_price = _safe_get(info, "currentPrice") or _safe_get(info, "regularMarketPrice") - fifty_two_high = _safe_get(info, "fiftyTwoWeekHigh") - fifty_two_low = _safe_get(info, "fiftyTwoWeekLow") - - # Calculate position in 52-week range - vs_52w_range_pct = None - if fifty_two_high and fifty_two_low and current_price and (fifty_two_high - fifty_two_low) > 0: - vs_52w_range_pct = round( - ((current_price - fifty_two_low) / (fifty_two_high - fifty_two_low)) * 100, 1 - ) - - result = { - "ticker": ticker.upper(), - "current_price": current_price, - "trailing_pe": _safe_get(info, "trailingPE"), - "forward_pe": _safe_get(info, "forwardPE"), - "peg_ratio": _safe_get(info, "pegRatio"), - "price_to_book": _safe_get(info, "priceToBook"), - "price_to_sales": _safe_get(info, "priceToSalesTrailing12Months"), - "enterprise_value": _safe_get(info, "enterpriseValue"), - "ev_to_ebitda": _safe_get(info, "enterpriseToEbitda"), - "ev_to_revenue": _safe_get(info, "enterpriseToRevenue"), - "market_cap": _safe_get(info, "marketCap"), - "fifty_two_week_high": fifty_two_high, - "fifty_two_week_low": fifty_two_low, - "vs_52w_range_pct": vs_52w_range_pct, - "fifty_day_average": _safe_get(info, "fiftyDayAverage"), - "two_hundred_day_average": _safe_get(info, "twoHundredDayAverage"), - "profit_margins": _safe_get(info, "profitMargins"), - "operating_margins": _safe_get(info, "operatingMargins"), - "gross_margins": _safe_get(info, "grossMargins"), - "return_on_equity": _safe_get(info, "returnOnEquity"), - "return_on_assets": _safe_get(info, "returnOnAssets"), - "revenue_growth": _safe_get(info, "revenueGrowth"), - "earnings_growth": _safe_get(info, "earningsGrowth"), - "debt_to_equity": _safe_get(info, "debtToEquity"), - "current_ratio": _safe_get(info, "currentRatio"), - "free_cashflow": _safe_get(info, "freeCashflow"), - "book_value": _safe_get(info, "bookValue"), - } - - return json.dumps(result, default=str) - - except Exception as e: - return json.dumps({"error": f"Error fetching valuation data for {ticker}: {str(e)}"}) diff --git a/tradingagents/dataflows/fmp.py b/tradingagents/dataflows/fmp.py new file mode 100644 index 00000000..a7e28f24 --- /dev/null +++ b/tradingagents/dataflows/fmp.py @@ -0,0 +1,819 @@ +"""FMP data source — primary market data for TradingAgents. + +Public API mirrors the shape of the (now deprecated) ``y_finance`` module +so callers in ``interface.py`` and the structured analyst tiers get drop-in +replacements. One extra function, :func:`get_ticker_info`, returns a dict +keyed by the same field names yfinance's ``Ticker.info`` emits — this lets +``tier1.py`` / ``tier2.py`` / ``portfolio.py`` swap ``yf.Ticker(t).info`` +for ``get_ticker_info(t)`` without touching dict-lookup sites. + +Data paths, in order: + 1. Postgres ``fmp_bulk`` (nightly ETL) — zero-cost, fast. + 2. FMP live API (``/stable/*``) — fills gaps and today's data before ETL. + 3. Alpaca (for bars) — already used by :mod:`alpaca_data` as an OHLCV layer. + +Fields that FMP genuinely does not expose (e.g. yfinance's extended +``insider_transactions`` DataFrame) return N/A — callers already handle +missing keys gracefully via ``.get(key, 'N/A')`` patterns. +""" +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import Annotated, Any, Dict, List, Optional + +from dateutil.relativedelta import relativedelta + +from .fmp_client import get_client + +_logger = logging.getLogger(__name__) + + +# ══════════════════════════════════════════════════════════════════════ +# Market cap bucket helpers (used by company profile) +# ══════════════════════════════════════════════════════════════════════ + +def _market_cap_category(mc: Optional[float]) -> str: + if not mc: + return "unknown" + if mc >= 10e9: + return "large_cap" + if mc >= 2e9: + return "mid_cap" + if mc >= 300e6: + return "small_cap" + return "micro_cap" + + +def _fmt_num(val: Optional[float]) -> Optional[str]: + if val is None: + return None + try: + v = float(val) + except (TypeError, ValueError): + return None + if abs(v) >= 1e12: + return f"${v/1e12:.2f}T" + if abs(v) >= 1e9: + return f"${v/1e9:.2f}B" + if abs(v) >= 1e6: + return f"${v/1e6:.2f}M" + return f"${v:,.0f}" + + +def _to_float(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _mul(a: Any, b: Any) -> Optional[float]: + """Multiply two potentially-string FMP values. Returns None if either is missing/invalid.""" + fa, fb = _to_float(a), _to_float(b) + if fa is None or fb is None: + return None + return fa * fb + + +# ══════════════════════════════════════════════════════════════════════ +# yfinance-compatible Ticker.info +# ══════════════════════════════════════════════════════════════════════ + +_SECTOR_ETF_MAP = { + "Technology": "XLK", + "Financial Services": "XLF", + "Financials": "XLF", + "Energy": "XLE", + "Healthcare": "XLV", + "Health Care": "XLV", + "Industrials": "XLI", + "Consumer Cyclical": "XLY", + "Consumer Discretionary": "XLY", + "Consumer Defensive": "XLP", + "Consumer Staples": "XLP", + "Utilities": "XLU", + "Real Estate": "XLRE", + "Basic Materials": "XLB", + "Materials": "XLB", + "Communication Services": "XLC", +} + + +def get_ticker_info(ticker: str) -> Dict[str, Any]: + """Return an FMP-backed dict shaped like ``yf.Ticker(t).info``. + + Keys emitted match yfinance's field names so downstream code works + without conditionals. Missing fields are simply absent — callers + already handle ``.get('key', default)``. + """ + symbol = ticker.upper() + client = get_client() + + # 1. Profile (fast path: fmp_bulk, fallback: /profile/{symbol}) + profile = client.bulk_lookup("profile-bulk", symbol) or client.live_get( + f"/profile/{symbol}" + ) or {} + + # 2. Ratios TTM + Key Metrics TTM for valuation/returns/margins + ratios = client.bulk_lookup("ratios-ttm-bulk", symbol) or client.live_get( + f"/ratios-ttm/{symbol}" + ) or {} + km = client.bulk_lookup("key-metrics-ttm-bulk", symbol) or client.live_get( + f"/key-metrics-ttm/{symbol}" + ) or {} + + # 3. Analyst estimates / price targets (for forward EPS) + estimates = client.live_get_list( + f"/analyst-estimates/{symbol}", params={"period": "annual", "limit": 2} + ) + pt_consensus = client.live_get(f"/price-target-consensus/{symbol}") or {} + + # 4. Quote (for current price / 52W / MAs if profile missed them) + quote = client.live_get(f"/quote/{symbol}") or {} + + # --- Assemble yfinance-shaped dict --------------------------------- + + mc = _to_float(profile.get("mktCap") or quote.get("marketCap")) + price = _to_float(profile.get("price") or quote.get("price")) + hi52 = _to_float(quote.get("yearHigh") or profile.get("yearHigh")) + lo52 = _to_float(quote.get("yearLow") or profile.get("yearLow")) + + # Forward EPS: use next-year consensus if available + forward_eps = None + if estimates: + # Pick the estimate with the latest date that is after today + today = datetime.utcnow().date() + future = [] + for e in estimates: + try: + d = datetime.strptime(str(e.get("date", ""))[:10], "%Y-%m-%d").date() + if d >= today: + future.append((d, e)) + except Exception: + continue + future.sort() + if future: + forward_eps = _to_float(future[0][1].get("estimatedEpsAvg")) + + # TTM margins/returns: FMP ratios-ttm uses grossProfitMarginTTM etc. + info: Dict[str, Any] = { + # Identity / profile + "symbol": symbol, + "longName": profile.get("companyName"), + "shortName": profile.get("companyName"), + "sector": profile.get("sector"), + "industry": profile.get("industry"), + "longBusinessSummary": profile.get("description"), + "fullTimeEmployees": profile.get("fullTimeEmployees"), + "exchange": profile.get("exchangeShortName") or profile.get("exchange"), + "website": profile.get("website"), + "country": profile.get("country"), + "city": profile.get("city"), + "currency": profile.get("currency"), + # Prices + "currentPrice": price, + "regularMarketPrice": price, + "marketCap": mc, + "fiftyTwoWeekHigh": hi52, + "fiftyTwoWeekLow": lo52, + "fiftyDayAverage": _to_float(quote.get("priceAvg50")), + "twoHundredDayAverage": _to_float(quote.get("priceAvg200")), + "beta": _to_float(profile.get("beta")), + # Volume / float + "averageVolume": _to_float(profile.get("volAvg") or quote.get("avgVolume")), + "averageVolume10days": _to_float(quote.get("avgVolume")), + "floatShares": _to_float(km.get("sharesFloatTTM") or profile.get("floatShares")), + "sharesOutstanding": _to_float(km.get("sharesOutTTM")), + # Valuation + "trailingPE": _to_float(ratios.get("priceEarningsRatioTTM") or km.get("peRatioTTM")), + "forwardPE": _to_float(km.get("peRatioTTM")), + "pegRatio": _to_float(ratios.get("priceEarningsToGrowthRatioTTM")), + "priceToBook": _to_float(ratios.get("priceBookValueRatioTTM") or km.get("pbRatioTTM")), + "priceToSales": _to_float(km.get("priceToSalesRatioTTM")), + "priceToSalesTrailing12Months": _to_float(km.get("priceToSalesRatioTTM")), + "enterpriseToEbitda": _to_float(km.get("enterpriseValueOverEBITDATTM")), + "enterpriseValue": _to_float(km.get("enterpriseValueTTM")), + "trailingEps": _to_float(km.get("netIncomePerShareTTM")), + "forwardEps": forward_eps, + "bookValue": _to_float(km.get("bookValuePerShareTTM")), + "dividendYield": _to_float(ratios.get("dividendYieldTTM")), + # Margins / returns (TTM) + "revenueGrowth": _to_float(km.get("revenueGrowthTTM")), + "earningsGrowth": _to_float(km.get("netIncomeGrowthTTM")), + "profitMargins": _to_float(ratios.get("netProfitMarginTTM")), + "operatingMargins": _to_float(ratios.get("operatingProfitMarginTTM")), + "grossMargins": _to_float(ratios.get("grossProfitMarginTTM")), + "ebitdaMargins": _to_float(km.get("ebitdaMarginTTM")), + "returnOnEquity": _to_float(ratios.get("returnOnEquityTTM") or km.get("roeTTM")), + "returnOnAssets": _to_float(ratios.get("returnOnAssetsTTM") or km.get("roaTTM")), + # Balance sheet + "debtToEquity": _to_float(ratios.get("debtEquityRatioTTM") or km.get("debtToEquityTTM")), + "currentRatio": _to_float(ratios.get("currentRatioTTM") or km.get("currentRatioTTM")), + "quickRatio": _to_float(ratios.get("quickRatioTTM")), + # Cash flow / income statement (per-share × shares-out reconstruction) + "freeCashflow": _mul(km.get("freeCashFlowPerShareTTM"), km.get("sharesOutTTM")), + "totalRevenue": _mul(km.get("revenuePerShareTTM"), km.get("sharesOutTTM")), + "netIncomeToCommon": _mul(km.get("netIncomePerShareTTM"), km.get("sharesOutTTM")), + "grossProfits": None, # requires income-statement fetch, not in TTM bulk + "ebitda": None, # requires income-statement fetch + # Analyst coverage + "targetHighPrice": _to_float(pt_consensus.get("targetHigh")), + "targetLowPrice": _to_float(pt_consensus.get("targetLow")), + "targetMeanPrice": _to_float(pt_consensus.get("targetConsensus")), + "targetMedianPrice": _to_float(pt_consensus.get("targetMedian")), + "recommendationKey": pt_consensus.get("recommendationKey"), + # Short interest / institutional / insider (FMP exposes these via other endpoints) + "heldPercentInstitutions": None, + "heldPercentInsiders": None, + "sharesShort": None, + "sharesShortPriorMonth": None, + "shortRatio": None, + } + # Drop explicit-None keys so callers' `.get(key)` returns None naturally + # (identical behavior to yfinance, where missing keys aren't present). + return {k: v for k, v in info.items() if v is not None} + + +# ══════════════════════════════════════════════════════════════════════ +# Router-facing functions (match y_finance signatures used by interface.py) +# ══════════════════════════════════════════════════════════════════════ + +def get_YFin_data_online( + symbol: Annotated[str, "ticker symbol of the company"], + start_date: Annotated[str, "Start date in yyyy-mm-dd format"], + end_date: Annotated[str, "End date in yyyy-mm-dd format"], +) -> str: + """Daily OHLCV CSV for ``symbol`` between ``start_date`` and ``end_date``. + + Tries Alpaca first (high rate limit, fast); falls back to FMP live API. + Returned CSV matches the header yfinance callers already expect: + ``Date,Open,High,Low,Close,Adj Close,Volume``. + """ + # Validate date format + datetime.strptime(start_date, "%Y-%m-%d") + datetime.strptime(end_date, "%Y-%m-%d") + + # Alpaca first + try: + from .alpaca_data import alpaca_available, get_bars_csv + if alpaca_available(): + result = get_bars_csv(symbol, start_date, end_date) + if result and not result.startswith("Error"): + return result + _logger.info("Alpaca bars failed, falling back to FMP for %s", symbol) + except Exception as e: + _logger.debug("Alpaca unavailable: %s", e) + + # FMP live + client = get_client() + rows = client.live_get_list( + "/historical-price-eod/full", + params={"symbol": symbol.upper(), "from": start_date, "to": end_date}, + ) + if not rows: + return f"No data found for symbol '{symbol}' between {start_date} and {end_date}" + + # Sort ascending and format as CSV (matches yfinance default) + rows_sorted = sorted(rows, key=lambda r: r.get("date", "")) + header = f"# Stock data for {symbol.upper()} from {start_date} to {end_date}\n" + header += f"# Total records: {len(rows_sorted)}\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + lines = ["Date,Open,High,Low,Close,Adj Close,Volume"] + for r in rows_sorted: + adj = r.get("adjClose", r.get("close")) + lines.append( + f"{r.get('date')},{_round(r.get('open'))},{_round(r.get('high'))}," + f"{_round(r.get('low'))},{_round(r.get('close'))},{_round(adj)}," + f"{int(r.get('volume') or 0)}" + ) + return header + "\n".join(lines) + "\n" + + +def _round(v: Any, nd: int = 2) -> str: + f = _to_float(v) + return "" if f is None else f"{f:.{nd}f}" + + +_INDICATOR_DESC = { + "close_50_sma": "50 SMA: A medium-term trend indicator. Usage: Identify trend direction and serve as dynamic support/resistance.", + "close_200_sma": "200 SMA: A long-term trend benchmark. Usage: Confirm overall market trend and identify golden/death cross setups.", + "close_10_ema": "10 EMA: A responsive short-term average. Usage: Capture quick shifts in momentum and potential entry points.", + "macd": "MACD: Computes momentum via differences of EMAs. Usage: Look for crossovers and divergence as signals of trend changes.", + "macds": "MACD Signal: An EMA smoothing of the MACD line. Usage: Use crossovers with the MACD line to trigger trades.", + "macdh": "MACD Histogram: Shows the gap between the MACD line and its signal. Usage: Visualize momentum strength and spot divergence early.", + "rsi": "RSI: Measures momentum to flag overbought/oversold conditions. Usage: Apply 70/30 thresholds and watch for divergence to signal reversals.", + "boll": "Bollinger Middle: A 20 SMA serving as the basis for Bollinger Bands. Usage: Acts as a dynamic benchmark for price movement.", + "boll_ub": "Bollinger Upper Band: Typically 2 standard deviations above the middle line. Usage: Signals potential overbought conditions and breakout zones.", + "boll_lb": "Bollinger Lower Band: Typically 2 standard deviations below the middle line. Usage: Indicates potential oversold conditions.", + "atr": "ATR: Averages true range to measure volatility. Usage: Set stop-loss levels and adjust position sizes based on current market volatility.", + "vwma": "VWMA: A moving average weighted by volume. Usage: Confirm trends by integrating price action with volume data.", + "mfi": "MFI: The Money Flow Index is a momentum indicator that uses both price and volume to measure buying and selling pressure.", +} + + +def get_stock_stats_indicators_window( + symbol: Annotated[str, "ticker symbol of the company"], + indicator: Annotated[str, "technical indicator to get the analysis and report of"], + curr_date: Annotated[str, "The current trading date you are trading on, YYYY-mm-dd"], + look_back_days: Annotated[int, "how many days to look back"], +) -> str: + """Technical indicator window. Reuses ``stockstats`` library on FMP OHLCV. + + This mirrors the y_finance implementation but sources bars from FMP + instead of yfinance. The heavy lifting stays in ``stockstats``. + """ + from stockstats import wrap + import pandas as pd + + desc = _INDICATOR_DESC.get(indicator) + if desc is None: + raise ValueError( + f"Indicator {indicator} is not supported. Choose from: {list(_INDICATOR_DESC.keys())}" + ) + + end_dt = datetime.strptime(curr_date, "%Y-%m-%d") + start_dt = end_dt - relativedelta(years=15) + + client = get_client() + rows = client.live_get_list( + "/historical-price-eod/full", + params={ + "symbol": symbol.upper(), + "from": start_dt.strftime("%Y-%m-%d"), + "to": end_dt.strftime("%Y-%m-%d"), + }, + ) + if not rows: + return f"No price history for {symbol} up to {curr_date}." + + df = pd.DataFrame(rows) + df["Date"] = pd.to_datetime(df["date"]) + df = df.rename(columns={ + "open": "Open", "high": "High", "low": "Low", + "close": "Close", "volume": "Volume", + }).sort_values("Date").reset_index(drop=True) + + wrapped = wrap(df.copy()) + try: + wrapped[indicator] # trigger calculation + except Exception as e: + return f"Error computing {indicator} for {symbol}: {e}" + + wrapped["DateStr"] = wrapped["Date"].dt.strftime("%Y-%m-%d") + value_map = dict(zip(wrapped["DateStr"], wrapped[indicator])) + + # Build window (inclusive of curr_date, walking back look_back_days) + before = end_dt - relativedelta(days=look_back_days) + out_lines = [] + cursor = end_dt + while cursor >= before: + k = cursor.strftime("%Y-%m-%d") + v = value_map.get(k) + if v is None: + out_lines.append(f"{k}: N/A: Not a trading day (weekend or holiday)") + elif pd.isna(v): + out_lines.append(f"{k}: N/A") + else: + out_lines.append(f"{k}: {v}") + cursor -= relativedelta(days=1) + + return ( + f"## {indicator} values from {before.strftime('%Y-%m-%d')} to {curr_date}:\n\n" + + "\n".join(out_lines) + + "\n\n" + + desc + ) + + +def get_fundamentals( + ticker: Annotated[str, "ticker symbol of the company"], + curr_date: Annotated[str, "current date"] = None, +): + """Company fundamentals overview (text).""" + info = get_ticker_info(ticker) + if not info.get("longName"): + return f"No fundamentals data found for symbol '{ticker}'" + + fields = [ + ("Name", info.get("longName")), + ("Sector", info.get("sector")), + ("Industry", info.get("industry")), + ("Market Cap", info.get("marketCap")), + ("PE Ratio (TTM)", info.get("trailingPE")), + ("Forward PE", info.get("forwardPE")), + ("PEG Ratio", info.get("pegRatio")), + ("Price to Book", info.get("priceToBook")), + ("EPS (TTM)", info.get("trailingEps")), + ("Forward EPS", info.get("forwardEps")), + ("Dividend Yield", info.get("dividendYield")), + ("Beta", info.get("beta")), + ("52 Week High", info.get("fiftyTwoWeekHigh")), + ("52 Week Low", info.get("fiftyTwoWeekLow")), + ("50 Day Average", info.get("fiftyDayAverage")), + ("200 Day Average", info.get("twoHundredDayAverage")), + ("Revenue (TTM)", info.get("totalRevenue")), + ("EBITDA", info.get("ebitda")), + ("Net Income", info.get("netIncomeToCommon")), + ("Profit Margin", info.get("profitMargins")), + ("Operating Margin", info.get("operatingMargins")), + ("Return on Equity", info.get("returnOnEquity")), + ("Return on Assets", info.get("returnOnAssets")), + ("Debt to Equity", info.get("debtToEquity")), + ("Current Ratio", info.get("currentRatio")), + ("Book Value", info.get("bookValue")), + ("Free Cash Flow", info.get("freeCashflow")), + ] + lines = [f"{label}: {value}" for label, value in fields if value is not None] + header = f"# Company Fundamentals for {ticker.upper()}\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + return header + "\n".join(lines) + + +def _statement_csv(ticker: str, endpoint: str, period: str, label: str) -> str: + """Fetch a financial statement and render as CSV (period as column headers).""" + client = get_client() + rows = client.live_get_list( + f"/{endpoint}/{ticker.upper()}", + params={"period": period, "limit": 5}, + ) + if not rows: + return f"No {label} data found for symbol '{ticker}'" + + # Use date as column header, field name as row. Matches yfinance.to_csv shape. + dates = [r.get("date") for r in rows] + keys: List[str] = [] + seen = set() + for r in rows: + for k in r.keys(): + if k in ("date", "symbol", "reportedCurrency", "cik", "fillingDate", + "acceptedDate", "calendarYear", "period", "link", "finalLink"): + continue + if k not in seen: + seen.add(k) + keys.append(k) + + out = [",".join([""] + dates)] + for k in keys: + row_vals = [str(r.get(k, "")) for r in rows] + out.append(",".join([k] + row_vals)) + + header = f"# {label} data for {ticker.upper()} ({period})\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + return header + "\n".join(out) + "\n" + + +def get_balance_sheet( + ticker: Annotated[str, "ticker symbol of the company"], + freq: Annotated[str, "'annual' or 'quarterly'"] = "quarterly", + curr_date: Annotated[str, "current date"] = None, +): + period = "quarter" if freq.lower().startswith("q") else "annual" + return _statement_csv(ticker, "balance-sheet-statement", period, "Balance Sheet") + + +def get_cashflow( + ticker: Annotated[str, "ticker symbol of the company"], + freq: Annotated[str, "'annual' or 'quarterly'"] = "quarterly", + curr_date: Annotated[str, "current date"] = None, +): + period = "quarter" if freq.lower().startswith("q") else "annual" + return _statement_csv(ticker, "cash-flow-statement", period, "Cash Flow") + + +def get_income_statement( + ticker: Annotated[str, "ticker symbol of the company"], + freq: Annotated[str, "'annual' or 'quarterly'"] = "quarterly", + curr_date: Annotated[str, "current date"] = None, +): + period = "quarter" if freq.lower().startswith("q") else "annual" + return _statement_csv(ticker, "income-statement", period, "Income Statement") + + +def get_insider_transactions( + ticker: Annotated[str, "ticker symbol of the company"], +): + """Insider transactions via FMP /insider-trading.""" + client = get_client() + rows = client.live_get_list( + "/insider-trading", + params={"symbol": ticker.upper(), "limit": 50}, + ) + if not rows: + return f"No insider transactions data found for symbol '{ticker}'" + + keys = [ + "filingDate", "transactionDate", "reportingName", "typeOfOwner", + "transactionType", "securitiesTransacted", "price", "securitiesOwned", + ] + out = [",".join(keys)] + for r in rows: + out.append(",".join(str(r.get(k, "")) for k in keys)) + + header = f"# Insider Transactions data for {ticker.upper()}\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + return header + "\n".join(out) + "\n" + + +# ══════════════════════════════════════════════════════════════════════ +# Plain functions used directly by tier1 / tier2 (no @tool decorator) +# ══════════════════════════════════════════════════════════════════════ + +def get_company_profile(ticker: str, curr_date: Optional[str] = None) -> str: + info = get_ticker_info(ticker) + if not info.get("longName"): + return json.dumps({"error": f"No data for {ticker}", "ticker": ticker}) + mc = info.get("marketCap") + profile = { + "company_name": info.get("longName", "Unknown"), + "ticker": ticker.upper(), + "sector": info.get("sector", "Unknown"), + "industry": info.get("industry", "Unknown"), + "description": info.get("longBusinessSummary", ""), + "market_cap": mc, + "market_cap_formatted": _fmt_num(mc), + "market_cap_category": _market_cap_category(mc), + "current_price": info.get("currentPrice"), + } + return json.dumps(profile, default=str) + + +def _fetch_etf_perf_fmp(symbols: List[str]) -> Dict[str, Dict[str, Any]]: + """3-month price window + 1m/3m returns for a set of ETF/index tickers.""" + client = get_client() + end = datetime.utcnow().date() + start = end - relativedelta(months=4) + out: Dict[str, Dict[str, Any]] = {} + for sym in symbols: + rows = client.live_get_list( + "/historical-price-eod/full", + params={"symbol": sym, "from": start.isoformat(), "to": end.isoformat()}, + ) + if not rows: + continue + # FMP returns most-recent-first + asc = sorted(rows, key=lambda r: r.get("date", "")) + closes = [_to_float(r.get("close")) for r in asc] + closes = [c for c in closes if c is not None] + if len(closes) < 5: + continue + current = closes[-1] + ret_1m = round((current - closes[-22]) / closes[-22] * 100, 2) if len(closes) >= 22 else None + ret_3m = round((current - closes[-63]) / closes[-63] * 100, 2) if len(closes) >= 63 else None + out[sym] = {"return_1m": ret_1m, "return_3m": ret_3m, "price": current} + return out + + +_SECTOR_ETFS = { + "SPY": "S&P 500", + "XLK": "Technology", "XLF": "Financials", "XLE": "Energy", + "XLV": "Health Care", "XLI": "Industrials", "XLY": "Consumer Discretionary", + "XLP": "Consumer Staples", "XLU": "Utilities", "XLRE": "Real Estate", + "XLB": "Materials", "XLC": "Communication Services", +} + + +def get_macro_indicators(curr_date: Optional[str] = None) -> str: + """Macro indicators: VIX, 10Y yield, sector ETF performance.""" + results: Dict[str, Any] = {} + client = get_client() + + # VIX and 10Y yield via FMP quote endpoints for indices + vix_quote = client.live_get("/quote/^VIX") + if vix_quote: + results["vix_level"] = _to_float(vix_quote.get("price")) + tnx_quote = client.live_get("/quote/^TNX") + if tnx_quote: + results["ten_year_yield"] = _to_float(tnx_quote.get("price")) + + # Sector ETF performance — Alpaca first, FMP fallback + sector_performance: Dict[str, Any] = {} + try: + from .alpaca_data import alpaca_available, get_sector_etf_performance + if alpaca_available(): + perf = get_sector_etf_performance(list(_SECTOR_ETFS.keys())) + if perf: + for sym, data in perf.items(): + sector_performance[sym] = { + "name": _SECTOR_ETFS.get(sym, sym), + "return_1m": data.get("return_1m"), + "return_3m": data.get("return_3m"), + "price": data.get("price"), + } + except Exception as e: + _logger.debug("Alpaca sector ETFs failed: %s", e) + + if not sector_performance: + perf = _fetch_etf_perf_fmp(list(_SECTOR_ETFS.keys())) + for sym, data in perf.items(): + sector_performance[sym] = { + "name": _SECTOR_ETFS.get(sym, sym), + **data, + } + + if sector_performance: + results["sector_performance"] = sector_performance + + return json.dumps(results, default=str) + + +def get_sector_rotation(ticker: str, curr_date: Optional[str] = None) -> str: + """Sector rotation data with relative performance vs SPY.""" + info = get_ticker_info(ticker) + sector = info.get("sector", "Unknown") + sector_etf = _SECTOR_ETF_MAP.get(sector) + + result: Dict[str, Any] = {"ticker": ticker.upper(), "sector": sector, "sector_etf": sector_etf} + if not sector_etf: + return json.dumps(result, default=str) + + etfs = [sector_etf, "SPY"] + perf: Dict[str, Dict[str, Any]] = {} + try: + from .alpaca_data import alpaca_available, get_sector_etf_performance + if alpaca_available(): + perf = get_sector_etf_performance(etfs) or {} + except Exception: + pass + if not perf: + perf = _fetch_etf_perf_fmp(etfs) + + spy_data = perf.get("SPY", {}) + etf_data = perf.get(sector_etf, {}) + spy_1m, spy_3m = spy_data.get("return_1m"), spy_data.get("return_3m") + etf_1m, etf_3m = etf_data.get("return_1m"), etf_data.get("return_3m") + + if etf_1m is not None and spy_1m is not None: + result["stock_sector_vs_spy_1m"] = round(etf_1m - spy_1m, 2) + if etf_3m is not None and spy_3m is not None: + result["stock_sector_vs_spy_3m"] = round(etf_3m - spy_3m, 2) + + try: + macro = json.loads(get_macro_indicators()) + sector_perf = macro.get("sector_performance", {}) + ranked = sorted( + [(s, d.get("return_1m", -999)) for s, d in sector_perf.items() if s != "SPY"], + key=lambda x: x[1], reverse=True, + ) + for i, (sym, _) in enumerate(ranked, 1): + if sym == sector_etf: + result["stock_sector_rank"] = i + result["total_sectors"] = len(ranked) + break + except Exception: + pass + + return json.dumps(result, default=str) + + +def get_institutional_flow(ticker: str) -> str: + """Institutional flow: ownership, volume, short interest, 13F holders, insiders.""" + symbol = ticker.upper() + client = get_client() + + profile = client.bulk_lookup("profile-bulk", symbol) or client.live_get( + f"/profile/{symbol}" + ) or {} + quote = client.live_get(f"/quote/{symbol}") or {} + + # Institutional ownership (percentage) + inst_ownership = client.live_get(f"/institutional-ownership/symbol-ownership", + params={"symbol": symbol, "includeCurrentQuarter": "false"}) + held_pct_inst = None + if isinstance(inst_ownership, dict): + # Already a dict (single record) + held_pct_inst = _to_float(inst_ownership.get("ownershipPercent")) + elif isinstance(inst_ownership, list) and inst_ownership: + held_pct_inst = _to_float(inst_ownership[0].get("ownershipPercent")) + + # Top institutional holders (13F) + holders_raw = client.live_get_list( + "/institutional-ownership/institutional-holders/symbol-ownership-percent", + params={"symbol": symbol}, + ) + top_holders = [] + for h in holders_raw[:10]: + top_holders.append({ + "holder": h.get("investorName") or h.get("holder") or "", + "shares": _to_float(h.get("sharesNumber") or h.get("shares")), + "pct_out": _to_float(h.get("ownershipPercent")), + "value": _to_float(h.get("marketValue")), + }) + + # Insider transactions (recent 10) + insiders_raw = client.live_get_list("/insider-trading", + params={"symbol": symbol, "limit": 20}) + buys = sum(1 for r in insiders_raw if "Purchase" in str(r.get("transactionType", ""))) + sells = sum(1 for r in insiders_raw if "Sale" in str(r.get("transactionType", ""))) + insider_signal = "buying" if buys > sells else "selling" if sells > buys else "none" + + # Short interest (FMP SEC short interest endpoint) + short_data = client.live_get(f"/short-interest", params={"symbol": symbol}) or {} + + avg_vol = _to_float(profile.get("volAvg") or quote.get("avgVolume")) + avg_vol_10d = _to_float(quote.get("avgVolume")) + float_shares = _to_float(short_data.get("floatShares")) + shares_short = _to_float(short_data.get("sharesShort")) + shares_short_prior = _to_float(short_data.get("sharesShortPriorMonth")) + + result: Dict[str, Any] = { + "ticker": symbol, + "average_volume": avg_vol, + "average_volume_10d": avg_vol_10d, + "float_shares": float_shares, + "shares_short": shares_short, + "shares_short_prior": shares_short_prior, + "short_ratio": _to_float(short_data.get("shortRatio")), + "held_percent_institutions": held_pct_inst, + "held_percent_insiders": None, # FMP doesn't expose this as a single field + "insider_buys_recent": buys, + "insider_sells_recent": sells, + "insider_transaction_signal": insider_signal, + "top_institutional_holders": top_holders, + "top_holders_count": len(top_holders), + } + + if avg_vol_10d and avg_vol and avg_vol > 0: + result["volume_ratio"] = round(avg_vol_10d / avg_vol, 2) + if float_shares and shares_short and float_shares > 0: + result["short_pct_of_float"] = round(shares_short / float_shares * 100, 2) + if shares_short is not None and shares_short_prior and shares_short_prior > 0: + pct_change = (shares_short - shares_short_prior) / shares_short_prior * 100 + result["short_interest_change_pct"] = round(pct_change, 1) + if pct_change > 5: + result["short_interest_trend"] = "rising" + elif pct_change < -5: + result["short_interest_trend"] = "falling" + else: + result["short_interest_trend"] = "stable" + if avg_vol_10d and float_shares and float_shares > 0: + result["float_turnover_5d_pct"] = round(avg_vol_10d * 5 / float_shares * 100, 2) + + return json.dumps(result, default=str) + + +def get_earnings_estimates(ticker: str) -> str: + """Earnings estimates (trailing/forward EPS, price).""" + info = get_ticker_info(ticker) + return json.dumps({ + "ticker": ticker.upper(), + "trailing_eps": info.get("trailingEps"), + "forward_eps": info.get("forwardEps"), + "current_price": info.get("currentPrice"), + "target_mean_price": info.get("targetMeanPrice"), + "target_high_price": info.get("targetHighPrice"), + "target_low_price": info.get("targetLowPrice"), + }, default=str) + + +def get_valuation_peers(ticker: str) -> str: + """Valuation metrics (P/E, PEG, P/B, EV/EBITDA, etc.).""" + info = get_ticker_info(ticker) + return json.dumps({ + "ticker": ticker.upper(), + "trailing_pe": info.get("trailingPE"), + "forward_pe": info.get("forwardPE"), + "peg_ratio": info.get("pegRatio"), + "price_to_book": info.get("priceToBook"), + "ev_to_ebitda": info.get("enterpriseToEbitda"), + "price_to_sales": info.get("priceToSales"), + "revenue_growth": info.get("revenueGrowth"), + "earnings_growth": info.get("earningsGrowth"), + }, default=str) + + +# ══════════════════════════════════════════════════════════════════════ +# News (FMP /stock-news or /stock_news — primary replacement for news paths) +# ══════════════════════════════════════════════════════════════════════ + +def get_news_fmp(ticker: str, curr_date: Optional[str] = None, look_back_days: int = 7) -> str: + client = get_client() + rows = client.live_get_list("/stock-news", params={"symbols": ticker.upper(), "limit": 30}) + if not rows: + return f"No recent news for {ticker.upper()}" + lines = [f"# Recent News for {ticker.upper()}\n"] + for r in rows[:20]: + date = r.get("publishedDate") or r.get("date") or "" + title = r.get("title") or "" + site = r.get("site") or "" + url = r.get("url") or "" + lines.append(f"- [{date[:10]}] {title} ({site}) {url}") + return "\n".join(lines) + + +def get_global_news_fmp(curr_date: Optional[str] = None) -> str: + client = get_client() + rows = client.live_get_list("/general-news", params={"limit": 30}) + if not rows: + return "No recent global news available." + lines = ["# Recent Global News\n"] + for r in rows[:20]: + date = r.get("publishedDate") or "" + title = r.get("title") or "" + site = r.get("site") or "" + url = r.get("url") or "" + lines.append(f"- [{date[:10]}] {title} ({site}) {url}") + return "\n".join(lines) diff --git a/tradingagents/dataflows/fmp_client.py b/tradingagents/dataflows/fmp_client.py new file mode 100644 index 00000000..2788a5a1 --- /dev/null +++ b/tradingagents/dataflows/fmp_client.py @@ -0,0 +1,154 @@ +"""FMP client — Postgres-bulk fast path with live API fallback. + +TradingAgents' FMP data layer. Mirrors the pattern used by +stock-screener/backend/app/services/fmp_data_service.py but lives inside +TradingAgents so this submodule doesn't depend on the parent repo's code. + +Preference order: + 1. Postgres ``fmp_bulk`` table (nightly ETL drops bulk endpoints as JSONB). + 2. FMP live API (``https://financialmodelingprep.com/stable/...``). + +Environment: + FMP_API_KEY — required for live API calls + FMP_BULK_DATABASE_URL — Postgres connection string (falls back to POSTGRES_URL) +""" +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, Iterable, List, Optional + +import requests + +logger = logging.getLogger(__name__) + +FMP_BASE_URL = "https://financialmodelingprep.com/stable" +DEFAULT_TIMEOUT_S = 30 + + +class FMPClient: + """Singleton FMP client. Use :func:`get_client` to access.""" + + _instance: Optional["FMPClient"] = None + + def __init__(self, api_key: Optional[str] = None, session: Optional[requests.Session] = None): + self.api_key = api_key or os.environ.get("FMP_API_KEY") or "" + if not self.api_key: + logger.debug("FMP_API_KEY not set — live FMP calls will fail") + self._session = session or requests.Session() + self._pg_conn = None + + # ────────────────────────────────────────────────────────────────── + # Postgres bulk lookups + # ────────────────────────────────────────────────────────────────── + + def bulk_lookup(self, bulk_name: str, primary_key: str) -> Optional[Dict[str, Any]]: + conn = self._get_pg() + if conn is None: + return None + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT data FROM fmp_bulk + WHERE bulk_name = %s AND primary_key = %s + ORDER BY captured_date DESC + LIMIT 1 + """, + (bulk_name, primary_key), + ) + row = cur.fetchone() + return row[0] if row else None + except Exception as e: + logger.debug("fmp_bulk lookup %s/%s failed: %s", bulk_name, primary_key, e) + return None + + def bulk_list(self, bulk_name: str) -> Optional[List[Dict[str, Any]]]: + conn = self._get_pg() + if conn is None: + return None + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT DISTINCT ON (primary_key) data FROM fmp_bulk + WHERE bulk_name = %s + ORDER BY primary_key, captured_date DESC + """, + (bulk_name,), + ) + return [r[0] for r in cur.fetchall()] + except Exception as e: + logger.debug("fmp_bulk list %s failed: %s", bulk_name, e) + return None + + # ────────────────────────────────────────────────────────────────── + # Live API + # ────────────────────────────────────────────────────────────────── + + def live_get( + self, + path: str, + params: Optional[Dict[str, Any]] = None, + unwrap_single: bool = True, + ) -> Any: + """Call an FMP /stable/* endpoint. Returns parsed JSON or None on error. + + If ``unwrap_single`` is True (default), a list result is unwrapped to + its first element (or None if empty) — matches the common pattern + of single-symbol endpoints wrapping their response in a 1-element list. + """ + params = dict(params or {}) + params["apikey"] = self.api_key + url = f"{FMP_BASE_URL}{path}" + try: + resp = self._session.get(url, params=params, timeout=DEFAULT_TIMEOUT_S) + resp.raise_for_status() + body = resp.json() + except requests.RequestException as e: + logger.debug("FMP %s failed: %s", path, e) + return None + if unwrap_single and isinstance(body, list): + return body[0] if body else None + return body + + def live_get_list( + self, + path: str, + params: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + """Call an FMP endpoint that returns a JSON array. Empty list on error.""" + result = self.live_get(path, params=params, unwrap_single=False) + return result if isinstance(result, list) else [] + + # ────────────────────────────────────────────────────────────────── + # Helpers + # ────────────────────────────────────────────────────────────────── + + def _get_pg(self): + if self._pg_conn is not None: + try: + # Detect stale connection + if getattr(self._pg_conn, "closed", 0): + self._pg_conn = None + else: + return self._pg_conn + except Exception: + self._pg_conn = None + url = os.environ.get("FMP_BULK_DATABASE_URL") or os.environ.get("POSTGRES_URL") + if not url: + return None + try: + import psycopg2 + self._pg_conn = psycopg2.connect(url) + self._pg_conn.autocommit = True + return self._pg_conn + except Exception as e: + logger.debug("psycopg2 connect failed: %s", e) + return None + + +def get_client() -> FMPClient: + if FMPClient._instance is None: + FMPClient._instance = FMPClient() + return FMPClient._instance diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py index 8c840974..248ee0d1 100644 --- a/tradingagents/dataflows/interface.py +++ b/tradingagents/dataflows/interface.py @@ -1,162 +1,185 @@ -from typing import Annotated - -# Import from vendor-specific modules -from .y_finance import ( - get_YFin_data_online, - get_stock_stats_indicators_window, - get_fundamentals as get_yfinance_fundamentals, - get_balance_sheet as get_yfinance_balance_sheet, - get_cashflow as get_yfinance_cashflow, - get_income_statement as get_yfinance_income_statement, - get_insider_transactions as get_yfinance_insider_transactions, -) -from .yfinance_news import get_news_yfinance, get_global_news_yfinance -from .alpha_vantage import ( - get_stock as get_alpha_vantage_stock, - get_indicator as get_alpha_vantage_indicator, - get_fundamentals as get_alpha_vantage_fundamentals, - get_balance_sheet as get_alpha_vantage_balance_sheet, - get_cashflow as get_alpha_vantage_cashflow, - get_income_statement as get_alpha_vantage_income_statement, - get_insider_transactions as get_alpha_vantage_insider_transactions, - get_news as get_alpha_vantage_news, - get_global_news as get_alpha_vantage_global_news, -) -from .alpha_vantage_common import AlphaVantageRateLimitError - -# Configuration and routing logic -from .config import get_config - -# Tools organized by category -TOOLS_CATEGORIES = { - "core_stock_apis": { - "description": "OHLCV stock price data", - "tools": [ - "get_stock_data" - ] - }, - "technical_indicators": { - "description": "Technical analysis indicators", - "tools": [ - "get_indicators" - ] - }, - "fundamental_data": { - "description": "Company fundamentals", - "tools": [ - "get_fundamentals", - "get_balance_sheet", - "get_cashflow", - "get_income_statement" - ] - }, - "news_data": { - "description": "News and insider data", - "tools": [ - "get_news", - "get_global_news", - "get_insider_transactions", - ] - } -} - -VENDOR_LIST = [ - "yfinance", - "alpha_vantage", -] - -# Mapping of methods to their vendor-specific implementations -VENDOR_METHODS = { - # core_stock_apis - "get_stock_data": { - "alpha_vantage": get_alpha_vantage_stock, - "yfinance": get_YFin_data_online, - }, - # technical_indicators - "get_indicators": { - "alpha_vantage": get_alpha_vantage_indicator, - "yfinance": get_stock_stats_indicators_window, - }, - # fundamental_data - "get_fundamentals": { - "alpha_vantage": get_alpha_vantage_fundamentals, - "yfinance": get_yfinance_fundamentals, - }, - "get_balance_sheet": { - "alpha_vantage": get_alpha_vantage_balance_sheet, - "yfinance": get_yfinance_balance_sheet, - }, - "get_cashflow": { - "alpha_vantage": get_alpha_vantage_cashflow, - "yfinance": get_yfinance_cashflow, - }, - "get_income_statement": { - "alpha_vantage": get_alpha_vantage_income_statement, - "yfinance": get_yfinance_income_statement, - }, - # news_data - "get_news": { - "alpha_vantage": get_alpha_vantage_news, - "yfinance": get_news_yfinance, - }, - "get_global_news": { - "yfinance": get_global_news_yfinance, - "alpha_vantage": get_alpha_vantage_global_news, - }, - "get_insider_transactions": { - "alpha_vantage": get_alpha_vantage_insider_transactions, - "yfinance": get_yfinance_insider_transactions, - }, -} - -def get_category_for_method(method: str) -> str: - """Get the category that contains the specified method.""" - for category, info in TOOLS_CATEGORIES.items(): - if method in info["tools"]: - 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. - """ - config = get_config() - - # Check tool-level configuration first (if method provided) - if method: - tool_vendors = config.get("tool_vendors", {}) - if method in tool_vendors: - return tool_vendors[method] - - # 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(',')] - - if method not in VENDOR_METHODS: - raise ValueError(f"Method '{method}' not supported") - - # Build fallback chain: primary vendors first, then remaining available vendors - all_available_vendors = list(VENDOR_METHODS[method].keys()) - fallback_vendors = primary_vendors.copy() - for vendor in all_available_vendors: - if vendor not in fallback_vendors: - fallback_vendors.append(vendor) - - for vendor in fallback_vendors: - if vendor not in VENDOR_METHODS[method]: - continue - - vendor_impl = VENDOR_METHODS[method][vendor] - impl_func = vendor_impl[0] if isinstance(vendor_impl, list) else vendor_impl - - try: - return impl_func(*args, **kwargs) - except AlphaVantageRateLimitError: - continue # Only rate limits trigger fallback - - raise RuntimeError(f"No available vendor for '{method}'") \ No newline at end of file +from typing import Annotated + +# Import from vendor-specific modules +from .y_finance import ( + get_YFin_data_online, + get_stock_stats_indicators_window, + get_fundamentals as get_yfinance_fundamentals, + get_balance_sheet as get_yfinance_balance_sheet, + get_cashflow as get_yfinance_cashflow, + get_income_statement as get_yfinance_income_statement, + get_insider_transactions as get_yfinance_insider_transactions, +) +from .yfinance_news import get_news_yfinance, get_global_news_yfinance +from .alpha_vantage import ( + get_stock as get_alpha_vantage_stock, + get_indicator as get_alpha_vantage_indicator, + get_fundamentals as get_alpha_vantage_fundamentals, + get_balance_sheet as get_alpha_vantage_balance_sheet, + get_cashflow as get_alpha_vantage_cashflow, + get_income_statement as get_alpha_vantage_income_statement, + get_insider_transactions as get_alpha_vantage_insider_transactions, + get_news as get_alpha_vantage_news, + get_global_news as get_alpha_vantage_global_news, +) +from .alpha_vantage_common import AlphaVantageRateLimitError +from .fmp import ( + get_YFin_data_online as get_fmp_stock, + get_stock_stats_indicators_window as get_fmp_indicator, + get_fundamentals as get_fmp_fundamentals, + get_balance_sheet as get_fmp_balance_sheet, + get_cashflow as get_fmp_cashflow, + get_income_statement as get_fmp_income_statement, + get_insider_transactions as get_fmp_insider_transactions, + get_news_fmp, + get_global_news_fmp, +) + +# Configuration and routing logic +from .config import get_config + +# Tools organized by category +TOOLS_CATEGORIES = { + "core_stock_apis": { + "description": "OHLCV stock price data", + "tools": [ + "get_stock_data" + ] + }, + "technical_indicators": { + "description": "Technical analysis indicators", + "tools": [ + "get_indicators" + ] + }, + "fundamental_data": { + "description": "Company fundamentals", + "tools": [ + "get_fundamentals", + "get_balance_sheet", + "get_cashflow", + "get_income_statement" + ] + }, + "news_data": { + "description": "News and insider data", + "tools": [ + "get_news", + "get_global_news", + "get_insider_transactions", + ] + } +} + +VENDOR_LIST = [ + "fmp", + "yfinance", + "alpha_vantage", +] + +# Mapping of methods to their vendor-specific implementations. +# Ordering in each dict matters only for fallback iteration (see route_to_vendor); +# the primary vendor is chosen via config. +VENDOR_METHODS = { + # core_stock_apis + "get_stock_data": { + "fmp": get_fmp_stock, + "yfinance": get_YFin_data_online, + "alpha_vantage": get_alpha_vantage_stock, + }, + # technical_indicators + "get_indicators": { + "fmp": get_fmp_indicator, + "yfinance": get_stock_stats_indicators_window, + "alpha_vantage": get_alpha_vantage_indicator, + }, + # fundamental_data + "get_fundamentals": { + "fmp": get_fmp_fundamentals, + "yfinance": get_yfinance_fundamentals, + "alpha_vantage": get_alpha_vantage_fundamentals, + }, + "get_balance_sheet": { + "fmp": get_fmp_balance_sheet, + "yfinance": get_yfinance_balance_sheet, + "alpha_vantage": get_alpha_vantage_balance_sheet, + }, + "get_cashflow": { + "fmp": get_fmp_cashflow, + "yfinance": get_yfinance_cashflow, + "alpha_vantage": get_alpha_vantage_cashflow, + }, + "get_income_statement": { + "fmp": get_fmp_income_statement, + "yfinance": get_yfinance_income_statement, + "alpha_vantage": get_alpha_vantage_income_statement, + }, + # news_data + "get_news": { + "fmp": get_news_fmp, + "yfinance": get_news_yfinance, + "alpha_vantage": get_alpha_vantage_news, + }, + "get_global_news": { + "fmp": get_global_news_fmp, + "yfinance": get_global_news_yfinance, + "alpha_vantage": get_alpha_vantage_global_news, + }, + "get_insider_transactions": { + "fmp": get_fmp_insider_transactions, + "yfinance": get_yfinance_insider_transactions, + "alpha_vantage": get_alpha_vantage_insider_transactions, + }, +} + +def get_category_for_method(method: str) -> str: + """Get the category that contains the specified method.""" + for category, info in TOOLS_CATEGORIES.items(): + if method in info["tools"]: + 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. + """ + config = get_config() + + # Check tool-level configuration first (if method provided) + if method: + tool_vendors = config.get("tool_vendors", {}) + if method in tool_vendors: + return tool_vendors[method] + + # 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(',')] + + if method not in VENDOR_METHODS: + raise ValueError(f"Method '{method}' not supported") + + # Build fallback chain: primary vendors first, then remaining available vendors + all_available_vendors = list(VENDOR_METHODS[method].keys()) + fallback_vendors = primary_vendors.copy() + for vendor in all_available_vendors: + if vendor not in fallback_vendors: + fallback_vendors.append(vendor) + + for vendor in fallback_vendors: + if vendor not in VENDOR_METHODS[method]: + continue + + vendor_impl = VENDOR_METHODS[method][vendor] + impl_func = vendor_impl[0] if isinstance(vendor_impl, list) else vendor_impl + + try: + return impl_func(*args, **kwargs) + except AlphaVantageRateLimitError: + continue # Only rate limits trigger fallback + + raise RuntimeError(f"No available vendor for '{method}'") diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py index 2fb59d1a..1d24f01d 100644 --- a/tradingagents/dataflows/y_finance.py +++ b/tradingagents/dataflows/y_finance.py @@ -1,16 +1,18 @@ """ -DEPRECATED — yfinance is scheduled for removal in the FMP-primary migration. +DEPRECATED — yfinance is the secondary fallback path only. -This module is the Alpaca-fallback path for TradingAgents data. It is -still wired in (tier1 + tier2 + interface.py reference it) and too -large to stub safely without rewiring the structured analysts, so it -stays live for now. Track: replace with calls through -FMPDataService (see stock-screener/backend/docs/FMP_MIGRATION.md for -the pattern) and route `interface.py` to FMP-first instead. +FMP (see ``fmp.py``) is the primary data source for TradingAgents as +of the 2026-04-22 migration: + - ``interface.py``'s VENDOR_METHODS lists ``fmp`` first, with yfinance + retained in the fallback chain. + - ``tier1.py``, ``tier2.py``, and ``portfolio.py`` import from ``.fmp``; + no agent code calls this module directly anymore. -Callers: tradingagents/agents/structured/tier1.py, -tradingagents/agents/structured/tier2.py, -tradingagents/test.py +This module stays live so that (a) the fallback chain still has a +working yfinance branch for transient FMP outages, and (b) the +``yfinance_news`` module still uses ``yf.Ticker`` internally for news. +Once FMP's news coverage proves sufficient in production, this whole +file and its yfinance dependency can be removed. """ from typing import Annotated from datetime import datetime diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 700bffc4..ace9240b 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -20,12 +20,13 @@ DEFAULT_CONFIG = { "max_risk_discuss_rounds": 1, "max_recur_limit": 100, # Data vendor configuration - # Category-level configuration (default for all tools in category) + # Category-level configuration (default for all tools in category). + # FMP is primary; yfinance/alpha_vantage kept in the fallback chain only. "data_vendors": { - "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance - "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance - "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance - "news_data": "yfinance", # Options: alpha_vantage, yfinance + "core_stock_apis": "fmp", # Options: fmp, yfinance, alpha_vantage + "technical_indicators": "fmp", # Options: fmp, yfinance, alpha_vantage + "fundamental_data": "fmp", # Options: fmp, yfinance, alpha_vantage + "news_data": "fmp", # Options: fmp, yfinance, alpha_vantage }, # Tool-level configuration (takes precedence over category-level) "tool_vendors": {