refactor(dataflows): FMP-primary data source; y_finance fallback-only

- New fmp_client.py — Postgres fmp_bulk fast path + live /stable/* fallback
- New fmp.py — FMP-backed public API matching y_finance shapes, plus
  get_ticker_info() returning a yfinance-Ticker.info-shaped dict so
  downstream .get(key) lookups work unchanged
- interface.py VENDOR_METHODS: fmp added as primary for every tool;
  VENDOR_LIST now [fmp, yfinance, alpha_vantage]
- default_config.py: all four data_vendors categories -> fmp
- tier1/tier2/portfolio: drop direct yfinance imports; yf.Ticker(t).info
  -> fmp.get_ticker_info(t); plain-function imports rerouted to .fmp
- y_finance.py: docstring flipped to fallback-only
- macro_data_tools.py deleted (orphaned 594-LOC @tool module — not
  imported anywhere, agent_utils.py wires its four siblings but skipped
  this one, so @tool versions never bound)

Zero live yf.Ticker / yf.download calls remain in the TradingAgents
execution graph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dtarkent2-sys 2026-04-22 08:15:53 -04:00
parent dcc98a7136
commit ad06448fda
9 changed files with 1258 additions and 859 deletions

View File

@ -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:

View File

@ -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.

View File

@ -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.

View File

@ -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)}"})

View File

@ -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)

View File

@ -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

View File

@ -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}'")
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}'")

View File

@ -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

View File

@ -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": {