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:
parent
dcc98a7136
commit
ad06448fda
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}"})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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}'")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue