diff --git a/.gitignore b/.gitignore index c4d8c478cd8c0d73eb616a42347f3efaafb18844..fbaec21e6d57a0ac63058781576b5b1b34a4e996 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,11 @@ deploy.sh # Cache .cache/ *.cache +.venv* +.obsidian/ +*.pyc +__pycache__/ +.env +reports/ +AAP*_data.txt +.venv-sentiment/ diff --git a/.obsidian/app.json b/.obsidian/app.json new file mode 100644 index 0000000000000000000000000000000000000000..e609a07e76e130678df3954958f1fd1d8039d314 --- /dev/null +++ b/.obsidian/app.json @@ -0,0 +1,3 @@ +{ + "promptDelete": false +} \ No newline at end of file diff --git a/.obsidian/appearance.json b/.obsidian/appearance.json new file mode 100644 index 0000000000000000000000000000000000000000..4be796930b138486378ed536558e1109ef148421 --- /dev/null +++ b/.obsidian/appearance.json @@ -0,0 +1,3 @@ +{ + "theme": "obsidian" +} \ No newline at end of file diff --git a/.obsidian/core-plugins.json b/.obsidian/core-plugins.json new file mode 100644 index 0000000000000000000000000000000000000000..639b90da7172bebbc1a65e30ed0a1c611b5b26dc --- /dev/null +++ b/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": true, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} \ No newline at end of file diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json new file mode 100644 index 0000000000000000000000000000000000000000..83f7e297399507d22e03ed6bd9962efb4870649a --- /dev/null +++ b/.obsidian/workspace.json @@ -0,0 +1,224 @@ +{ + "main": { + "id": "5ba76909b4eb5568", + "type": "split", + "children": [ + { + "id": "3ae386f0468a33a2", + "type": "tabs", + "children": [ + { + "id": "369b0d31e1527f56", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "docs/mcp_raw_visa.md", + "mode": "source", + "source": false + }, + "icon": "lucide-file", + "title": "mcp_raw_visa" + } + } + ] + } + ], + "direction": "vertical" + }, + "left": { + "id": "b2599d2c6c63c05b", + "type": "split", + "children": [ + { + "id": "546864a95c63f5d3", + "type": "tabs", + "children": [ + { + "id": "84981946f0bd4dc4", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "byModifiedTime", + "autoReveal": false + }, + "icon": "lucide-folder-closed", + "title": "Files" + } + }, + { + "id": "713d96cfe76a5528", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + }, + "icon": "lucide-search", + "title": "Search" + } + }, + { + "id": "aa54f7dbd5c02255", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {}, + "icon": "lucide-bookmark", + "title": "Bookmarks" + } + } + ] + } + ], + "direction": "horizontal", + "width": 249.5 + }, + "right": { + "id": "9564af0c9ac372a6", + "type": "split", + "children": [ + { + "id": "5a247df6660da061", + "type": "tabs", + "children": [ + { + "id": "9203f35f45682bb1", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "file": "docs/mcp_data_sources.md", + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-coming-in", + "title": "Backlinks for mcp_data_sources" + } + }, + { + "id": "0da6d740aca962b3", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "file": "docs/mcp_data_sources.md", + "linksCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-going-out", + "title": "Outgoing links from mcp_data_sources" + } + }, + { + "id": "028acb36fb7ac837", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-tags", + "title": "Tags" + } + }, + { + "id": "e2d887d8aa7fda47", + "type": "leaf", + "state": { + "type": "all-properties", + "state": { + "sortOrder": "frequency", + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-archive", + "title": "All properties" + } + }, + { + "id": "3270af983d2799b6", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "file": "docs/mcp_data_sources.md", + "followCursor": false, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-list", + "title": "Outline of mcp_data_sources" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300, + "collapsed": true + }, + "left-ribbon": { + "hiddenItems": { + "switcher:Open quick switcher": false, + "graph:Open graph view": false, + "canvas:Create new canvas": false, + "daily-notes:Open today's daily note": false, + "templates:Insert template": false, + "command-palette:Open command palette": false, + "bases:Create new base": false + } + }, + "active": "369b0d31e1527f56", + "lastOpenFiles": [ + "docs/mcp_test_report_KO.md", + "docs/metrics_schema_human_readable.md", + "docs/metrics_schema_emitted.md", + "docs/schema_normalization.md", + "mcp_client.py.tmp.1610.1768140230378", + "mcp_client.py.tmp.1610.1768140224277", + "mcp_client.py.tmp.1610.1768140212665", + "mcp_client.py.tmp.1610.1768140199252", + "mcp_client.py.tmp.1610.1768140165633", + "mcp_client.py.tmp.1610.1768140149514", + "mcp_client.py.tmp.1610.1768140127411", + "mcp_client.py.tmp.1610.1768140120176", + "mcp_client.py.tmp.1610.1768140113269", + "mcp_client.py.tmp.1610.1768139353083", + "docs/mcp_raw_visa.md", + "docs/alphavantage_data_schema.md", + "docs/mcp_data_sources.md", + "docs/mcp_normalized_schemas.md", + "docs/universal_metrics_schema.md", + "docs/mcp_test_report_BAC.md", + "docs/mcp_output_visa.md", + "docs/mcp_output_citigroup.md", + "docs/data_flow.md", + "docs/volatility_data_schema.md", + "docs/SEC_EDGAR_data_schema.md", + "docs/tavily_data_schema.md", + "docs/vader_data_schema.md", + "docs/reddit_data_schema.md", + "docs/finnhub_data_schema.md", + "docs/yahoo_options_data_schema.md", + "docs/yahoo_data_schema.md", + "docs/mcp_output_sample.md", + "docs/fundamentals_data_schema.md", + "docs/valuation_data_schema.md", + "mcp-servers/fundamentals-basket/mcp_sec_edgar.md" + ] +} \ No newline at end of file diff --git a/AAPL_data.txt b/AAPL_data.txt new file mode 100644 index 0000000000000000000000000000000000000000..646096f066854f6574ab37d89169917a671729ba --- /dev/null +++ b/AAPL_data.txt @@ -0,0 +1,168 @@ + ========================================================================================== + FINANCIALS - SEC EDGAR + Yahoo Finance + ========================================================================================== + +----------------------+--------------+--------------+------+ + | Metric | Value | Source | Form | + +----------------------+--------------+--------------+------+ + | revenue | 265595000000 | SEC (FY2018) | 10-K | + | net_income | 112010000000 | SEC (FY2025) | 10-K | + | gross_profit | 195201000000 | SEC (FY2025) | 10-K | + | operating_income | 133050000000 | SEC (FY2025) | 10-K | + | gross_margin_pct | 73.5000 | SEC (FY2018) | 10-K | + | operating_margin_pct | 50.1000 | SEC (FY2018) | 10-K | + | net_margin_pct | 42.1700 | SEC (FY2018) | 10-K | + | total_assets | 359241000000 | SEC (FY2025) | 10-K | + | total_liabilities | 285508000000 | SEC (FY2025) | 10-K | + | stockholders_equity | 73733000000 | SEC (FY2025) | 10-K | + | long_term_debt | 90678000000 | SEC (FY2025) | 10-K | + | total_debt | 90678000000 | SEC (FY2025) | 10-K | + | cash | 35934000000 | SEC (FY2025) | 10-K | + | net_debt | 54744000000 | SEC (FY2025) | 10-K | + | debt_to_equity | 1.2300 | SEC (FY2025) | 10-K | + | operating_cash_flow | 111482000000 | SEC (FY2025) | 10-K | + | capital_expenditure | 12715000000 | SEC (FY2025) | 10-K | + | free_cash_flow | 98767000000 | SEC (FY2025) | 10-K | + | rd_expense | 34550000000 | SEC (FY2025) | 10-K | + +----------------------+--------------+--------------+------+ + + Fetching valuation... + + ========================================================================================== + VALUATION - Yahoo Finance + Alpha Vantage + ========================================================================================== + +------------------+----------+--------------+ + | Metric | Value | Source | + +------------------+----------+--------------+ + | current_price | 259.3700 | Yahoo | + | market_cap | 3832.54B | Yahoo | + | enterprise_value | 3889.34B | Yahoo | + | trailing_pe | 34.7216 | Yahoo | + | forward_pe | 28.3417 | Yahoo | + | ps_ratio | 9.2093 | Yahoo | + | pb_ratio | 51.9675 | Yahoo | + | ev_ebitda | 26.8700 | Yahoo | + | trailing_peg | 2.6214 | Yahoo | + | forward_peg | 0.3108 | Yahoo | + | earnings_growth | 0.9120 | Yahoo | + | revenue_growth | 0.0790 | Yahoo | + | current_price | 273.0100 | AlphaVantage | + | market_cap | 3844.25B | AlphaVantage | + | trailing_pe | 34.7200 | AlphaVantage | + | forward_pe | 31.4500 | AlphaVantage | + | ps_ratio | 9.2400 | AlphaVantage | + | pb_ratio | 52.1700 | AlphaVantage | + | ev_ebitda | 26.8800 | AlphaVantage | + | trailing_peg | 2.6350 | AlphaVantage | + | earnings_growth | 0.9120 | AlphaVantage | + | revenue_growth | 0.0790 | AlphaVantage | + +------------------+----------+--------------+ + + Fetching volatility... + + ========================================================================================== + VOLATILITY - Yahoo Finance + CBOE + ========================================================================================== + +-----------------------+---------+--------+ + | Metric | Value | Source | + +-----------------------+---------+--------+ + | beta | 1.2930 | Yahoo | + | historical_volatility | 12.3300 | Yahoo | + | implied_volatility | 30.0000 | Yahoo | + +-----------------------+---------+--------+ + + Fetching macro... + + ========================================================================================== + MACRO - FRED Economic Data + ========================================================================================== + +---------------+--------+--------+------------+ + | Indicator | Value | Source | Date | + +---------------+--------+--------+------------+ + | gdp_growth | 4.3000 | FRED | 2025-07-01 | + | cpi_inflation | 2.7100 | FRED | 2025-11-01 | + | unemployment | 4.4000 | FRED | 2025-12-01 | + | interest_rate | 3.7200 | FRED | 2025-12-01 | + +---------------+--------+--------+------------+ + + Fetching news... + + ========================================================================================== + NEWS - Tavily+NYT+NewsAPI (Raw:10 Deduped:10) + ========================================================================================== + +----------------------------------------------------+--------------+------------+ + | Title | Source | Date | + +----------------------------------------------------+--------------+------------+ + | Google parent hits $3.88 trillion m-cap, edges pas | The Times of | 2026-01-08 | + | Apple (NASDAQ: AAPL) Stock Price Prediction and Fo | Biztoc.com | 2026-01-07 | + | With Warren Buffett Done as CEO, Just 3 Stocks Mak | 24/7 Wall St | 2026-01-06 | + | Read the Ruling | New York Tim | 2025-04-17 | + | Read the Lawsuit Against Apple | New York Tim | 2024-03-21 | + | The Numbers Behind Walmart’s Pay Raise: DealBook B | New York Tim | 2018-01-11 | + | APPLE INC. (AAPL) Stock, Price, News, Quotes, Fore | - | None | + | Apple Inc. Stock Quote (U.S.: Nasdaq) - AAPL | - | None | + | Apple Inc. (AAPL) Stock Price, News, Quote & Histo | - | None | + | Apple Inc. (AAPL) Stock Price Today - WSJ | - | None | + +----------------------------------------------------+--------------+------------+ + + Fetching sentiment... + + ========================================================================================== + SENTIMENT SUMMARY + ========================================================================================== + +------------------+---------------------------+------------+ + | Metric | Value | Source | + +------------------+---------------------------+------------+ + | Composite Score | 65.09 | weighted | + | Interpretation | Overall Bullish Sentiment | - | + | SWOT Category | STRENGTH | - | + | VADER Score | 49 | news_vader | + | VADER Articles | 20 | news_vader | + | Finnhub Score | 70.18 | finnhub | + | Finnhub Articles | 50 | finnhub | + | Reddit Score | 81.44 | reddit | + | Reddit Posts | 12 | reddit | + | Reddit Upvotes | 6165 | reddit | + +------------------+---------------------------+------------+ + + ========================================================================================== + SENTIMENT - Finnhub Articles (Top 15) + ========================================================================================== + +--------------------------------------------------+--------+--------+ + | Headline | Score | Source | + +--------------------------------------------------+--------+--------+ + | Jamie Dimon's Grip On US Credit Card Dominance G | 0.929 | Yahoo | + | Stifel Canada Hikes Aritzia Price Target to $150 | 0.296 | Yahoo | + | CIO survey ranks Microsoft and OpenAI as AI lead | 0.778 | Yahoo | + | 3 No-Brainer Tech Stocks to Buy Right Now | 0.421 | Yahoo | + | Apple CEO Pay Dips as Board Opposes China Audit | -0.103 | Yahoo | + | XAI Limits Grok Images After Uproar Over Sexuali | 0.0 | Yahoo | + | TSMC's December Dip Isn't the Real Story | 0.44 | Yahoo | + | Intel Rises After Trump Meeting as Markets Await | 0.421 | Yahoo | + | Goldman Projects 46-Cent EPS Gain in Q4 From App | 0.727 | Yahoo | + | Jim Cramer Says Insider Buying in Nike Signals “ | 0.908 | Yahoo | + | Apple (AAPL)’s “the Winner With Gemini,” Says Ji | 0.718 | Yahoo | + | Jim Cramer Says “Apple Deserves a Premium Multip | -0.25 | Yahoo | + | Alphabet (GOOGL) Has a Special Relationship With | 0.844 | Yahoo | + | Corning (GLW)’s a “Potential Winner,” Says Jim C | 0.511 | Yahoo | + | Apple Stock Continues Its Brutal Losing Streak. | -0.691 | Yahoo | + +--------------------------------------------------+--------+--------+ + + ========================================================================================== + SENTIMENT - Reddit Posts + ========================================================================================== + +--------------------------------------------------+--------+---------+ + | Title | Score | Upvotes | + +--------------------------------------------------+--------+---------+ + | AAPL will revisit $275 in the next 2 weeks | 0.0 | 76 | + | Apple admits defeat. Puts on Tim Cook, Calls on | 0.807 | 1630 | + | 🐻 in the town | 0.361 | 1777 | + | Tech YOLO | 0.161 | 47 | + | Apple 300 after er | 0.34 | 24 | + | Could Apple's stock price really drop to $200? I | 0.762 | 0 | + | AAPL price debate: still a “safe premium” or qui | 0.813 | 17 | + | Google has overtaken Apple's market cap, becomin | 0.7 | 2189 | + | r/Stocks Daily Discussion & Fundamentals Fri | 0.214 | 17 | + | JPMorgan Chase Reaches a Deal to Take Over the A | 0.942 | 378 | + | Back to Investing after years off the markets. | -0.296 | 10 | + | What happened to Mondelez International in last | -0.895 | 0 | + +--------------------------------------------------+--------+---------+ \ No newline at end of file diff --git a/configs/__init__.py b/configs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c422fb8eb115df27690f852b427fe24fd563691b --- /dev/null +++ b/configs/__init__.py @@ -0,0 +1,2 @@ +from .company_name_filters import clean_company_name, COMPANY_SUFFIXES, COMPANY_PREFIXES +from .output_schemas import OUTPUT_SCHEMAS, get_schema_string, get_all_schema_strings diff --git a/configs/company_name_filters.py b/configs/company_name_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..d108a6377109733e0bebb6fae1fe6bf608f3871d --- /dev/null +++ b/configs/company_name_filters.py @@ -0,0 +1,65 @@ +""" +Company name normalization filters. +Add suffixes/prefixes here as new edge cases are discovered. +""" + +# Suffixes to strip from company names (order matters - longer first) +COMPANY_SUFFIXES = [ + " Corporation", + " Incorporated", + " Technologies", + " Technology", + " International", + " Platforms", + " Holdings", + " Company", + " Limited", + " Group", + " Inc.", + " Inc", + " Ltd.", + " Ltd", + " LLC", + " L.P.", + " Co.", + " Corp.", + " Corp", + " PLC", + " N.V.", + " S.A.", + " AG", +] + +# Prefixes to strip (if any) +COMPANY_PREFIXES = [ + "The ", +] + + +def clean_company_name(name: str) -> str: + """ + Remove common corporate suffixes/prefixes for better search matching. + e.g., "NVIDIA Corporation" -> "NVIDIA" + "Meta Platforms, Inc." -> "Meta" + """ + result = name + + # Strip prefixes + for prefix in COMPANY_PREFIXES: + if result.startswith(prefix): + result = result[len(prefix):] + + # Clean punctuation first (commas interfere with suffix matching) + result = result.replace(",", "").strip() + + # Strip suffixes iteratively (handles "Meta Platforms Inc" -> "Meta") + changed = True + while changed: + changed = False + for suffix in COMPANY_SUFFIXES: + if result.endswith(suffix): + result = result[: -len(suffix)].strip() + changed = True + break + + return result diff --git a/configs/output_schemas.py b/configs/output_schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..9f40923e9e70cf4f7006943ab806cf051ea684d1 --- /dev/null +++ b/configs/output_schemas.py @@ -0,0 +1,48 @@ +""" +Output schemas for MCP data presentation in markdown files. +These define the field structure for each section in mcp_output_xxx.md files. +""" + +OUTPUT_SCHEMAS = { + "company_details": { + "fields": ["longName", "address1", "city", "state", "zip", "country", "sector", "industry"], + "description": "Company profile and classification" + }, + "fundamentals": { + "fields": ["value", "data_type", "end_date", "filed", "fiscal_year", "form"], + "description": "Financial statement metrics from SEC EDGAR and Yahoo Finance" + }, + "valuation": { + "fields": ["value", "data_type", "as_of"], + "description": "Valuation ratios and multiples" + }, + "volatility": { + "fields": ["value", "data_type", "as_of", "source", "fallback"], + "description": "Market volatility and risk metrics" + }, + "macro": { + "fields": ["value", "data_type", "as_of", "source"], + "description": "Macroeconomic indicators" + }, + "news": { + "fields": ["title", "content", "date", "url", "source"], + "description": "News articles from financial sources" + }, + "sentiment": { + "fields": ["title", "content", "date", "url", "source", "subreddit"], + "description": "Sentiment data from Finnhub and Reddit" + }, +} + + +def get_schema_string(section: str) -> str: + """Get schema as formatted string for markdown display.""" + schema = OUTPUT_SCHEMAS.get(section) + if not schema: + return "" + return "{ " + ", ".join(schema["fields"]) + " }" + + +def get_all_schema_strings() -> dict: + """Get all schemas as formatted strings.""" + return {key: get_schema_string(key) for key in OUTPUT_SCHEMAS} diff --git a/docs/SEC_EDGAR_data_schema.md b/docs/SEC_EDGAR_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..d11fd09d0de2a63f9a1d2ac22ca159677433e91f --- /dev/null +++ b/docs/SEC_EDGAR_data_schema.md @@ -0,0 +1,64 @@ +SEC EDGAR Data Schema +===================== + +Example: AAPL (Apple Inc) - CIK 0000320193 + +Raw field meanings: + +| Field | Description | +|-------|-----------------------------------| +| val | Raw value (in USD) | +| end | Period end date | +| fy | Fiscal year | +| fp | Fiscal period (FY, Q1, Q2, Q3) | +| form | Filing type (10-K, 10-Q) | +| filed | Filing date | +| accn | SEC accession number | +| frame | Calendar frame (CY2025, CY2025Q2) | + +Common fields (same across all metrics): + +| Field | Value | +|-------|----------------------| +| end | 2025-09-27 | +| fy | 2025 | +| fp | FY | +| form | 10-K | +| filed | 2025-10-31 | +| accn | 0000320193-25-000079 | + + +Metrics +------- + +Income Statement + +| name | val | frame | +|------------------|--------------|--------| +| revenue | 416161000000 | CY2025 | +| gross_profit | 195201000000 | CY2025 | +| operating_income | 133050000000 | CY2025 | +| net_income | 112010000000 | CY2025 | + +Balance Sheet + +| name | val | frame | +|---------------------|--------------|-----------| +| total_assets | 359241000000 | CY2025Q3I | +| cash | 35934000000 | CY2025Q3I | +| total_liabilities | 285508000000 | CY2025Q3I | +| long_term_debt | 90678000000 | CY2025Q3I | +| stockholders_equity | 73733000000 | CY2025Q3I | + +Cash Flow + +| name | val | frame | +|---------------------|--------------|--------| +| operating_cash_flow | 111482000000 | CY2025 | +| capital_expenditure | 12715000000 | CY2025 | + +Expenses + +| name | val | frame | +|------------|-------------|--------| +| rd_expense | 34550000000 | CY2025 | diff --git a/docs/alphavantage_data_schema.md b/docs/alphavantage_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..5c6c137eb8a26d76864899db149bb04695fb5991 --- /dev/null +++ b/docs/alphavantage_data_schema.md @@ -0,0 +1,77 @@ +Alpha Vantage Data Schema +========================= + +Example: AAPL (Apple Inc) + +Endpoint: https://www.alphavantage.co/query?function=OVERVIEW + + +Company Info + +| field | value | +|----------|----------------------| +| Symbol | AAPL | +| Name | Apple Inc | +| Exchange | NASDAQ | +| Currency | USD | +| Country | USA | +| Sector | TECHNOLOGY | +| Industry | CONSUMER ELECTRONICS | + + +Valuation Metrics + +| field | value | +|----------------------|---------------| +| MarketCapitalization | 3844254728000 | +| TrailingPE | 34.72 | +| ForwardPE | 31.45 | +| PEGRatio | 2.635 | +| PriceToBookRatio | 52.17 | +| PriceToSalesRatioTTM | 9.24 | +| EVToEBITDA | 26.88 | +| EVToRevenue | 9.35 | + + +Growth Metrics + +| field | value | +|----------------------------|--------| +| QuarterlyEarningsGrowthYOY | 0.912 | +| QuarterlyRevenueGrowthYOY | 0.079 | +| AnalystTargetPrice | 287.71 | + + +Financial Metrics + +| field | value | +|--------------------|--------------| +| EBITDA | 144748003000 | +| RevenueTTM | 416161006000 | +| GrossProfitTTM | 195201008000 | +| DilutedEPSTTM | 7.46 | +| ProfitMargin | 0.269 | +| OperatingMarginTTM | 0.317 | +| ReturnOnAssetsTTM | 0.23 | +| ReturnOnEquityTTM | 1.714 | + + +Dividend & Book Value + +| field | value | +|------------------|------------| +| DividendPerShare | 1.02 | +| DividendYield | 0.0039 | +| ExDividendDate | 2025-11-10 | +| BookValue | 4.991 | + + +Moving Averages & Risk + +| field | value | +|---------------------|--------| +| 50DayMovingAverage | 273.01 | +| 200DayMovingAverage | 232.75 | +| 52WeekHigh | 288.62 | +| 52WeekLow | 168.63 | +| Beta | 1.093 | diff --git a/docs/bea_data_schema.md b/docs/bea_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..d1c1e84894164b9ffd1db114d24b475e3c3dbe81 --- /dev/null +++ b/docs/bea_data_schema.md @@ -0,0 +1,73 @@ +BEA Data Schema +=============== + +Bureau of Economic Analysis - NIPA (National Income and Product Accounts) + +Endpoint: https://apps.bea.gov/api/data +Dataset: NIPA +Table: T10101 (Percent Change From Preceding Period in Real GDP) + + +Request Parameters + +| field | description | +|--------------|-------------------------------------| +| UserID | API key (required) | +| method | GetData | +| datasetname | NIPA | +| TableName | T10101 (GDP percent change) | +| Frequency | Q (Quarterly) or A (Annual) | +| Year | X (all years) or specific year | +| ResultFormat | JSON | + + +Response Structure + +| field | description | +|---------------------------|-----------------------------| +| BEAAPI | Root container | +| BEAAPI.Request | Echo of request parameters | +| BEAAPI.Results | Results container | +| Results.Statistic | Data type (NIPA Table) | +| Results.UTCProductionTime | Timestamp of data generation| +| Results.Notes[] | Array of data notes | +| Results.Data[] | Array of data observations | + + +Data Row Fields + +| field | description | +|-----------------|---------------------------------------------------| +| TableName | NIPA table identifier (T10101) | +| SeriesCode | BEA series code for the metric | +| LineNumber | Row number in the table (1 = Real GDP) | +| LineDescription | Human-readable metric name | +| TimePeriod | Time period (YYYYQN format, e.g., 2025Q3) | +| METRIC_NAME | Metric type (e.g., Fisher Quantity Index) | +| CL_UNIT | Unit description (Percent change, annual rate) | +| UNIT_MULT | Unit multiplier | +| DataValue | The actual data value | +| NoteRef | Reference to notes array | + + +GDP Data (LineNumber = 1) + +| TimePeriod | DataValue | LineDescription | +|------------|-----------|------------------------| +| 2025Q3 | 4.3 | Gross domestic product | +| 2025Q2 | 3.8 | Gross domestic product | +| 2025Q1 | -0.6 | Gross domestic product | +| 2024Q4 | 1.9 | Gross domestic product | +| 2024Q3 | 3.3 | Gross domestic product | +| 2024Q2 | 3.6 | Gross domestic product | + + +Line Numbers Reference + +| LineNumber | Description | +|------------|---------------------------------------| +| 1 | Gross domestic product | +| 2 | Personal consumption expenditures | +| 7 | Gross private domestic investment | +| 11 | Net exports of goods and services | +| 22 | Government consumption expenditures | diff --git a/docs/bls_data_schema.md b/docs/bls_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..00fd835837b04c0dc5f1f604bb45410ce3275c4c --- /dev/null +++ b/docs/bls_data_schema.md @@ -0,0 +1,98 @@ +BLS Data Schema +=============== + +Bureau of Labor Statistics API + +Endpoint: https://api.bls.gov/publicAPI/v2/timeseries/data/ +Method: POST with JSON payload + + +Series IDs + +| series_id | description | +|--------------|---------------------------------------| +| CUUR0000SA0 | CPI-U All Items (Consumer Price Index)| +| LNS14000000 | Unemployment Rate | + + +Request Payload + +| field | description | +|-----------------|------------------------------------| +| seriesid[] | Array of BLS series IDs to fetch | +| startyear | Start year for data range | +| endyear | End year for data range | +| registrationkey | Optional API key for higher limits | + + +Response Structure + +| field | description | +|-----------------|-----------------------------------| +| status | REQUEST_SUCCEEDED or error code | +| responseTime | Response time in milliseconds | +| message[] | Array of status messages | +| Results | Results container | +| Results.series[]| Array of series data | + + +Series Structure + +| field | description | +|----------|-----------------------------| +| seriesID | BLS series identifier | +| data[] | Array of observations | + + +Observation Fields + +| field | description | +|------------|-----------------------------------------------------| +| year | 4-digit year (e.g., 2025) | +| period | Period code (M01-M12 for monthly, A01 for annual) | +| periodName | Human-readable period (January, February, etc.) | +| value | Data value as string | +| footnotes | Array of footnote objects | + + +CPI-U All Items (CUUR0000SA0) + +| field | value | +|-----------|----------------------------------| +| series_id | CUUR0000SA0 | +| title | Consumer Price Index - All Items | +| units | Index 1982-1984=100 | +| frequency | Monthly | +| period | 2025-November | +| value | 324.122 | + + +Unemployment Rate (LNS14000000) + +| field | value | +|-----------|-------------------| +| series_id | LNS14000000 | +| title | Unemployment Rate | +| units | Percent | +| frequency | Monthly | +| period | 2025-December | +| value | 4.4 | + + +Period Codes + +| code | description | +|------|-------------| +| M01 | January | +| M02 | February | +| M03 | March | +| M04 | April | +| M05 | May | +| M06 | June | +| M07 | July | +| M08 | August | +| M09 | September | +| M10 | October | +| M11 | November | +| M12 | December | +| A01 | Annual | diff --git a/docs/data_flow.md b/docs/data_flow.md new file mode 100644 index 0000000000000000000000000000000000000000..140a0d6c0f13f49a2f0786bc71216e7faed2af18 --- /dev/null +++ b/docs/data_flow.md @@ -0,0 +1,141 @@ +# Researcher-Agent Data Flow + +## Request Flow + +``` +Frontend/Caller + │ + │ POST / (JSON-RPC: message/send) + │ + │ Example Request: + │ { + │ "jsonrpc": "2.0", + │ "method": "message/send", + │ "params": {"message": {"content": "Research AAPL"}}, + │ "id": 1 + │ } + ▼ +┌──────────────────┐ +│ app.py │ A2A Server (port 7860) +└────────┬─────────┘ + │ + │ Extracts: ticker="AAPL", company_name="Apple Inc" + │ Spawns background task, returns task_id + │ + │ Example Response: + │ {"jsonrpc": "2.0", "result": {"task": {"id": "task-123", "status": "working"}}, "id": 1} + ▼ +┌──────────────────┐ +│ mcp_client.py │ Orchestrator +└────────┬─────────┘ + │ + │ Spawns each MCP server as subprocess + │ Sends JSON-RPC over stdio + │ + │ Example MCP Request: + │ {"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "get_all_sources_fundamentals", "arguments": {"ticker": "AAPL"}}, "id": 2} + ▼ +┌──────────────────────────────────────────────────────────────────────────────────────────┐ +│ MCP SERVERS │ +│ │ +│ 1. fundamentals-basket/server.py │ +│ └─► SEC EDGAR ──► {"revenue": {"value": 416161000000, "fiscal_year": 2025}} │ +│ └─► Yahoo Finance (fallback) │ +│ │ +│ 2. valuation-basket/server.py │ +│ └─► Yahoo Finance ──► {"trailing_pe": 34.72, "pb_ratio": 52.17} │ +│ └─► Alpha Vantage (fallback) │ +│ │ +│ 3. volatility-basket/server.py │ +│ └─► FRED ──► {"vix": {"value": 15.45}, "vxn": {"value": 20.15}} │ +│ └─► Yahoo Finance ──► {"beta": 1.29, "historical_volatility": 0.12} │ +│ │ +│ 4. macro-basket/server.py │ +│ └─► BEA ──► {"gdp_growth": {"value": 4.3, "date": "Q3 2025"}} │ +│ └─► BLS ──► {"cpi": 2.74, "unemployment": 4.4} │ +│ └─► FRED ──► {"interest_rate": 3.72} │ +│ │ +│ 5. news-basket/server.py │ +│ └─► Tavily + NYT + NewsAPI ──► {"items": [{"title": "...", "url": "..."}]} │ +│ │ +│ 6. sentiment-basket/server.py │ +│ └─► Finnhub ──► {"items": [{"title": "...", "url": "..."}]} │ +│ └─► Reddit ──► {"items": [{"title": "...", "url": "..."}]} │ +└──────────────────────────────────────────────────────────────────────────────────────────┘ + │ + │ Example MCP Response: + │ {"jsonrpc": "2.0", "result": {"content": [{"type": "text", "text": "{...}"}]}, "id": 2} + ▼ +┌──────────────────┐ +│ mcp_client.py │ Aggregates all sources +└────────┬─────────┘ + │ + │ Merges 6 responses into single payload + │ + │ Completeness: checks required vs missing + │ required = { + │ "financials": ["revenue", "net_income", "eps", "debt_to_equity"], + │ "valuation": ["trailing_pe", "pb_ratio", "ps_ratio"], + │ "volatility": ["beta", "vix"], + │ "macro": ["gdp_growth", "interest_rate", "cpi_inflation"], + │ "news": ["items"], + │ "sentiment": ["items"] + │ } + │ missing = {"volatility": ["implied_volatility"]} + │ found: 19, total: 20 + │ + │ Conflict Resolution: compares primary vs secondary source values + │ financials: SEC EDGAR (primary) vs Yahoo Finance (secondary) + │ valuation: Yahoo Finance (primary) vs Alpha Vantage (secondary) + │ → if values differ, marks conflict and uses primary + │ → example: {"metric": "revenue", "primary_value": 416B, "secondary_value": 415B, "used": "primary"} + │ + │ Example Aggregated: + │ {"ticker": "AAPL", "metrics": {...}, "multi_source": {...}, "completeness": {"pct": 95}} + ▼ +┌──────────────────┐ +│ app.py │ Stores as task artifact +└────────┬─────────┘ + │ + │ Updates task status: "working" → "completed" + │ Stores result in artifacts[] + │ + │ Example Final Response (tasks/get): + │ {"jsonrpc": "2.0", "result": {"task": {"id": "task-123", "status": "completed", "artifacts": [{...}]}}, "id": 3} + ▼ +Frontend/Caller +``` + +--- + +## Output Structure + +```python +{ + "ticker": "AAPL", + "company_name": "Apple Inc", + "sources_available": ["financials", "valuation", ...], + "sources_failed": [], + "metrics": { + "fundamentals": {...}, + "valuation": {...}, + "volatility": {...}, + "macro": {...}, + "news": {...}, + "sentiment": {...} + }, + "multi_source": { + "fundamentals_all": {...}, + "valuation_all": {...}, + "volatility_all": {...}, + "macro_all": {...} + }, + "completeness": { + "completeness_pct": 95.0, + "metrics_found": 19, + "metrics_total": 20, + "missing": ["implied_volatility"] + }, + "generated_at": "2026-01-10T..." +} +``` diff --git a/docs/finnhub_data_schema.md b/docs/finnhub_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..2705eca20f19ad6e3ded4f273d5ba061f23ac4f9 --- /dev/null +++ b/docs/finnhub_data_schema.md @@ -0,0 +1,37 @@ +Finnhub Data Schema +=================== + +Endpoint: https://finnhub.io/api/v1/company-news +Method: GET + + +Request Parameters + +| field | type | description | +|--------|--------|-------------------------| +| symbol | string | Stock ticker | +| from | string | Start date (YYYY-MM-DD) | +| to | string | End date (YYYY-MM-DD) | +| token | string | API key | + + +Response (array of articles) + +| field | type | description | +|----------|--------|------------------| +| headline | string | Article headline | +| summary | string | Article summary | +| url | string | Article URL | +| source | string | Publisher name | +| datetime | int | Unix timestamp | + + +Example Result + +| field | value | +|----------|--------------------------------------| +| headline | "Apple Reports Strong Q4 Earnings" | +| summary | "Apple Inc reported quarterly..." | +| url | "https://bloomberg.com/apple-q4..." | +| source | "Bloomberg" | +| datetime | 1736416200 | diff --git a/docs/fred_data_schema.md b/docs/fred_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..6de06e18882ebd50ce72c5d6cbfd67e0ac78c9d6 --- /dev/null +++ b/docs/fred_data_schema.md @@ -0,0 +1,110 @@ +FRED Data Schema +================ + +Endpoint: https://api.stlouisfed.org/fred/series/observations + + +Raw API Response Structure +-------------------------- + +Series Info (seriess[0]) + +| field | description | +|---------------------|----------------------------------------| +| id | Series identifier | +| title | Series title | +| units | Data units | +| frequency | Update frequency (Daily, Monthly, etc) | +| seasonal_adjustment | Adjustment type (SA, NSA) | +| last_updated | Last update timestamp | + +Observation (observations[]) + +| field | description | +|----------------|-------------------------| +| realtime_start | Real-time period start | +| realtime_end | Real-time period end | +| date | Observation date | +| value | Data value (string) | + + +Series Data +----------- + +GDP Growth (A191RL1Q225SBEA) + +| field | value | +|--------------|--------------------------------------| +| series_id | A191RL1Q225SBEA | +| title | Real Gross Domestic Product | +| units | Percent Change from Preceding Period | +| frequency | Quarterly | +| date | 2025-07-01 | +| value | 4.3 | +| last_updated | 2025-12-23 07:54:34 | + +Interest Rate (FEDFUNDS) + +| field | value | +|--------------|------------------------------| +| series_id | FEDFUNDS | +| title | Federal Funds Effective Rate | +| units | Percent | +| frequency | Monthly | +| date | 2025-12-01 | +| value | 3.72 | +| last_updated | 2026-01-02 15:18:33 | + +CPI (CPIAUCSL) + +| field | value | +|--------------|-----------------------------------------------| +| series_id | CPIAUCSL | +| title | Consumer Price Index for All Urban Consumers | +| units | Index 1982-1984=100 | +| frequency | Monthly | +| date | 2025-11-01 | +| value | 325.031 | +| last_updated | 2025-12-18 08:03:48 | + +Unemployment (UNRATE) + +| field | value | +|--------------|---------------------| +| series_id | UNRATE | +| title | Unemployment Rate | +| units | Percent | +| frequency | Monthly | +| date | 2025-12-01 | +| value | 4.4 | +| last_updated | 2026-01-09 08:10:37 | + +VIX (VIXCLS) + +| field | value | +|--------------|----------------------------| +| series_id | VIXCLS | +| title | CBOE Volatility Index: VIX | +| units | Index | +| frequency | Daily, Close | +| date | 2026-01-08 | +| value | 15.45 | +| last_updated | 2026-01-09 08:37:39 | + +VXN (VXNCLS) + +| field | value | +|--------------|----------------------------------| +| series_id | VXNCLS | +| title | CBOE NASDAQ 100 Volatility Index | +| units | Index | +| frequency | Daily, Close | +| date | 2026-01-08 | +| value | 20.15 | +| last_updated | 2026-01-09 08:37:34 | + + +Time Categories +--------------- +- Macro indicators (GDP, Interest Rate, CPI, Unemployment): date field is observation date +- Volatility indices (VIX, VXN): date field is market close date diff --git a/docs/fundamentals_data_schema.md b/docs/fundamentals_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..46a0d5d5350f5e700480ec956dec8a6296082fe7 --- /dev/null +++ b/docs/fundamentals_data_schema.md @@ -0,0 +1,42 @@ +Fundamentals Data Schema +======================== + +Single source of truth for fundamentals-basket MCP server output. +Example: AAPL (Apple Inc) + +| S/N | metric | value | temporal info | source | +| --- | ------------------------- | ------- | ------------- | ------------- | +| | **Income Statement** | | | | +| 1 | Revenue | $416.2B | FY 2025 | SEC EDGAR | +| 2 | Gross Profit | $195.2B | FY 2025 | SEC EDGAR | +| 3 | Operating Income | $133.1B | FY 2025 | SEC EDGAR | +| 4 | Net Income | $112.0B | FY 2025 | SEC EDGAR | +| | **Balance Sheet** | | | | +| 5 | Total Assets | $359.2B | Q3 2025 | SEC EDGAR | +| 6 | Total Liabilities | $285.5B | Q3 2025 | SEC EDGAR | +| 7 | Stockholders Equity | $73.7B | Q3 2025 | SEC EDGAR | +| 8 | Cash | $35.9B | Q3 2025 | SEC EDGAR | +| 9 | Long Term Debt | $90.7B | Q3 2025 | SEC EDGAR | +| 10 | Total Debt | $112.4B | Q3 2025 | SEC EDGAR | +| | **Cash Flow** | | | | +| 11 | Operating Cash Flow | $111.5B | FY 2025 | SEC EDGAR | +| 12 | Capital Expenditure | $12.7B | FY 2025 | SEC EDGAR | +| 13 | Free Cash Flow | $98.8B | FY 2025 | SEC EDGAR | +| 14 | R&D Expense | $34.6B | FY 2025 | SEC EDGAR | +| | **Margins & Ratios** | | | | +| 15 | Gross Margin | 46.9% | FY 2025 | SEC EDGAR | +| 16 | Operating Margin | 32.0% | FY 2025 | SEC EDGAR | +| 17 | Net Margin | 26.9% | FY 2025 | SEC EDGAR | +| 18 | Debt to Equity | 1.53x | Q3 2025 | SEC EDGAR | +| 19 | Revenue Growth (3yr CAGR) | 7.9% | FY 2025 | SEC EDGAR | +| | **Yahoo-Only Metrics** | | | | +| 20 | Return on Equity (ROE) | 171.4% | Q3 2025 | Yahoo Finance | +| 21 | Return on Assets (ROA) | 23.0% | Q3 2025 | Yahoo Finance | +| 22 | EBITDA Margin | 34.8% | Q3 2025 | Yahoo Finance | +| 23 | Current Ratio | 0.89 | Q3 2025 | Yahoo Finance | +| 24 | Quick Ratio | 0.77 | Q3 2025 | Yahoo Finance | +| 25 | Trailing EPS | $7.47 | FY 2025 | Yahoo Finance | +| 26 | Forward EPS | $9.15 | FY 2026E | Yahoo Finance | +| 27 | Payout Ratio | 13.7% | Q3 2025 | Yahoo Finance | +| 28 | Revenue Growth (QoQ) | 7.9% | Q3 2025 | Yahoo Finance | +| 29 | Earnings Growth (QoQ) | 86.4% | Q3 2025 | Yahoo Finance | diff --git a/docs/macro_data_schema.md b/docs/macro_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..6cfafbf273b32b63d96fb4fd45be9156a12383fc --- /dev/null +++ b/docs/macro_data_schema.md @@ -0,0 +1,22 @@ +Macro Economic Data Schema +========================== + +Single source of truth for macro-basket MCP server output. + +| S/N | metric | value | temporal info | source | +|-----|-------------------|-------|---------------|--------| +| 1 | GDP Growth | 4.3% | Q3 2025 | BEA | +| 2 | CPI / Inflation | 2.74% | Nov 2025 | BLS | +| 3 | Unemployment Rate | 4.4% | Dec 2025 | BLS | +| 4 | Federal Funds Rate| 3.72% | Dec 2025 | FRED | + + +Source Hierarchy +---------------- + +| metric | primary | fallback | +|-------------------|---------|----------| +| GDP Growth | BEA | FRED | +| CPI / Inflation | BLS | FRED | +| Unemployment Rate | BLS | FRED | +| Federal Funds Rate| FRED | - | diff --git a/docs/mcp_data_sources.md b/docs/mcp_data_sources.md new file mode 100644 index 0000000000000000000000000000000000000000..2d02623ea73d7ce995682bb41bf77ffa46e6897f --- /dev/null +++ b/docs/mcp_data_sources.md @@ -0,0 +1,172 @@ + + +### fundamentals-basket (29 metrics) + +| S/N | metric | temporal info | primary | secondary | +| --- | ------------------------- | ------------- | ------------- | ------------- | +| | **Income Statement** | | | | +| 1 | Revenue | FY | SEC EDGAR | Yahoo Finance | +| 2 | Gross Profit | FY | SEC EDGAR | Yahoo Finance | +| 3 | Operating Income | FY | SEC EDGAR | Yahoo Finance | +| 4 | Net Income | FY | SEC EDGAR | Yahoo Finance | +| | **Balance Sheet** | | | | +| 5 | Total Assets | Q | SEC EDGAR | Yahoo Finance | +| 6 | Total Liabilities | Q | SEC EDGAR | Yahoo Finance | +| 7 | Stockholders Equity | Q | SEC EDGAR | Yahoo Finance | +| 8 | Cash | Q | SEC EDGAR | Yahoo Finance | +| 9 | Long Term Debt | Q | SEC EDGAR | Yahoo Finance | +| 10 | Total Debt | Q | SEC EDGAR | Yahoo Finance | +| | **Cash Flow** | | | | +| 11 | Operating Cash Flow | FY | SEC EDGAR | Yahoo Finance | +| 12 | Capital Expenditure | FY | SEC EDGAR | Yahoo Finance | +| 13 | Free Cash Flow | FY | SEC EDGAR | Yahoo Finance | +| 14 | R&D Expense | FY | SEC EDGAR | Yahoo Finance | +| | **Margins & Ratios** | | | | +| 15 | Gross Margin | FY | SEC EDGAR | Yahoo Finance | +| 16 | Operating Margin | FY | SEC EDGAR | Yahoo Finance | +| 17 | Net Margin | FY | SEC EDGAR | Yahoo Finance | +| 18 | Debt to Equity | Q | SEC EDGAR | Yahoo Finance | +| 19 | Revenue Growth (3yr CAGR) | FY | SEC EDGAR | Yahoo Finance | +| | **Yahoo-Only Metrics** | | | | +| 20 | Return on Equity (ROE) | Q | Yahoo Finance | - | +| 21 | Return on Assets (ROA) | Q | Yahoo Finance | - | +| 22 | EBITDA Margin | Q | Yahoo Finance | - | +| 23 | Current Ratio | Q | Yahoo Finance | - | +| 24 | Quick Ratio | Q | Yahoo Finance | - | +| 25 | Trailing EPS | FY | Yahoo Finance | - | +| 26 | Forward EPS | FY+1E | Yahoo Finance | - | +| 27 | Payout Ratio | Q | Yahoo Finance | - | +| 28 | Revenue Growth (QoQ) | Q | Yahoo Finance | - | +| 29 | Earnings Growth (QoQ) | Q | Yahoo Finance | - | + +### valuation-basket (17 metrics) + +| S/N | metric | temporal info | primary | secondary | +|-----|-----------------------|---------------|---------------|---------------| +| | **Price & Size** | | | | +| 1 | Current Price | Market Time | Yahoo Finance | Alpha Vantage | +| 2 | Market Cap | Market Time | Yahoo Finance | Alpha Vantage | +| 3 | Enterprise Value | Market Time | Yahoo Finance | Alpha Vantage | +| | **Earnings Multiples**| | | | +| 4 | Trailing P/E | Market Time | Yahoo Finance | Alpha Vantage | +| 5 | Forward P/E | Market Time | Yahoo Finance | Alpha Vantage | +| 6 | Trailing PEG | Market Time | Yahoo Finance | Alpha Vantage | +| 7 | Forward PEG | Market Time | Yahoo Finance | Alpha Vantage | +| | **Revenue Multiples** | | | | +| 8 | P/S Ratio | Market Time | Yahoo Finance | Alpha Vantage | +| 9 | EV/Revenue | Market Time | Yahoo Finance | Alpha Vantage | +| 10 | EV/EBITDA | Market Time | Yahoo Finance | Alpha Vantage | +| | **Asset Multiples** | | | | +| 11 | P/B Ratio | Market Time | Yahoo Finance | Alpha Vantage | +| | **Risk** | | | | +| 12 | Beta | Market Time | Yahoo Finance | Alpha Vantage | +| | **Alpha Vantage Only**| | | | +| 13 | 50 Day Moving Avg | Market Time | Alpha Vantage | - | +| 14 | 200 Day Moving Avg | Market Time | Alpha Vantage | - | +| 15 | 52 Week High | Market Time | Alpha Vantage | - | +| 16 | 52 Week Low | Market Time | Alpha Vantage | - | +| 17 | Analyst Target Price | Market Time | Alpha Vantage | - | + +### volatility-basket (5 metrics) + +| S/N | metric | temporal info | primary | secondary | +|-----|-----------------------|---------------|---------------------|---------------| +| | **Market Indices** | | | | +| 1 | VIX | Daily | FRED | Yahoo Finance | +| 2 | VXN | Daily | FRED | - | +| | **Stock-Specific** | | | | +| 3 | Beta | 1yr rolling | Yahoo Finance | Alpha Vantage | +| 4 | Historical Volatility | 30-day | Yahoo Finance | Alpha Vantage | +| 5 | Implied Volatility | ATM option | Yahoo Finance Options| - | + +### macro-basket (4 metrics) + +| S/N | metric | temporal info | primary | secondary | +|-----|--------------------|---------------|---------|-----------| +| 1 | GDP Growth | Quarterly | BEA | FRED | +| 2 | CPI / Inflation | Monthly | BLS | FRED | +| 3 | Unemployment Rate | Monthly | BLS | FRED | +| 4 | Federal Funds Rate | Monthly | FRED | - | + +### news-basket (3 sources) + +| S/N | metric | temporal info | sources (collated) | +|-----|---------------|---------------|-------------------------------| +| 1 | News Articles | Real-time | Tavily + NYT + NewsAPI | + +### sentiment-basket (2 content sources) + +| S/N | content | temporal info | source | note | +|-----|--------------|---------------|---------|-----------------------------------| +| 1 | Finnhub News | Real-time | Finnhub | Raw articles, VADER applied downstream | +| 2 | Reddit Posts | Real-time | Reddit | Raw posts, VADER applied downstream | + +--- + +## Content Analysis: Source Cutoffs & Roles + +### News Sources (news-basket) + +| Source | Window | Delay | Role | Content Type | SWOT Value | Rate Limit | +|--------|--------|-------|------|--------------|------------|------------| +| Tavily | 7 days | Real-time | Breaking news, immediate coverage | Headlines, snippets | Identifies emerging threats/opportunities | API key required | +| NYT | 6 days | Real-time | Quality journalism, verified reporting | Full articles | Credible source for major events | Free tier available | +| NewsAPI | 7 days | 24hr | Analysis, opinion pieces, deep dives | Aggregated articles | Provides context after breaking news settles | 100 req/day (free) | + +### Sentiment Sources (sentiment-basket) + +| Source | Window | Delay | Role | Content Type | SWOT Value | Rate Limit | +|--------|--------|-------|------|--------------|------------|------------| +| Finnhub | 7 days | Real-time | Financial news, earnings coverage | Company news articles | Professional/institutional sentiment | Free tier available | +| Reddit | 7 days | Real-time | Consumer sentiment, retail investor views | Posts, discussions | Grassroots perception, emerging concerns | Public JSON endpoints | + +### Temporal Strategy + +The staggered timing creates complementary coverage: + +``` +T+0 hours → Tavily, NYT, Finnhub (Breaking news) +T+24 hours → NewsAPI (Analysis/opinion pieces) +T+1 to T+7 → Reddit (Consumer discussion develops) +``` + +**Rationale:** +- Breaking news captures immediate market-moving events +- Delayed analysis provides deeper context and expert opinions +- Consumer sentiment lags announcements as discussions develop organically +- 7-day window balances recency with sufficient volume + +--- + +## API Endpoints + +| S/N | Source | Base URL | API Key Required | +|-----|---------------|-----------------------------------------------------|------------------| +| 1 | SEC EDGAR | `https://data.sec.gov/api/xbrl/` | No | +| 2 | Yahoo Finance | `https://query1.finance.yahoo.com/` | No | +| 3 | FRED | `https://api.stlouisfed.org/fred/` | Yes (free) | +| 4 | BEA | `https://apps.bea.gov/api/data/` | Yes (free) | +| 5 | BLS | `https://api.bls.gov/publicAPI/v2/timeseries/data/` | Optional (free) | +| 6 | Alpha Vantage | `https://www.alphavantage.co/query` | Yes (free tier) | +| 7 | Tavily | `https://api.tavily.com/` | Yes | +| 8 | NYT | `https://api.nytimes.com/` | Yes (free) | +| 9 | NewsAPI | `https://newsapi.org/v2/` | Yes (free tier) | +| 10 | Finnhub | `https://finnhub.io/api/v1/` | Yes (free tier) | +| 11 | Reddit | `https://oauth.reddit.com/` | Yes (OAuth) | + +--- + +## API Key Environment Variables + +```bash +FRED_API_KEY=your_key +BEA_API_KEY=your_key +BLS_API_KEY=your_key +ALPHA_VANTAGE_API_KEY=your_key +TAVILY_API_KEY=your_key +NYT_API_KEY=your_key +NEWS_API_KEY=your_key +FINNHUB_API_KEY=your_key +REDDIT_CLIENT_ID=your_id +REDDIT_CLIENT_SECRET=your_key +``` diff --git a/docs/mcp_normalized_schemas.md b/docs/mcp_normalized_schemas.md new file mode 100644 index 0000000000000000000000000000000000000000..28ba1ee386a394176d9ab49317d0e244cdcef0df --- /dev/null +++ b/docs/mcp_normalized_schemas.md @@ -0,0 +1,30 @@ +# MCP Normalized Output Schemas + +All 6 MCPs normalized to 3 schema groups for consistent downstream processing. + +--- + +## Group 1: raw_metrics + +| MCP | Function | Schema | +|-----|----------|--------| +| volatility-basket | `get_all_sources_volatility` | `{group, ticker, metrics: {name: {value, source, fallback}}}` | +| macro-basket | `get_all_sources_macro` | `{group, ticker, metrics: {name: {value, source, fallback}}}` | + +--- + +## Group 2: source_comparison + +| MCP | Function | Schema | +| ------------------- | ---------------------------- | --------------------------------------------------------- | +| fundamentals-basket | `get_all_sources_fundamentals` | `{group, ticker, sources: {source_name: {source, data}}}` | +| valuation-basket | `get_all_sources_valuation` | `{group, ticker, sources: {source_name: {source, data}}}` | + +--- + +## Group 3: content_analysis + +| MCP | Function | Schema | +| ---------------- | --------------------------- | ------------------------------------------------------------------- | +| news-basket | `get_all_sources_news` | `{group, ticker, items: [{title, content, url, datetime, source}]}` | +| sentiment-basket | `get_all_sources_sentiment` | `{group, ticker, items: [{title, content, url, datetime, source}]}` | diff --git a/docs/mcp_output_sample.md b/docs/mcp_output_sample.md new file mode 100644 index 0000000000000000000000000000000000000000..deeb8f0ad3dedeb9b338d2c8afce408e8cc58dac --- /dev/null +++ b/docs/mcp_output_sample.md @@ -0,0 +1,246 @@ +# MCP Output Sample: NVIDIA (NVDA) + +Generated: 2026-01-11 + +## Summary + +| Field | Value | +|-------|-------| +| Ticker | NVDA | +| Company | NVIDIA Corporation | +| Sources Available | financials, valuation, volatility, macro, news, sentiment | +| Sources Failed | None | + +--- + +## Schema Groups + +### Group 1: Source Comparison (fundamentals-basket, valuation-basket) + +Multi-source data with primary/secondary comparison. + +```json +{ + "group": "source_comparison", + "ticker": "NVDA", + "sources": { + "sec_edgar": { + "source": "SEC EDGAR XBRL", + "data": { ... } + }, + "yahoo_finance": { + "source": "Yahoo Finance", + "data": { ... } + } + } +} +``` + +### Group 2: Raw Metrics (volatility-basket, macro-basket) + +Single-value metrics without interpretation. + +```json +{ + "group": "raw_metrics", + "ticker": "NVDA", + "metrics": { + "vix": { + "value": 15.45, + "source": "FRED (Federal Reserve)", + "fallback": false + } + } +} +``` + +### Group 3: Content Analysis (news-basket, sentiment-basket) + +Raw content items without scoring. + +```json +{ + "group": "content_analysis", + "ticker": "NVDA", + "items": [ + { + "title": "...", + "content": "...", + "url": "...", + "datetime": "2026-01-09T20:05:00Z", + "source": "Yahoo Entertainment" + } + ] +} +``` + +--- + +## Fundamentals (source_comparison) + +### SEC EDGAR Data + +| Metric | Value | Fiscal Year | Form | +|--------|-------|-------------|------| +| Revenue | $26.9B | 2022 | 10-K | +| Net Income | $72.9B | 2025 | 10-K | +| Gross Profit | $97.9B | 2025 | 10-K | +| Operating Income | $81.5B | 2025 | 10-K | +| Total Assets | $111.6B | 2025 | 10-K | +| Total Liabilities | $32.3B | 2025 | 10-K | +| Stockholders Equity | $79.3B | 2025 | 10-K | +| Long Term Debt | $8.5B | 2025 | 10-K | +| Cash | $8.6B | 2025 | 10-K | +| Debt to Equity | 0.11 | 2025 | 10-K | +| Operating Cash Flow | $64.1B | 2025 | 10-K | +| Free Cash Flow | $64.0B | 2025 | 10-K | +| R&D Expense | $12.9B | 2025 | 10-K | + +### Yahoo Finance Data + +| Metric | Value | +|--------|-------| +| Revenue | $187.1B | +| Net Income | $99.2B | +| Gross Profit | $131.1B | +| Gross Margin | 70.05% | +| Net Margin | 53.01% | +| Total Debt | $10.8B | +| Cash | $60.6B | +| Net Debt | -$49.8B | +| Operating Cash Flow | $83.2B | +| Free Cash Flow | $53.3B | + +--- + +## Valuation (source_comparison) + +### Yahoo Finance vs Alpha Vantage + +| Metric | Yahoo Finance | Alpha Vantage | +|--------|---------------|---------------| +| Current Price | $184.86 | $186.70 | +| Market Cap | $4.50T | $4.50T | +| Enterprise Value | $4.44T | - | +| Trailing P/E | 45.64 | 45.64 | +| Forward P/E | 24.37 | 24.21 | +| P/S Ratio | 24.05 | 24.05 | +| P/B Ratio | 37.79 | 37.83 | +| EV/EBITDA | 39.42 | 37.34 | +| Trailing PEG | 0.70 | 0.70 | +| Forward PEG | 0.37 | - | +| Earnings Growth | 66.7% | 66.7% | +| Revenue Growth | 62.5% | 62.5% | + +--- + +## Volatility (raw_metrics) + +| Metric | Value | Source | Fallback | +|--------|-------|--------|----------| +| VIX | 15.45 | FRED (Federal Reserve) | No | +| VXN | 20.15 | FRED (Federal Reserve) | No | +| Beta | 1.929 | Calculated from Yahoo Finance | No | +| Historical Volatility | 27.72% | Calculated from Yahoo Finance | No | +| Implied Volatility | 30.0% | Market Average (estimated) | Yes | + +--- + +## Macro (raw_metrics) + +| Metric | Value | Source | Fallback | +| ------------- | ----- | --------------------------------- | -------- | +| GDP Growth | 4.3% | BEA (Bureau of Economic Analysis) | No | +| Interest Rate | 3.72% | FRED (Federal Reserve) | No | +| CPI Inflation | 2.74% | BLS (Bureau of Labor Statistics) | No | +| Unemployment | 4.4% | BLS (Bureau of Labor Statistics) | No | + +--- + +## News (content_analysis) + +**Sources Configured:** Tavily, NYT, NewsAPI +**Sources Used:** Tavily, NewsAPI +**Item Count:** 7 +**Time Window:** 7 days + +### Tavily (4 items) + +| Title | Date | +|-------|------| +| NVIDIA: NVDA Stock Price Quote & News | - | +| NVDA - NVIDIA Corporation Stock Price | - | +| NVIDIA CORPORATION (NVDA) Stock, Price, News | - | +| NVDA Stock Quote Price and Forecast | - | + +### NYT (5 items) + +| Title | Date | +|-------|------| +| Google Guys Say Bye to California | 2026-01-09 | +| China Is Investigating Meta's Latest A.I. Acquisition | 2026-01-08 | +| Elon Musk's xAI Raises $20 Billion | 2026-01-06 | +| The Rush to Profit From Maduro's Capture | 2026-01-06 | +| Nvidia Details New A.I. Chips and Autonomous Car Project With Mercedes | 2026-01-05 | + +### NewsAPI (3 items) + +| Title | Date | +|-------|------| +| Micron vs. NVIDIA: One AI Chip Stock is Poised to Win Big in 2026 | 2026-01-09 | +| NVIDIA (NVDA)'s Gonna Have a Great Q1, Says Jim Cramer | 2026-01-09 | +| Jim Cramer on NVIDIA: "It's Insanely Cheap" | 2026-01-09 | + +--- + +## Sentiment (content_analysis) + +**Sources Configured:** Finnhub, Reddit +**Sources Used:** Finnhub, Reddit +**Item Count:** 66 +**Time Window:** 7 days + +### Finnhub (50 items) + +| Title | Date | +|-------|------| +| AI Reset Is Complete; Tech's Next Leg Starts Here | 2026-01-10 | +| How BlackRock is fine-tuning market portfolios for 2026 | 2026-01-10 | +| Super Micro Computer: Commoditization Continues | 2026-01-10 | +| NVIDIA Discusses Rubin and Blackwell Performance Advancements | 2026-01-10 | +| Wall Street's start to 2026 is going exactly according to plan | 2026-01-10 | +| Behind Anthropic's stunning growth is a sibling team | 2026-01-10 | +| Are we in an AI bubble? What 40 tech leaders are saying | 2026-01-10 | +| What Moved Markets This Week | 2026-01-10 | +| AI memory is sold out, causing an unprecedented surge in prices | 2026-01-10 | +| Prediction: 2 Ways To Capitalize on AI Stocks in 2026 | 2026-01-10 | + +### Reddit (16 items) + +| Title | Subreddit | Date | +|-------|-----------|------| +| What's the most unexpected stock tip you got? | r/stocks | 2026-01-09 | +| tough year 2025 | r/wallstreetbets | 2026-01-09 | +| r/Stocks Daily Discussion & Options Trading Thursday | r/stocks | 2026-01-08 | +| China to Approve Nvidia H200 Purchases | r/wallstreetbets | 2026-01-08 | +| Reddit's Top Stocks 2026 ETF Experiment | r/stocks | 2026-01-08 | +| NVDA 125k margin | r/wallstreetbets | 2026-01-07 | +| Going balls deep on GOOG thanks to insiders on Polymarket | r/wallstreetbets | 2026-01-07 | +| What are your top stock picks for 2026? | r/stocks | 2026-01-06 | +| Uber, Lyft Surge Following Nvidia's Self-Driving Tech Announcement | r/stocks | 2026-01-06 | +| Nvidia launches Vera Rubin AI platform at CES 2026 | r/wallstreetbets | 2026-01-06 | + +--- + +## Conflict Resolution + +| Source Type | Primary | Secondary | Conflicts | +|-------------|---------|-----------|-----------| +| Financials | SEC EDGAR XBRL | Yahoo Finance | None | +| Valuation | Yahoo Finance | Alpha Vantage | None | + +--- + +## Raw JSON Output + +Full output available at: `/home/vn6295337/.claude/projects/-home-vn6295337-Instant-SWOT-Agent/e881431f-b90d-45c5-88a2-e0b36ca052f8/tool-results/toolu_01PUcLHKRRs9HwWYvdhQWrmb.txt` diff --git a/docs/mcp_output_visa.md b/docs/mcp_output_visa.md new file mode 100644 index 0000000000000000000000000000000000000000..23f4371ba2a2753033b3797aa4adbe1691ee708f --- /dev/null +++ b/docs/mcp_output_visa.md @@ -0,0 +1,91 @@ +# MCP Output: Visa (V) + +Generated: 2026-01-11 + +> Schemas defined in `configs/output_schemas.py` + +--- + +## Quantitative Data + +| Metric | Value | Data Type | As Of | Filed | Source | Category | +| --------------------- | -------- | ------------- | ------------- | ---------- | -------------- | ------------ | +| Revenue | $40.00B | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Net Income | $20.06B | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Operating Income | $23.99B | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Operating Margin | 59.98% | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Net Margin | 50.14% | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Total Assets | $99.63B | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Total Liabilities | $61.72B | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Stockholders Equity | $26.44B | FY | 2011-09-30 | 2011-11-18 | SEC EDGAR | Fundamentals | +| Long Term Debt | $20.98B | FY | 2021-09-30 | 2021-11-18 | SEC EDGAR | Fundamentals | +| Total Debt | $25.17B | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Debt to Equity | 0.95 | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Operating Cash Flow | $23.06B | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Free Cash Flow | $23.00B | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Revenue Growth (3yr) | 14.34% | FY | 2025-09-30 | 2025-11-06 | SEC EDGAR | Fundamentals | +| Revenue | $40.00B | TTM | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Net Income | $19.85B | TTM | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Gross Profit | $39.11B | TTM | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Gross Margin | 97.76% | TTM | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Net Margin | 49.63% | TTM | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Total Debt | $26.08B | Point-in-time | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Cash | $19.00B | Point-in-time | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Net Debt | $7.09B | Point-in-time | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Operating Cash Flow | $23.06B | TTM | 2025-09-30 | 2026-01-09 | Yahoo Finance | Fundamentals | +| Current Price | $349.77 | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| Market Cap | $675.02B | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| Enterprise Value | $677.39B | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| Trailing P/E | 34.26 | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| Forward P/E | 24.25 | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| P/S Ratio | 16.88 | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| P/B Ratio | 18.05 | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| EV/EBITDA | 24.17 | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| Trailing PEG | 1.92 | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| Earnings Growth | -1.4% | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| Revenue Growth | 11.5% | - | 2026-01-09 | - | Yahoo Finance | Valuation | +| Current Price (50DMA) | $339.81 | - | 2026-01-09 | - | Alpha Vantage | Valuation | +| Market Cap | $675.02B | - | 2026-01-09 | - | Alpha Vantage | Valuation | +| Trailing P/E | 34.26 | - | 2026-01-09 | - | Alpha Vantage | Valuation | +| Forward P/E | 27.32 | - | 2026-01-09 | - | Alpha Vantage | Valuation | +| P/S Ratio | 16.88 | - | 2026-01-09 | - | Alpha Vantage | Valuation | +| P/B Ratio | 18.15 | - | 2026-01-09 | - | Alpha Vantage | Valuation | +| EV/EBITDA | 26.20 | - | 2026-01-09 | - | Alpha Vantage | Valuation | +| Trailing PEG | 1.92 | - | 2026-01-09 | - | Alpha Vantage | Valuation | +| VIX | 15.45 | Daily | 2026-01-08 | - | FRED | Volatility | +| VXN | 20.15 | Daily | 2026-01-08 | - | FRED | Volatility | +| Beta | 0.79 | 1Y | 2026-01-09 | - | Yahoo Finance | Volatility | +| Historical Volatility | 22.16% | 30D | 2026-01-09 | - | Yahoo Finance | Volatility | +| Implied Volatility | 30.00% | Forward | 2026-01-11 | - | Market Average | Volatility | +| GDP Growth | 4.30% | Quarterly | 2025Q3 | - | BEA | Macro | +| Interest Rate | 3.72% | Monthly | 2025-12-01 | - | FRED | Macro | +| CPI Inflation | 2.74% | Monthly | 2025-November | - | BLS | Macro | +| Unemployment | 4.40% | Monthly | 2025-December | - | BLS | Macro | + +--- + +## Qualitative Data + +**News Sources:** Tavily, NewsAPI (filtered to business/finance/tech domains) +**Sentiment Sources:** Finnhub, Reddit +**Item Count:** 64 (News: 6, Sentiment: 58) +**Time Window:** 7 days + +| Title | Date | Source | Subreddit | URL | Category | +| ---------------------------------------------------------------------------------------- | ---------- | ------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| Big Tech stocks are getting cheaper, and that could mean gains of up to 60% | 2025-12-16 | MarketWatch | - | [Link](https://www.marketwatch.com/story/big-tech-stocks-are-getting-cheaper-and-that-could-mean-gains-of-up-to-60-fdf1b70c) | News | +| Dow, S&P 500 end at records because investors feel good about the economy | 2025-12-11 | MarketWatch | - | [Link](https://www.marketwatch.com/story/dow-s-p-500-end-at-records-because-investors-feel-good-about-the-economy-beyond-the-ai-boom-0dcad0b9) | News | +| V: Visa Inc - Stock Price, Quote and News | - | CNBC | - | [Link](https://www.cnbc.com/quotes/V) | News | +| V Stock Price - Visa Inc. Cl A Stock Quote (U.S.: NYSE) | - | MarketWatch | - | [Link](https://www.marketwatch.com/investing/stock/v) | News | +| Why Visa Stock Could Be A Strong Portfolio Add | - | Forbes | - | [Link](https://www.forbes.com/sites/greatspeculations/2026/01/09/why-visa-stock-could-be-a-strong-portfolio-add/) | News | +| Visa Inc. (V) Interactive Stock Chart | - | Yahoo Finance | - | [Link](https://ca.finance.yahoo.com/quote/V/chart/) | News | +| Googl gains | 2026-01-11 | Reddit | r/wallstreetbets | [Link](https://reddit.com/r/wallstreetbets/comments/1q9r6es/googl_gains/) | Sentiment | +| Oklo ride to 1000$ | 2026-01-10 | Reddit | r/wallstreetbets | [Link](https://reddit.com/r/wallstreetbets/comments/1q9b1qx/oklo_ride_to_1000/) | Sentiment | +| 5 Things That Won't Happen In 2026 (The Alpha Of Inertia) | 2026-01-10 | Finnhub | - | [Link](https://finnhub.io/api/news?id=dc02cc7e318de2d458d5b92b23ef870ba3ca8e58c355af40624245a0a5104aac) | Sentiment | +| Rain Raises $250 Million at $1.95 Billion Valuation for Stablecoin Payments Expansion | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=5b4865a899c398090c883407215234a2257358b44e7887e6273245102ea078e3) | Sentiment | +| Visa's Tokenization Push Is Becoming More Than a Security Play | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=6a13ef2d88f1a76f3e9aa488ff8145e902e35a956e8d738a011cb308c46e00bc) | Sentiment | +| Circle's Non-Interest Revenues Accelerate: Can the Momentum Continue? | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=1289ce4bcc1cdc2007ebf983c1432f188ec44e19f416675d57c7840935a79e3a) | Sentiment | +| Mastercard Up 7.6% in a Month: Are Investors Looking Beyond AI Hype? | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=1f8cda21dfecdb07398c4a91e6db2482c248150caeb8f6e60fa8c75a5dba1866) | Sentiment | +| The Influencer Marketing Factory Unveils Season 6 of "The Influence Factor" Podcast | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=2d14c9529412c1c8e935a931fc5a332c7d5348f12652408c956d87fd2f6d3c10) | Sentiment | +| Fiserv: Potentially In A Bottoming Process | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=a1e7f5530265f365630da945e31e0b63dd4badff90a7accd50a181ce8ead81b7) | Sentiment | +| Why is Mastercard Incorporated (MA) One of the Best Major Stocks to Invest in Right Now? | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=8bd58b88bae91ac0c493840cb9b9f851b4fdf1b61c6ec880020ad51e4ebc2a04) | Sentiment | diff --git a/docs/mcp_raw_visa.json b/docs/mcp_raw_visa.json new file mode 100644 index 0000000000000000000000000000000000000000..1ef73471de0e8eb1618c079158783fca49d4b0f5 --- /dev/null +++ b/docs/mcp_raw_visa.json @@ -0,0 +1,939 @@ +{ + "ticker": "V", + "company_name": "Visa Inc.", + "sources_available": [ + "financials", + "valuation", + "volatility", + "macro", + "news", + "sentiment" + ], + "sources_failed": [], + "metrics": { + "financials": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "sec_edgar": { + "source": "SEC EDGAR XBRL", + "data": { + "financials": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_income": { + "value": 20058000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_income": { + "value": 23994000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_margin_pct": { + "value": 59.98, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_margin_pct": { + "value": 50.14, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "revenue_growth_3yr": { + "value": 14.34, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_assets": { + "value": 99627000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_liabilities": { + "value": 61718000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "stockholders_equity": { + "value": 26437000000, + "data_type": "FY", + "end_date": "2011-09-30", + "filed": "2011-11-18", + "fiscal_year": 2011, + "form": "10-K" + } + }, + "debt": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "long_term_debt": { + "value": 20977000000, + "data_type": "FY", + "end_date": "2021-09-30", + "filed": "2021-11-18", + "fiscal_year": 2021, + "form": "10-K" + }, + "total_debt": { + "value": 25171000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "cash": { + "value": 17164000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_debt": { + "value": 8007000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "debt_to_equity": { + "value": 0.95, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + }, + "cash_flow": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23059000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "capital_expenditure": { + "value": 59000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "free_cash_flow": { + "value": 23000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + } + } + }, + "yahoo_finance": { + "source": "Yahoo Finance", + "data": { + "financials": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_income": { + "value": 19853000704, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_profit": { + "value": 39105998848, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_margin_pct": { + "value": 97.76, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_margin_pct": { + "value": 49.63, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "debt": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "total_debt": { + "value": 26083999744, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "cash": { + "value": 18997000192, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_debt": { + "value": 7086999552, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "cash_flow": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23058999296, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "free_cash_flow": { + "value": 20072873984, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + } + } + } + }, + "source": "fundamentals-basket", + "as_of": "2026-01-11" + }, + "valuation": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "yahoo_finance": { + "source": "Yahoo Finance", + "regular_market_time": "2026-01-09", + "data": { + "current_price": 349.77, + "market_cap": 675020144640.0, + "enterprise_value": 677386649600.0, + "trailing_pe": 34.25759, + "forward_pe": 24.254278, + "ps_ratio": 16.875504, + "pb_ratio": 18.046125, + "ev_ebitda": 24.168, + "trailing_peg": 1.9228, + "forward_peg": null, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + }, + "alpha_vantage": { + "source": "Alpha Vantage", + "latest_quarter": "2025-09-30", + "fetched_at": "2026-01-09", + "data": { + "current_price": 339.81, + "market_cap": 675020145000.0, + "trailing_pe": 34.26, + "forward_pe": 27.32, + "ps_ratio": 16.88, + "pb_ratio": 18.15, + "ev_ebitda": 26.2, + "trailing_peg": 1.923, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + } + }, + "source": "valuation-basket", + "as_of": "2026-01-11" + }, + "volatility": { + "group": "raw_metrics", + "ticker": "V", + "metrics": { + "vix": { + "value": 15.45, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "vxn": { + "value": 20.15, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "beta": { + "value": 0.785, + "data_type": "1Y", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "historical_volatility": { + "value": 22.16, + "data_type": "30D", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "implied_volatility": { + "value": 30.0, + "data_type": "Forward", + "as_of": "2026-01-11", + "source": "Market Average (estimated)", + "fallback": true + } + }, + "source": "volatility-basket", + "as_of": "2026-01-11" + }, + "macro": { + "group": "raw_metrics", + "ticker": "MACRO", + "metrics": { + "gdp_growth": { + "value": 4.3, + "data_type": "Quarterly", + "as_of": "2025Q3", + "source": "BEA (Bureau of Economic Analysis)", + "fallback": false + }, + "interest_rate": { + "value": 3.72, + "data_type": "Monthly", + "as_of": "2025-12-01", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "cpi_inflation": { + "value": 2.74, + "data_type": "Monthly", + "as_of": "2025-November", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + }, + "unemployment": { + "value": 4.4, + "data_type": "Monthly", + "as_of": "2025-December", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + } + }, + "source": "macro-basket", + "as_of": "2026-01-11" + }, + "news": { + "group": "content_analysis", + "ticker": "V", + "query": "Visa Inc. (V) stock news", + "items": [ + { + "title": "Big Tech stocks are getting cheaper, and that could mean gains of up to 60%", + "content": "S 500 stocks may be expensive relative to their historical averages, but many are cheaper than they were at the start of the quarter.", + "url": "https://www.marketwatch.com/story/big-tech-stocks-are-getting-cheaper-and-that-could-mean-gains-of-up-to-60-fdf1b70c", + "datetime": "2025-12-16", + "source": "MarketWatch" + }, + { + "title": "Dow, S&P 500 end at records because investors feel good about the economy \u2014 beyond the AI boom", + "content": "Investors were moving away from tech stocks, and using that money to push other parts of the market to new records", + "url": "https://www.marketwatch.com/story/dow-s-p-500-end-at-records-because-investors-feel-good-about-the-economy-beyond-the-ai-boom-0dcad0b9", + "datetime": "2025-12-11", + "source": "MarketWatch" + }, + { + "title": "V: Visa Inc - Stock Price, Quote and News", + "content": "# Visa Inc V:NYSE. * 52 Week High Date06/11/25. * 52 Week Low Date04/07/25. ## Latest On Visa Inc. * CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and VisaCNBC.com. And payment giants are preparingCNBC.com. * Economy grows, chip tariff delay, new S&P 500 record and more in Morning SquawkCNBC.com. ## Latest On Visa Inc. * CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and VisaCNBC.com. And payment giants are preparingCNBC.com. * 52 Week High Date06/11/25. * 52 Week Low Date04/07/25. * P/E (TTM)34.66. * Earnings Date01/28/2026(est). * When Stock Recovery Defeats Securities Fraud Claims: The Visa DecisionTipRanks. * Monness Keeps Their Hold Rating on Visa (V)\")TipRanks. * Visa Stock Stumbles While Its Crypto Card Network Sees 525% Volume ExplosionTipRanks. * Visa Updates Class B Conversion Rates After Escrow DepositTipRanks. CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and Visa. Visa Inc. is a global payments technology company. Vice Chairman, Chief People and Corporate Affairs Officer and Corporate Secretary. Independent Chairman of the Board.", + "url": "https://www.cnbc.com/quotes/V", + "datetime": null, + "source": null + }, + { + "title": "V Stock Price | Visa Inc. Cl A Stock Quote (U.S.: NYSE)", + "content": "Key Data. Open $352.16; Day Range 349.16 - 354.70; 52 Week Range 299.00 - 375.51; Market Cap $668.84B; Shares Outstanding 1.69B; Public Float 1.68B", + "url": "https://www.marketwatch.com/investing/stock/v", + "datetime": null, + "source": null + }, + { + "title": "Why Visa Stock Could Be A Strong Portfolio Add", + "content": "Image 1: Is UPS Stock A Buy After The Recent Rally? Image 3: Nvidia Vs Broadcom: Which AI Stock To Bet On? Jan 09, 2026, 08:57am ESTNvidia Vs Broadcom: Which AI Stock To Bet On? Image 5: How T-Mobile Stock Becomes A Cash Machine. Jan 09, 2026, 07:00am ESTHow T-Mobile Stock Becomes A Cash Machine. Image 6: Apple Stock\u2019s Make-Or-Break AI Bet. Jan 09, 2026, 06:30am ESTApple Stock\u2019s Make-Or-Break AI Bet. Image 7: Up 20% Will Cardano (ADA) Keep Rising? Why Visa Stock Could Be A Strong Portfolio Add. Forbes contributors publish independent expert analyses and insights. Visa (V) stock could be an appealing purchase at this time. Stocks can decline even when markets are performing well \u2013 consider events such as earnings reports, business updates, and outlook modifications. Forbes Daily: Join over 1 million Forbes Daily subscribers and get our best stories, exclusive reporting and essential analysis of the day\u2019s news in your inbox every weekday.", + "url": "https://www.forbes.com/sites/greatspeculations/2026/01/09/why-visa-stock-could-be-a-strong-portfolio-add/", + "datetime": null, + "source": null + }, + { + "title": "Visa Inc. (V) Interactive Stock Chart", + "content": "Recent News: V \u00b7 Rain Raises $250 Million at $1.95 Billion Valuation for Stablecoin Payments Expansion \u00b7 Visa's Tokenization Push Is Becoming More Than a Security", + "url": "https://ca.finance.yahoo.com/quote/V/chart/", + "datetime": null, + "source": null + } + ], + "item_count": 6, + "sources_used": [ + "Tavily", + "NewsAPI" + ], + "source": "news-basket", + "as_of": "2026-01-11", + "total_items": 6, + "showing": 6 + }, + "sentiment": { + "group": "content_analysis", + "ticker": "V", + "items": [ + { + "title": "Googl gains", + "content": "Bought after 2025 February earnings, doubled down in April dip, some more in May dip. Started with 60k. \n\nSold lots of weeklies to you regards against my long calls; just closed my position. \n\nWill dip my legs into goog again for the next run to $400. \n\nMicro contributors - aapl in Jan, Msft in May; avgo after earnings and MU. ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q9r6es/googl_gains/", + "datetime": "2026-01-11", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "5 Things That Won't Happen In 2026 (The Alpha Of Inertia)", + "content": "This article lays out five slow-moving certainties for 2026 that investors can lean on. Click here to read more.", + "url": "https://finnhub.io/api/news?id=dc02cc7e318de2d458d5b92b23ef870ba3ca8e58c355af40624245a0a5104aac", + "datetime": "2026-01-10", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Oklo ride to 1000$", + "content": "Call me skeptical boys but after losing a 160k I am going to let this ride to the moon. Not going to take profits until expiration. Diamond hands \ud83d\udc8e\ud83d\ude4c Let\u2019s see how much this can make me..", + "url": "https://reddit.com/r/wallstreetbets/comments/1q9b1qx/oklo_ride_to_1000/", + "datetime": "2026-01-10", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Rain Raises $250 Million at $1.95 Billion Valuation for Stablecoin Payments Expansion", + "content": "ICONIQ led the round as Rain targets growth across the Americas, Europe, Asia and Africa.", + "url": "https://finnhub.io/api/news?id=5b4865a899c398090c883407215234a2257358b44e7887e6273245102ea078e3", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa's Tokenization Push Is Becoming More Than a Security Play", + "content": "V's tokenization push is evolving beyond security, boosting transaction efficiency, AI-driven insights and seamless payments across devices.", + "url": "https://finnhub.io/api/news?id=6a13ef2d88f1a76f3e9aa488ff8145e902e35a956e8d738a011cb308c46e00bc", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Circle's Non-Interest Revenues Accelerate: Can the Momentum Continue?", + "content": "CRCL shows rapid growth in non-interest revenues, lifting 2025 guidance as subscriptions, services and transaction fees scale across its platform.", + "url": "https://finnhub.io/api/news?id=1289ce4bcc1cdc2007ebf983c1432f188ec44e19f416675d57c7840935a79e3a", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Mastercard Up 7.6% in a Month: Are Investors Looking Beyond AI Hype?", + "content": "MA shares jump 7.6% in a month as investors rotate from AI trades toward durable payment networks with steady growth drivers.", + "url": "https://finnhub.io/api/news?id=1f8cda21dfecdb07398c4a91e6db2482c248150caeb8f6e60fa8c75a5dba1866", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "The Influencer Marketing Factory Unveils Season 6 of \"The Influence Factor\" Podcast with Cutting-Edge Industry Leaders", + "content": "NEW YORK CITY, NEW YORK / ACCESS Newswire / January 9, 2026 / Global influencer marketing leader The Influencer Marketing Factory today announced Season 6 of its acclaimed podcast,The Influence Factor . Bi-weekly episodes will feature conversations ...", + "url": "https://finnhub.io/api/news?id=2d14c9529412c1c8e935a931fc5a332c7d5348f12652408c956d87fd2f6d3c10", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Fiserv: Potentially In A Bottoming Process", + "content": "Fiserv (FISV) stock plunged 70% and FY25 guidance reset.", + "url": "https://finnhub.io/api/news?id=a1e7f5530265f365630da945e31e0b63dd4badff90a7accd50a181ce8ead81b7", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Why is Mastercard Incorporated (MA) One of the Best Major Stocks to Invest in Right Now?", + "content": "Mastercard Incorporated (NYSE:MA) is one of the best major stocks to invest in right now. Monness Crespi Hardt & Co., Inc. reiterated a Hold rating on Mastercard Incorporated (NYSE:MA) on January 5 and set a price target of $525.00. In addition, Keefe, Bruyette & Woods maintained a Buy rating on the company on January 2 [\u2026]", + "url": "https://finnhub.io/api/news?id=8bd58b88bae91ac0c493840cb9b9f851b4fdf1b61c6ec880020ad51e4ebc2a04", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + } + ], + "item_count": 58, + "sources_used": [ + "Finnhub", + "Reddit" + ], + "source": "sentiment-basket", + "as_of": "2026-01-11", + "total_items": 58, + "showing": 10 + } + }, + "multi_source": { + "financials_all": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "sec_edgar": { + "source": "SEC EDGAR XBRL", + "data": { + "financials": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_income": { + "value": 20058000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_income": { + "value": 23994000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_margin_pct": { + "value": 59.98, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_margin_pct": { + "value": 50.14, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "revenue_growth_3yr": { + "value": 14.34, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_assets": { + "value": 99627000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_liabilities": { + "value": 61718000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "stockholders_equity": { + "value": 26437000000, + "data_type": "FY", + "end_date": "2011-09-30", + "filed": "2011-11-18", + "fiscal_year": 2011, + "form": "10-K" + } + }, + "debt": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "long_term_debt": { + "value": 20977000000, + "data_type": "FY", + "end_date": "2021-09-30", + "filed": "2021-11-18", + "fiscal_year": 2021, + "form": "10-K" + }, + "total_debt": { + "value": 25171000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "cash": { + "value": 17164000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_debt": { + "value": 8007000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "debt_to_equity": { + "value": 0.95, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + }, + "cash_flow": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23059000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "capital_expenditure": { + "value": 59000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "free_cash_flow": { + "value": 23000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + } + } + }, + "yahoo_finance": { + "source": "Yahoo Finance", + "data": { + "financials": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_income": { + "value": 19853000704, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_profit": { + "value": 39105998848, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_margin_pct": { + "value": 97.76, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_margin_pct": { + "value": 49.63, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "debt": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "total_debt": { + "value": 26083999744, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "cash": { + "value": 18997000192, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_debt": { + "value": 7086999552, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "cash_flow": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23058999296, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "free_cash_flow": { + "value": 20072873984, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + } + } + } + }, + "source": "fundamentals-basket", + "as_of": "2026-01-11" + }, + "valuation_all": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "yahoo_finance": { + "source": "Yahoo Finance", + "regular_market_time": "2026-01-09", + "data": { + "current_price": 349.77, + "market_cap": 675020144640.0, + "enterprise_value": 677386649600.0, + "trailing_pe": 34.25759, + "forward_pe": 24.254278, + "ps_ratio": 16.875504, + "pb_ratio": 18.046125, + "ev_ebitda": 24.168, + "trailing_peg": 1.9228, + "forward_peg": null, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + }, + "alpha_vantage": { + "source": "Alpha Vantage", + "latest_quarter": "2025-09-30", + "fetched_at": "2026-01-09", + "data": { + "current_price": 339.81, + "market_cap": 675020145000.0, + "trailing_pe": 34.26, + "forward_pe": 27.32, + "ps_ratio": 16.88, + "pb_ratio": 18.15, + "ev_ebitda": 26.2, + "trailing_peg": 1.923, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + } + }, + "source": "valuation-basket", + "as_of": "2026-01-11" + }, + "macro_all": { + "group": "raw_metrics", + "ticker": "MACRO", + "metrics": { + "gdp_growth": { + "value": 4.3, + "data_type": "Quarterly", + "as_of": "2025Q3", + "source": "BEA (Bureau of Economic Analysis)", + "fallback": false + }, + "interest_rate": { + "value": 3.72, + "data_type": "Monthly", + "as_of": "2025-12-01", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "cpi_inflation": { + "value": 2.74, + "data_type": "Monthly", + "as_of": "2025-November", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + }, + "unemployment": { + "value": 4.4, + "data_type": "Monthly", + "as_of": "2025-December", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + } + }, + "source": "macro-basket", + "as_of": "2026-01-11" + }, + "volatility_all": { + "group": "raw_metrics", + "ticker": "V", + "metrics": { + "vix": { + "value": 15.45, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "vxn": { + "value": 20.15, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "beta": { + "value": 0.785, + "data_type": "1Y", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "historical_volatility": { + "value": 22.16, + "data_type": "30D", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "implied_volatility": { + "value": 30.0, + "data_type": "Forward", + "as_of": "2026-01-11", + "source": "Market Average (estimated)", + "fallback": true + } + }, + "source": "volatility-basket", + "as_of": "2026-01-11" + } + }, + "conflict_resolution": { + "financials": { + "primary_source": "SEC EDGAR XBRL", + "secondary_source": "Yahoo Finance", + "conflicts": [] + }, + "valuation": { + "primary_source": "Yahoo Finance", + "secondary_source": "Alpha Vantage", + "conflicts": [] + } + }, + "aggregated_swot": { + "strengths": [], + "weaknesses": [], + "opportunities": [], + "threats": [] + }, + "completeness": { + "completeness_pct": 50.0, + "metrics_found": 7, + "metrics_total": 14, + "missing": { + "financials": [ + "revenue", + "net_income", + "eps", + "debt_to_equity" + ], + "valuation": [ + "trailing_pe", + "pb_ratio", + "ps_ratio" + ] + } + }, + "generated_at": "2026-01-11T14:01:14.368474" +} \ No newline at end of file diff --git a/docs/mcp_raw_visa.md b/docs/mcp_raw_visa.md new file mode 100644 index 0000000000000000000000000000000000000000..02ae505f5e9707087ff86b77aebf3c891f772b50 --- /dev/null +++ b/docs/mcp_raw_visa.md @@ -0,0 +1,962 @@ +# MCP Raw Output: Visa (V) + +Generated: 2026-01-11 12:32:18 + +```json +{ + "ticker": "V", + "company_details": { + "longName": "Visa Inc.", + "address1": "P.O. Box 8999", + "city": "San Francisco", + "state": "CA", + "zip": "94128-8999", + "country": "United States", + "sector": "Financial Services", + "industry": "Credit Services" + }, + "fundamentals": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "sec_edgar": { + "source": "SEC EDGAR XBRL", + "data": { + "financials": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_income": { + "value": 20058000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_income": { + "value": 23994000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_margin_pct": { + "value": 59.98, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_margin_pct": { + "value": 50.14, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "revenue_growth_3yr": { + "value": 14.34, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_assets": { + "value": 99627000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_liabilities": { + "value": 61718000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "stockholders_equity": { + "value": 26437000000, + "data_type": "FY", + "end_date": "2011-09-30", + "filed": "2011-11-18", + "fiscal_year": 2011, + "form": "10-K" + } + }, + "debt": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "long_term_debt": { + "value": 20977000000, + "data_type": "FY", + "end_date": "2021-09-30", + "filed": "2021-11-18", + "fiscal_year": 2021, + "form": "10-K" + }, + "total_debt": { + "value": 25171000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "cash": { + "value": 17164000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_debt": { + "value": 8007000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "debt_to_equity": { + "value": 0.95, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + }, + "cash_flow": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23059000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "capital_expenditure": { + "value": 59000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "free_cash_flow": { + "value": 23000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + } + } + }, + "yahoo_finance": { + "source": "Yahoo Finance", + "data": { + "financials": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_income": { + "value": 19853000704, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_profit": { + "value": 39105998848, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_margin_pct": { + "value": 97.76, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_margin_pct": { + "value": 49.63, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "debt": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "total_debt": { + "value": 26083999744, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "cash": { + "value": 18997000192, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_debt": { + "value": 7086999552, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "cash_flow": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23058999296, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "free_cash_flow": { + "value": 20072873984, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + } + } + } + }, + "source": "fundamentals-basket", + "as_of": "2026-01-11" + }, + "valuation": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "yahoo_finance": { + "source": "Yahoo Finance", + "regular_market_time": "2026-01-09", + "data": { + "current_price": 349.77, + "market_cap": 675020144640.0, + "enterprise_value": 677386649600.0, + "trailing_pe": 34.25759, + "forward_pe": 24.254278, + "ps_ratio": 16.875504, + "pb_ratio": 18.046125, + "ev_ebitda": 24.168, + "trailing_peg": 1.9228, + "forward_peg": null, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + }, + "alpha_vantage": { + "source": "Alpha Vantage", + "latest_quarter": "2025-09-30", + "fetched_at": "2026-01-09", + "data": { + "current_price": 339.81, + "market_cap": 675020145000.0, + "trailing_pe": 34.26, + "forward_pe": 27.32, + "ps_ratio": 16.88, + "pb_ratio": 18.15, + "ev_ebitda": 26.2, + "trailing_peg": 1.923, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + } + }, + "source": "valuation-basket", + "as_of": "2026-01-11" + }, + "volatility": { + "group": "raw_metrics", + "ticker": "V", + "metrics": { + "vix": { + "value": 15.45, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "vxn": { + "value": 20.15, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "beta": { + "value": 0.785, + "data_type": "1Y", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "historical_volatility": { + "value": 22.16, + "data_type": "30D", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "implied_volatility": { + "value": 30.0, + "data_type": "Forward", + "as_of": "2026-01-11", + "source": "Market Average (estimated)", + "fallback": true + } + }, + "source": "volatility-basket", + "as_of": "2026-01-11" + }, + "macro": { + "group": "raw_metrics", + "ticker": "MACRO", + "metrics": { + "gdp_growth": { + "value": 4.3, + "data_type": "Quarterly", + "as_of": "2025Q3", + "source": "BEA (Bureau of Economic Analysis)", + "fallback": false + }, + "interest_rate": { + "value": 3.72, + "data_type": "Monthly", + "as_of": "2025-12-01", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "cpi_inflation": { + "value": 2.74, + "data_type": "Monthly", + "as_of": "2025-November", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + }, + "unemployment": { + "value": 4.4, + "data_type": "Monthly", + "as_of": "2025-December", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + } + }, + "source": "macro-basket", + "as_of": "2026-01-11" + }, + "news": { + "group": "content_analysis", + "ticker": "V", + "query": "Visa Inc. (V) stock news", + "items": [ + { + "title": "After Three Hot Years, Will Stock Markets Sizzle Again?", + "content": "Returns have been fabulous but consider the potential for setbacks in this already hazardous year, our columnist says.", + "url": "https://www.nytimes.com/2026/01/09/business/stock-market-investing-returns.html", + "datetime": "2026-01-09T14:00:07Z", + "source": "New York Times" + }, + { + "title": "Bill Gates Makes a Multibillion-Dollar Divorce Payout", + "content": "The billionaire and philanthropist has made a nearly $8 billion payment to the private foundation of his ex-wife, Melinda French Gates, tax filings show.", + "url": "https://www.nytimes.com/2026/01/09/business/dealbook/gates-divorce-settlement.html", + "datetime": "2026-01-09T12:43:02Z", + "source": "New York Times" + }, + { + "title": "Is Visa Inc. (V) One of the Best Major Stocks to Invest in Right Now?", + "content": "Visa Inc. (NYSE:V) is one of the best major stocks to invest in right now. Monness, Crespi, Hardt & Co., Inc. reiterated a Hold rating on Visa Inc. (NYSE:V) ...", + "url": "https://finance.yahoo.com/news/visa-inc-v-one-best-092151784.html", + "datetime": "2026-01-09T09:21:51Z", + "source": "Yahoo Entertainment" + }, + { + "title": "All Eyes on the U.S. in 2026", + "content": "Our reporters in Washington tell us what they\u2019re watching from the Trump administration.", + "url": "https://www.nytimes.com/2026/01/07/world/us-trump-venezuela-2026.html", + "datetime": "2026-01-07T21:15:24Z", + "source": "New York Times" + }, + { + "title": "Why Trump\u2019s Greenland Strategy Has the World on Edge", + "content": "The president is again focused on acquiring the mineral-rich island. But lack of clarity about his goals and tactics is weighing on political and business leaders.", + "url": "https://www.nytimes.com/2026/01/07/business/dealbook/buy-invade-trump-greenland.html", + "datetime": "2026-01-07T13:21:34Z", + "source": "New York Times" + }, + { + "title": "Oil Stocks Rally as Investors Bet on Return to Venezuela", + "content": "The energy sector of the S&P 500 rose 2.7 percent on Monday, lifting the broader market.", + "url": "https://www.nytimes.com/2026/01/05/business/oil-stocks-venezuela.html", + "datetime": "2026-01-05T18:20:06Z", + "source": "New York Times" + }, + { + "title": "5 fantastic ASX ETFs for beginners in 2026", + "content": "These funds are highly rated for a reason. Here's what you need to know about them.\nThe post 5 fantastic ASX ETFs for beginners in 2026 appeared first on The Motley Fool Australia.", + "url": "https://www.fool.com.au/2026/01/02/5-fantastic-asx-etfs-for-beginners-in-2026/", + "datetime": "2026-01-02T02:49:20Z", + "source": "Motley Fool Australia" + }, + { + "title": "Visa (NYSE: V) Stock Price Prediction and Forecast 2026-2030 (Jan 2026)", + "content": "This year, Visa Inc. (NYSE: V) has unveiled a scam disruption initiative, adoption of its \u201cTap to Phone\u201d technology has soared, it unveiled its vision for artificial intelligence (AI) in commerce, and it expanded its capabilities in the digital currency space\u2026", + "url": "https://biztoc.com/x/c031d63d4b6abfd2", + "datetime": "2026-01-01T13:51:47Z", + "source": "Biztoc.com" + }, + { + "title": "V: Visa Inc - Stock Price, Quote and News", + "content": "# Visa Inc V:NYSE. * 52 Week High Date06/11/25. * 52 Week Low Date04/07/25. ## Latest On Visa Inc. * CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and VisaCNBC.com. And payment giants are preparingCNBC.com. * Economy grows, chip tariff delay, new S&P 500 record and more in Morning SquawkCNBC.com. ## Latest On Visa Inc. * CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and VisaCNBC.com. And payment giants are preparingCNBC.com. * 52 Week High Date06/11/25. * 52 Week Low Date04/07/25. * P/E (TTM)34.66. * Earnings Date01/28/2026(est). * When Stock Recovery Defeats Securities Fraud Claims: The Visa DecisionTipRanks. * Monness Keeps Their Hold Rating on Visa (V)\")TipRanks. * Visa Stock Stumbles While Its Crypto Card Network Sees 525% Volume ExplosionTipRanks. * Visa Updates Class B Conversion Rates After Escrow DepositTipRanks. CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and Visa. Visa Inc. is a global payments technology company. Vice Chairman, Chief People and Corporate Affairs Officer and Corporate Secretary. Independent Chairman of the Board.", + "url": "https://www.cnbc.com/quotes/V", + "datetime": null, + "source": null + }, + { + "title": "Buy or Sell Visa Stock - V Stock Price Quote & News", + "content": "On 2026-01-09, Visa(V) stock moved within a range of $349.00 to $354.70. With shares now at $355.54, the stock is trading +1.9% above its intraday low and +0.2%", + "url": "https://robinhood.com/us/en/stocks/V/", + "datetime": null, + "source": null + }, + { + "title": "Visa Inc. (V) Latest Stock News & Headlines", + "content": "Visa Inc. (V) and Mastercard to Pay $167.5m to Settle Lawsuit Over ATM Fees. Insider Monkey \u2022 11d ago. V. -0.70% \u00b7 MA \u00b7 PayPal Stock", + "url": "https://ca.finance.yahoo.com/quote/V/news/", + "datetime": null, + "source": null + }, + { + "title": "V Stock Quote Price and Forecast", + "content": "Here\u2019s Why. by TipRanks Dec 23, 2025 7:15am ET Visa reports Canada holiday spending rises 4.4% by TipRanks Dec 23, 2025 7:05am ET Visa reports holiday retail spending increased 4.2% by TipRanks Dec 23, 2025 3:30am ET Analysts\u2019 Opinions Are Mixed on These Financial Stocks: Visa (V) and Ally Financial (ALLY) by TipRanks Dec 22, 2025 9:15am ET Fiserv collaborates with Visa to accelerate agentic commerce by TipRanks Dec 19, 2025 5:07pm ET 5 Overlooked Stocks With Over 10% Upside for 2026 by TipRanks Dec 19, 2025 1:34pm ET Visa (V), Mastercard Stocks Rise after $168M Settlement Deal for 14-Year-Old Lawsuit by TipRanks Dec 19, 2025 12:52pm ET Credit Card Giants Visa (V) and Mastercard to Pay $167.5 Million in ATM User Fee Legal Case by TipRanks Dec 19, 2025 12:30pm ET Visa, MasterCard to pay $167.5M to settle ATM user fee lawsuit, Reuters says by TipRanks Dec 19, 2025 10:40am ET Crypto Currents: Coinbase starts rollout for stock trading, prediction markets by TipRanks Dec 18, 2025 9:33am ET SoFi Stock Soars after Announcing the Launch of SoFiUSD Dollar-Pegged Stablecoin by TipRanks Dec 17, 2025 5:53pm ET \u2018Risk Geometry\u2019 Identifies Exotic Options Play on Visa Stock (V) by TipRanks Dec 17, 2025 7:12am ET Akamai, Visa announce strategic collaboration for next era of agentic commerce by TipRanks Dec 17, 2025 7:11am ET Akamai, Visa partner on agentic commerce protections by TipRanks Dec 16, 2025 11:35am ET Crypto Currents: Visa launches stablecoin settlement in U.S. by TipRanks Dec 16, 2025 7:46am ET Visa Is Weaponizing USDC to Protect Its $17 Trillion Network from the $50 Trillion Crypto Disruptors by TipRanks Dec 16, 2025 7:05am ET Visa launches USDC settlement in the United States by TipRanks Dec 15, 2025 7:16am ET Visa launches stablecoins advisory practice by TipRanks Dec 15, 2025 1:40am ET Wall Street Analysts Are Bullish on Top Financial Picks by TipRanks Dec 14, 2025 8:17am ET 3 Stocks Bank", + "url": "https://www.cnn.com/markets/stocks/V", + "datetime": null, + "source": null + } + ], + "item_count": 12, + "sources_used": [ + "Tavily", + "NYT", + "NewsAPI" + ], + "source": "news-basket", + "as_of": "2026-01-11" + }, + "sentiment": { + "group": "content_analysis", + "ticker": "V", + "items": [ + { + "title": "Googl gains", + "content": "Bought after 2025 February earnings, doubled down in April dip, some more in May dip. Started with 60k. \n\nSold lots of weeklies to you regards against my long calls; just closed my position. \n\nWill dip my legs into goog again for the next run to $400. \n\nMicro contributors - aapl in Jan, Msft in May; avgo after earnings and MU. ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q9r6es/googl_gains/", + "datetime": "2026-01-11T11:07:32", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Oklo ride to 1000$", + "content": "Call me skeptical boys but after losing a 160k I am going to let this ride to the moon. Not going to take profits until expiration. Diamond hands \ud83d\udc8e\ud83d\ude4c Let\u2019s see how much this can make me..", + "url": "https://reddit.com/r/wallstreetbets/comments/1q9b1qx/oklo_ride_to_1000/", + "datetime": "2026-01-10T23:39:51", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "5 Things That Won't Happen In 2026 (The Alpha Of Inertia)", + "content": "This article lays out five slow-moving certainties for 2026 that investors can lean on. Click here to read more.", + "url": "https://finnhub.io/api/news?id=dc02cc7e318de2d458d5b92b23ef870ba3ca8e58c355af40624245a0a5104aac", + "datetime": "2026-01-10T14:00:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Rain Raises $250 Million at $1.95 Billion Valuation for Stablecoin Payments Expansion", + "content": "ICONIQ led the round as Rain targets growth across the Americas, Europe, Asia and Africa.", + "url": "https://finnhub.io/api/news?id=5b4865a899c398090c883407215234a2257358b44e7887e6273245102ea078e3", + "datetime": "2026-01-09T23:32:31", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa's Tokenization Push Is Becoming More Than a Security Play", + "content": "V's tokenization push is evolving beyond security, boosting transaction efficiency, AI-driven insights and seamless payments across devices.", + "url": "https://finnhub.io/api/news?id=6a13ef2d88f1a76f3e9aa488ff8145e902e35a956e8d738a011cb308c46e00bc", + "datetime": "2026-01-09T23:19:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Circle's Non-Interest Revenues Accelerate: Can the Momentum Continue?", + "content": "CRCL shows rapid growth in non-interest revenues, lifting 2025 guidance as subscriptions, services and transaction fees scale across its platform.", + "url": "https://finnhub.io/api/news?id=1289ce4bcc1cdc2007ebf983c1432f188ec44e19f416675d57c7840935a79e3a", + "datetime": "2026-01-09T23:06:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "RDDT 250c 3/20", + "content": "Let\u2019s go 300 after ER. I can feel it in my plums ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q8ep76/rddt_250c_320/", + "datetime": "2026-01-09T23:05:28", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Mastercard Up 7.6% in a Month: Are Investors Looking Beyond AI Hype?", + "content": "MA shares jump 7.6% in a month as investors rotate from AI trades toward durable payment networks with steady growth drivers.", + "url": "https://finnhub.io/api/news?id=1f8cda21dfecdb07398c4a91e6db2482c248150caeb8f6e60fa8c75a5dba1866", + "datetime": "2026-01-09T21:57:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "The Influencer Marketing Factory Unveils Season 6 of \"The Influence Factor\" Podcast with Cutting-Edge Industry Leaders", + "content": "NEW YORK CITY, NEW YORK / ACCESS Newswire / January 9, 2026 / Global influencer marketing leader The Influencer Marketing Factory today announced Season 6 of its acclaimed podcast,The Influence Factor . Bi-weekly episodes will feature conversations ...", + "url": "https://finnhub.io/api/news?id=2d14c9529412c1c8e935a931fc5a332c7d5348f12652408c956d87fd2f6d3c10", + "datetime": "2026-01-09T19:30:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Fiserv: Potentially In A Bottoming Process", + "content": "Fiserv (FISV) stock plunged 70% and FY25 guidance reset.", + "url": "https://finnhub.io/api/news?id=a1e7f5530265f365630da945e31e0b63dd4badff90a7accd50a181ce8ead81b7", + "datetime": "2026-01-09T18:37:01", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Why is Mastercard Incorporated (MA) One of the Best Major Stocks to Invest in Right Now?", + "content": "Mastercard Incorporated (NYSE:MA) is one of the best major stocks to invest in right now. Monness Crespi Hardt & Co., Inc. reiterated a Hold rating on Mastercard Incorporated (NYSE:MA) on January 5 and set a price target of $525.00. In addition, Keefe, Bruyette & Woods maintained a Buy rating on the company on January 2 [\u2026]", + "url": "https://finnhub.io/api/news?id=8bd58b88bae91ac0c493840cb9b9f851b4fdf1b61c6ec880020ad51e4ebc2a04", + "datetime": "2026-01-09T14:51:59", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Is Visa Inc. (V) One of the Best Major Stocks to Invest in Right Now?", + "content": "Visa Inc. (NYSE:V) is one of the best major stocks to invest in right now. Monness, Crespi, Hardt & Co., Inc. reiterated a Hold rating on Visa Inc. (NYSE:V) on January 5 and set a price target of $330.00. In a separate development, Visa Inc. (NYSE:V) and Fiserv, Inc. announced on December 22 a strategic [\u2026]", + "url": "https://finnhub.io/api/news?id=4a7abccded3cd2b373deb1ecf6f665a30923fd414a0e26e1026f228f1885d732", + "datetime": "2026-01-09T14:51:51", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Brown & Brown: A Deep Value Opportunity", + "content": "Brown & Brown stock looks undervalued given its EPS growth and long dividend streak. Click here to see why I rate BRO stock a Buy.", + "url": "https://finnhub.io/api/news?id=bcbec1bc767dc8f96c4bca19f5565fcbc69129c77eb386e6a1e7e648e9452f04", + "datetime": "2026-01-09T13:30:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa and JPMorgan Use Solana Rails But One Risk Still Worries Banks", + "content": "Major financial institutions, including JPMorgan and Visa, are increasingly turning to .cwp-coin-chart svg path { stroke-width: 0.65 !important; } .cwp-coin-widget-container .cwp-graph-container.positive svg path:nth-of-type(2) { stroke: #008868 !important; } .cwp-coin-widget-container .cwp-coin-trend.positive { color: #008868 !important; background-color: transparent !important; } .cwp-coin-widget-container .cwp-coin-popup-holder .cwp-coin-trend.positive { border: 1px solid #008868; border-radi", + "url": "https://finnhub.io/api/news?id=d01493ec2df5e6ce762d753f6aa91b2b04a0071e70c227eb13ff0060fb4f0b88", + "datetime": "2026-01-09T12:14:22", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Assessing Visa (V) Valuation After Partnerships And Services Fuel Recent Share Price Momentum", + "content": "Visa (V) has been in focus after Deluxe adopted Visa Direct to power dlxFastFunds, offering near real-time funding for businesses and reducing typical settlement delays to support cash flow and day-to-day operations. See our latest analysis for Visa. Those product wins sit alongside a steady share price story, with Visa\u2019s 1 month share price return of 7.9% and 1 year total shareholder return of 13.5%. Its 3 year total shareholder return of 61.1% points to momentum that has built over time...", + "url": "https://finnhub.io/api/news?id=3bca1517d817f4be8e186900396305af36eaa89900335fb6238abe5373ed416f", + "datetime": "2026-01-09T08:45:24", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "$8K to $46K in my 0DTE account this week, took out $8K then blew the rest on SPY $588 puts. Wish I took out more, greed & too excited again.", + "content": "", + "url": "https://reddit.com/r/wallstreetbets/comments/1q7sy7u/8k_to_46k_in_my_0dte_account_this_week_took_out/", + "datetime": "2026-01-09T05:47:46", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Visa (V) Stock Falls Amid Market Uptick: What Investors Need to Know", + "content": "Visa (V) closed the most recent trading day at $352.23, moving 1.03% from the previous trading session.", + "url": "https://finnhub.io/api/news?id=e1852c5ac307a1fb1db7702bebe0f1f73f99059bdac6261abfe19a0b2731d857", + "datetime": "2026-01-09T04:15:07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Top 50 High-Quality Dividend Growth Stocks For January 2026", + "content": "2025 dividend growth stock screen: 50 picks, FCF valuation, +10.55% return. 17 stocks appear potentially undervalued, while 7 saw valuation downgrades. See more.", + "url": "https://finnhub.io/api/news?id=671f8c9315813f7a75e40f073a9858371b2150af06b9af85636d996aba0f4404", + "datetime": "2026-01-09T03:33:27", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Deluxe to Implement Visa Direct to Enable Fast, Seamless Payments with dlxFastFunds", + "content": "MINNEAPOLIS, January 08, 2026--Deluxe (NYSE: DLX), a trusted Payments and Data company, today announced its collaboration with Visa to implement Visa Direct. This collaboration introduces dlxFastFundsSM, a funding solution that leverages Visa\u2019s trusted payment network to help businesses take control of their cash flow by skipping the typical one- to two-day settlement delay.", + "url": "https://finnhub.io/api/news?id=39cfb0684de57341da1b77116e273e6971c05fd8c71cf3ab7f58736916546ba5", + "datetime": "2026-01-09T03:31:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "ONDS--drones, so hot right now...drones.", + "content": "V cool and v legal company with a lot of room to grow in the coming years. Also, trains are involved...literally an autist's dream. ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q7jaje/ondsdrones_so_hot_right_nowdrones/", + "datetime": "2026-01-08T23:44:38", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Polygon Labs Launches Open Money Stack to Bridge Fiat and Onchain Settlement", + "content": "Polygon Labs has unveiled the Open Money Stack, a new set of rails designed to support regulated stablecoin payments and close the infrastructure gap between wallets, fiat access, routing and orchestration, compliance, and on-chain settlement. The next three years will define how money moves over the next thirty years.The Polygon ...", + "url": "https://finnhub.io/api/news?id=fdda05347f5ac2f705b44111e768cbfa316c11a0bb79c9d19991330d26e65f8d", + "datetime": "2026-01-08T22:03:06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "EA SPORTS\u2122 Presents Madden Bowl to Headline Super Bowl LX Week With Luke Combs, Teddy Swims and More", + "content": "REDWOOD CITY, Calif., January 08, 2026--EA SPORTS\u2122 today unveiled the music lineup for EA SPORTS\u2122 Presents Madden Bowl in Partnership with Visa, delivering Super Bowl week\u2019s premier night of football and culture to the Bay. On Friday, February 6, Luke Combs, Teddy Swims, Stephen Wilson Jr, Gavin Adcock, and the Bay Area\u2019s own LaRussell will take over San Francisco\u2019s Chase Center for a high-energy celebration bringing fans, music, and football together under one roof.", + "url": "https://finnhub.io/api/news?id=2a92a99d52190b4eda53a7f562ce880f50cc2c51e624e686fbbf3a5c8a2fb74f", + "datetime": "2026-01-08T22:00:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "What Recent Analyst Calls Mean For Visa\u2019s (V) Evolving Story And Valuation", + "content": "Why Visa\u2019s Price Target Just Nudged Higher Visa\u2019s modeled fair value estimate has shifted only slightly to about US$395.85 from US$395.44, a small change that still reflects the company\u2019s central role in global payments and its large, widely used network. This modest move aligns with views that Visa\u2019s brand strength, cross border business and perceived quality versus some peers can support a higher price target, even as more cautious voices point to execution risks and already high...", + "url": "https://finnhub.io/api/news?id=7bdee8df49015189419a1c3f7dd04cbf86dc080cea8ec9bea3afc4d30a091aa0", + "datetime": "2026-01-08T10:38:55", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Third time is charm for posting", + "content": "Whatever is the reason, it keeps getting removed so I am posting gain.", + "url": "https://reddit.com/r/wallstreetbets/comments/1q72kc9/third_time_is_charm_for_posting/", + "datetime": "2026-01-08T10:17:17", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Casino Apps USA 2026: LoneStar's Leading Mobile Casino App", + "content": "A 2026 Look at LoneStar\u2019s Mobile-First Sweepstakes Casino Platform, Features, and User Experience in the U.S.Atlantic City, New Jersey, Jan. 07, 2026 (GLOBE NEWSWIRE) -- The LoneStar Casino app has quickly emerged as a top choice for American players since its successful launch in early 2025. Noting the impact of mobile gaming, the sweepstakes casino has mainly focused on convenience and flexibility through a mobile-optimized platform. As a newcomer to the sweepstakes community, LoneStar has ent", + "url": "https://finnhub.io/api/news?id=3cadc758fda0b63f99d52bdd5767671a30c6b458f5faa448dbe318d8954f46c7", + "datetime": "2026-01-08T06:21:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "SCHD ETF Alternative Strategy Aimed For Higher Total Return", + "content": "4-factor dividend growth portfolio beats SCHD: 14.74% CAGR vs 7.28%. Portfolio turnover averages 40\u00e2\u0080\u009350% annually, with stable dividend growth. See more here.", + "url": "https://finnhub.io/api/news?id=dbd59f8f7ee961e57a20dfa24f9f4284f6813c4bfe3ddd2217fbd06f79f32720", + "datetime": "2026-01-08T04:28:03", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Global Card Fraud Losses at $33 Billion", + "content": "The Nilson Report Marks Decline in Card FraudSANTA BARBARA, Calif., Jan. 07, 2026 (GLOBE NEWSWIRE) -- Payment card fraud losses worldwide dipped 1.2% to $33.41 billion in 2024, according to the Nilson Report, the leading trade publication covering the global payment card industry. This fraud was tied to global card volume of $51.920 trillion. Losses to fraud are incurred by credit, debit and prepaid card issuers, merchants, processors of card payments from merchants and processors of card transa", + "url": "https://finnhub.io/api/news?id=243d4205b93aae6fa120b268c218a30c7586056da88b5a6dc353084ca0a44f1f", + "datetime": "2026-01-07T22:38:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "WEX or V: Which Is the Better Value Stock Right Now?", + "content": "WEX vs. V: Which Stock Is the Better Value Option?", + "url": "https://finnhub.io/api/news?id=db10552a92d9208e9243100ac6c641d30aa7d824a0385d058880091cad0be3df", + "datetime": "2026-01-07T22:10:08", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa Stock's Steady 14.4% Rise in A Year: Can 2026 Add More Firepower?", + "content": "V's quiet consistency powers growth, stablecoin expansion, and AI-ready rails, steady, not flashy, but hard to ignore.", + "url": "https://finnhub.io/api/news?id=99ab1a0174780352d537a5fa91fba8ed2d0753082ced1833a627155f34e12ebc", + "datetime": "2026-01-07T21:30:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "How nonpayments became big business at Visa and Mastercard", + "content": "With fintechs and legal cases pressuring payment fees, the card companies are leaning more on revenue from other sources.", + "url": "https://finnhub.io/api/news?id=12860aaa4567cbf6661f2e4326a988edcd3ac22f58674961e4a1ee7236f46bd3", + "datetime": "2026-01-07T21:30:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Investors Heavily Search Visa Inc. (V): Here is What You Need to Know", + "content": "Zacks.com users have recently been watching Visa (V) quite a bit. Thus, it is worth knowing the facts that could determine the stock's prospects.", + "url": "https://finnhub.io/api/news?id=f163bc032a9a11ef14cb90f78f037851789de56300db553a735c964c297773a6", + "datetime": "2026-01-07T19:30:02", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Diginex Limited Announces Signing of Definitive Agreement to Acquire Plan A, Creating One of Europe\u2019s Leading Integrated ESG, Carbon Accounting and Decarbonization Platforms", + "content": "The combined business will deliver a single sophisticated platform to expand beyond existing strategic relationships, including HSBC, Coca Cola, Visa, and BMW.LONDON, Jan. 07, 2026 (GLOBE NEWSWIRE) -- Diginex Limited (NASDAQ: DGNX), a leading provider of Sustainability RegTech and data management solutions, today announced the signing of a definitive share purchase and transfer agreement (the \u201cSPTA\u201d), to acquire PlanA.earth GmbH (\"Plan A\"), one of Europe's leading AI-powered carbon accounting an", + "url": "https://finnhub.io/api/news?id=b8513dc445d50098a89a945f4795b079d4972cd3dc7787af8eda7aee4eebb102", + "datetime": "2026-01-07T18:30:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa, Mastercard, ACI and Wex: 2026 predictions", + "content": "Major payments companies see several 2026 trends beyond the much-discussed stablecoin and agentic commerce crazes.", + "url": "https://finnhub.io/api/news?id=79b17d21b24b5c097282d9ccc00dd1840b91800c69c082c5decd99ee7b54b06a", + "datetime": "2026-01-07T15:45:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa Crypto Card Spending Jumps 525%: Is Mainstream Adoption Finally Here?", + "content": "Visa-linked crypto card spending jumped 525% in 2025, rising from $14.6M to $91.3M in net spend, according to a Dune dashboard from @obchakevich research. This shift fits a broader pattern. Stablecoins and payment rails now carry trillions of dollars in monthly volume, turning crypto from speculation into something closer to ...", + "url": "https://finnhub.io/api/news?id=d0893c2096069a6eafd7fdc5fbe197be07fa1a9b4aec82a1f15ed17941cfeeba", + "datetime": "2026-01-07T14:43:41", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa: The Easiest Way To Benefit From Consumers", + "content": "Visa stock is a \"Buy\" given its tollbooth role in the global economy. Here's what investors need to know.", + "url": "https://finnhub.io/api/news?id=0c74dc085ccd9cf2e470899b1d89b0d0b1a854e62e63b6bfea0f458623641e8e", + "datetime": "2026-01-07T13:30:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa (V): 3 Reasons We Love This Stock", + "content": "Since July 2025, Visa has been in a holding pattern, floating around $357.79. The stock also fell short of the S&P 500\u2019s 10.8% gain during that period.", + "url": "https://finnhub.io/api/news?id=a708d77d34904a4beb1e1a1e1e746cb8146edc91b6158a3f6d004c7582d0e2f2", + "datetime": "2026-01-07T09:33:09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Is Visa (V) Still Reasonably Priced After Multi Year Share Price Strength?", + "content": "If you are wondering whether Visa's current share price still offers value, it helps to step back and look at what the recent numbers are actually telling you. Visa shares last closed at US$357.56, with returns of 1.1% over 7 days, 7.9% over 30 days, 3.2% year to date, 15.5% over 1 year, 65.4% over 3 years and 77.5% over 5 years. This gives you a clear snapshot of how the stock has behaved over different time frames. Recent headlines around Visa have largely focused on its role in global...", + "url": "https://finnhub.io/api/news?id=3eb7d568e1fd40b842e6a56df369e6dd7de93cd12ae832778e569cafb5017f62", + "datetime": "2026-01-07T08:49:52", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa vs. Mastercard: Which Is the Better Growth Stock for 2026?", + "content": "Both companies have impressive business momentum, but one stock looks like the better buy headed into 2026.", + "url": "https://finnhub.io/api/news?id=28bdd8a4f1c45d08c86afe55ddf494344df26baff255304b968ea9f9d8027865", + "datetime": "2026-01-07T03:46:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Here\u2019s the net worth and income of America\u2019s top 10%. Are you on track to catch up?", + "content": "Entry into the \"affluent class\" is harder than it used to be.", + "url": "https://finnhub.io/api/news?id=6eb2042ed3384118ef33e26ac6b10b8c56953db2d71f67857fbc85900ca01cb8", + "datetime": "2026-01-07T02:30:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Back again with Moderna calls 400 buys $36 calls", + "content": "This time I sold before posting to Reddit ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q5tpzb/back_again_with_moderna_calls_400_buys_36_calls/", + "datetime": "2026-01-07T01:48:25", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "How Mastercard Is Diversifying Growth Beyond Card-Based Payments", + "content": "MA is expanding beyond card payments as value-added services like AI-driven security and data solutions fuel revenue growth.", + "url": "https://finnhub.io/api/news?id=9a9263d7cd877a1fb516f01e579bd5f3dc9b3559ae7e69203753c9bf99c6fd1a", + "datetime": "2026-01-06T22:12:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Why These 3 Mega-Caps Could Still Surprise Investors in 2026", + "content": "Three durable compounders for 2026: Visa\u2019s payment scale, Walmart\u2019s 52-year dividend streak, and Amazon\u2019s cloud-led rebound after a heavy AI spending cycle.", + "url": "https://finnhub.io/api/news?id=89558854e0cf2937e14daa88815503902d35d14bb220132d4322827d4ab7f471", + "datetime": "2026-01-06T22:06:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Russia\u2019s digital ruble will undercut its own card payment system, says analyst", + "content": "Digital ruble launch will see bank card market growth fall by up to 12% a year, expert says. MIR is Russia\u2019s answer to Visa and Mastercard. Digital ruble set to roll out in September. Foreign card firms \u201cwill never again dominate the Russian financial system.\u201d", + "url": "https://finnhub.io/api/news?id=d82bed2d9d6496004aca6f073533eedcac0a0f1292395f71a7c7a649714b526b", + "datetime": "2026-01-06T21:37:23", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Rose's Income Garden Portfolio: 8 December Raises Report", + "content": "RIG portfolio gained 12.97% in 2025, matching the Dow with a 6.29% forward yield.", + "url": "https://finnhub.io/api/news?id=8cc766fe43a5294b540bc70630cd3174b8a813bd5c2236ca9bafd8d16e15ff21", + "datetime": "2026-01-06T21:27:14", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Credit Card Processing for Nicotine Pouches is on Tower Payments' Radar for 2026", + "content": "Tower Payments today announced a focused payment gateway and merchant account service that helps nicotine pouch websites restore stable credit card processing after being declined by Stripe, Square, their bank, or PayPal. Looking toward next year, the service solves the immediate problem of sudden account closures and held deposits by providing an underwritten, nicotine\u2013friendly payment solution with clear pricing and one\u2013on\u2013one support.", + "url": "https://finnhub.io/api/news?id=7f539e4137ebba7f22056ecb47743b0df70f5f64c249ea021d011ad08d206e9b", + "datetime": "2026-01-06T19:08:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa, Mastercard track roughly 4% US holiday retail sales growth", + "content": "The 2025 holiday season saw consumers shopping across channels to land the best deals and maximize convenience, according to new reports.", + "url": "https://finnhub.io/api/news?id=0d94b758eca0cb33100bfb367ab90de5c2c566b4f52a7f4b95219fb52a772960", + "datetime": "2026-01-06T16:30:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Consumer groups attack card settlement", + "content": "Groups representing consumers and small business joined merchants in faulting a proposed Visa, Mastercard card fee settlement.", + "url": "https://finnhub.io/api/news?id=77a4e1444b457be52e1e557105ba25b259aa76d4b5c9a0539c2886f146274acb", + "datetime": "2026-01-06T16:16:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "My Dividend Growth Income: December 2025 Update", + "content": "Raised forward projected dividend income to $6,289.66 in December. Click here to read my portfolio analysis.", + "url": "https://finnhub.io/api/news?id=5c160c540ce5c6d099deb64a1fcf413c2fcbed9f7742d7e53f04a16119110529", + "datetime": "2026-01-06T13:49:06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Freedom Capital Sees Strong Results at Mastercard (MA), Maintains Hold View", + "content": "Mastercard Incorporated (NYSE:MA) is included among the 13 Best January Dividend Stocks to Invest in. On December 25, Freedom Capital analyst Mikhail Paramonov raised the firm\u2019s price target on Mastercard Incorporated (NYSE:MA) to $655 from $635 and kept a Hold rating on the shares. The change followed what the firm described as \u201cstrong\u201d Q4 results. [\u2026]", + "url": "https://finnhub.io/api/news?id=4b3e435d2ca911a2998a9d3c7f0eb62b9128c63714bf66681a6856606d3e2928", + "datetime": "2026-01-06T08:13:43", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "PayPal Casinos 2026: High 5 Casino Selected as Leading PayPal Casino", + "content": "CasinoTop10 Highlights High 5 Casino\u2019s PayPal Integration, Fast Transactions, and Player-Focused Gaming Experience for 2026 CHICAGO, IL, Jan. 05, 2026 (GLOBE NEWSWIRE) -- CasinoTop10, a trusted voice in igaming reviews, has officially named High 5 Casino as the best PayPal Casino, ranking the platform as the best for lightning-fast PayPal deposits and instant payouts. The site ensures a seamless online gaming experience for all players, enabling them to top up their accounts and withdraw their h", + "url": "https://finnhub.io/api/news?id=be6cb4b91f43dc5e99e2f63c101a07e10068099c6783c6a1184a5aabcdb51bb1", + "datetime": "2026-01-06T07:48:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Prediction: The S&P 500 $1 Trillion Club Will Double Between 2026 and 2030", + "content": "Several companies could see massive gains by 2030 to join the vaunted club.", + "url": "https://finnhub.io/api/news?id=c21b8422372dcd0fd38b21180a0c04914df14d2c1119ec0bda01a12f79987df4", + "datetime": "2026-01-06T05:50:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Why Does Visa Continue to Sit at the Center of Digital Payments?", + "content": "V remains central to digital payments thanks to its massive global network, transaction-based model and expanding value-added services as cash use declines.", + "url": "https://finnhub.io/api/news?id=b5eddf6787f624c20230022bc875bff0829bfbd65bf821721992595f16931e20", + "datetime": "2026-01-05T22:38:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa Inc. Cl A stock underperforms Monday when compared to competitors despite daily gains", + "content": "Visa Inc. Cl A stock underperforms Monday when compared to competitors despite daily gains", + "url": "https://finnhub.io/api/news?id=d571ceea6eeefe63ee6aaab7ecb7cfbbd580622a0b3d8dc8ed2de3f76a1a5e40", + "datetime": "2026-01-05T22:01:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "My Dividend Stock Portfolio: New November Dividend Record - 100 Holdings With 12 Buys", + "content": "Read about the latest moves in my dividend stock portfolio and why I trimmed NVDA/AMD on valuation and rotated into BDCs (ARCC, OBDC, HTGC) for yield.", + "url": "https://finnhub.io/api/news?id=c7d57cc52fce12c1c1fb6836f77982bdb9233d7d33af4280465110aaef63946d", + "datetime": "2026-01-05T21:17:28", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "This Week In Digital Payment - South Africa Embraces Cashless Revolution With Rapid Growth", + "content": "South Africa's digital payment landscape is experiencing significant growth, as highlighted by a report from EBC Financial Group. The rapid adoption of cashless transactions is fostering greater financial inclusion and economic participation, with card-based transactions projected to surpass ZAR2.9 trillion ($159 billion) by 2025. This growth is driven by increased merchant acceptance, advancements in contactless technology, and the proliferation of mobile-led payments. Moreover, the...", + "url": "https://finnhub.io/api/news?id=06324755f2f1329987484ca015be14f3b68d50036283538519a382208d4cede4", + "datetime": "2026-01-05T18:07:30", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "VIG: Proof That A Higher Yield Isn't Everything", + "content": "Vanguard Dividend Appreciation ETF (VIG) mixes dividend growth with favorable sectors. Read more about VIG here.", + "url": "https://finnhub.io/api/news?id=1c161cf429298579cc1f69edda183ab767a637d65062d78afe362969dd6b2950", + "datetime": "2026-01-05T12:45:00", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa-Issued Crypto Card Spending Jumps 525% in 2025", + "content": "Spending through Visa-issued crypto cards surged in 2025, with total net transaction volume rising 525% over the year, signaling growing consumer use of crypto-linked payment products for everyday purchases. Key Takeaways: Visa-issued crypto card spending jumped 525% in 2025, pointing to rising everyday use of crypto-linked payments. EtherFi led all ...", + "url": "https://finnhub.io/api/news?id=67b60cdfe08396c77986e5ad68bdda901e8afc1b6eba64040de0fa584688c7ec", + "datetime": "2026-01-05T12:43:11", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Why is no one seeing the bigger picture?", + "content": ">\\*Disclaimer\\*\n\n>The information provided is for educational and informational purposes only and does not constitute financial, investment, legal, or tax advice. Economic conditions, markets, and regulations can change rapidly, and past performance is not indicative of future results. You should conduct your own research and consult with a qualified professional before making any financial decisions.\n\n\n\nOk First of all i'm not good with words im more good with math and numbers... so i'll ", + "url": "https://reddit.com/r/stocks/comments/1q3e6jr/why_is_no_one_seeing_the_bigger_picture/", + "datetime": "2026-01-04T08:16:33", + "source": "Reddit", + "subreddit": "r/stocks" + } + ], + "item_count": 58, + "sources_used": [ + "Finnhub", + "Reddit" + ], + "source": "sentiment-basket", + "as_of": "2026-01-11" + }, + "generated_at": "2026-01-11T12:32:18.332380" +} +``` diff --git a/docs/mcp_raw_visa_test.json b/docs/mcp_raw_visa_test.json new file mode 100644 index 0000000000000000000000000000000000000000..d053a34507a78b539c0c56870af88861d53e397e --- /dev/null +++ b/docs/mcp_raw_visa_test.json @@ -0,0 +1,1368 @@ +{ + "ticker": "V", + "company_name": "Visa Inc.", + "sources_available": [ + "financials", + "valuation", + "volatility", + "macro", + "news", + "sentiment" + ], + "sources_failed": [], + "metrics": { + "financials": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "sec_edgar": { + "source": "SEC EDGAR XBRL", + "data": { + "financials": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_income": { + "value": 20058000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_income": { + "value": 23994000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_margin_pct": { + "value": 59.98, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_margin_pct": { + "value": 50.14, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "revenue_growth_3yr": { + "value": 14.34, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_assets": { + "value": 99627000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_liabilities": { + "value": 61718000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "stockholders_equity": { + "value": 26437000000, + "data_type": "FY", + "end_date": "2011-09-30", + "filed": "2011-11-18", + "fiscal_year": 2011, + "form": "10-K" + } + }, + "debt": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "long_term_debt": { + "value": 20977000000, + "data_type": "FY", + "end_date": "2021-09-30", + "filed": "2021-11-18", + "fiscal_year": 2021, + "form": "10-K" + }, + "total_debt": { + "value": 25171000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "cash": { + "value": 17164000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_debt": { + "value": 8007000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "debt_to_equity": { + "value": 0.95, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + }, + "cash_flow": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23059000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "capital_expenditure": { + "value": 59000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "free_cash_flow": { + "value": 23000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + } + } + }, + "yahoo_finance": { + "source": "Yahoo Finance", + "data": { + "financials": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_income": { + "value": 19853000704, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_profit": { + "value": 39105998848, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_margin_pct": { + "value": 97.76, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_margin_pct": { + "value": 49.63, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "debt": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "total_debt": { + "value": 26083999744, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "cash": { + "value": 18997000192, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_debt": { + "value": 7086999552, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "cash_flow": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23058999296, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "free_cash_flow": { + "value": 20072873984, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + } + } + } + }, + "source": "fundamentals-basket", + "as_of": "2026-01-11" + }, + "valuation": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "yahoo_finance": { + "source": "Yahoo Finance", + "regular_market_time": "2026-01-09", + "data": { + "current_price": 349.77, + "market_cap": 675020144640.0, + "enterprise_value": 677386649600.0, + "trailing_pe": 34.25759, + "forward_pe": 24.254278, + "ps_ratio": 16.875504, + "pb_ratio": 18.046125, + "ev_ebitda": 24.168, + "trailing_peg": 1.9228, + "forward_peg": null, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + }, + "alpha_vantage": { + "source": "Alpha Vantage", + "latest_quarter": "2025-09-30", + "fetched_at": "2026-01-09", + "data": { + "current_price": 339.81, + "market_cap": 675020145000.0, + "trailing_pe": 34.26, + "forward_pe": 27.32, + "ps_ratio": 16.88, + "pb_ratio": 18.15, + "ev_ebitda": 26.2, + "trailing_peg": 1.923, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + } + }, + "source": "valuation-basket", + "as_of": "2026-01-11" + }, + "volatility": { + "group": "raw_metrics", + "ticker": "V", + "metrics": { + "vix": { + "value": 15.45, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "vxn": { + "value": 20.15, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "beta": { + "value": 0.785, + "data_type": "1Y", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "historical_volatility": { + "value": 22.16, + "data_type": "30D", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "implied_volatility": { + "value": 30.0, + "data_type": "Forward", + "as_of": "2026-01-11", + "source": "Market Average (estimated)", + "fallback": true + } + }, + "source": "volatility-basket", + "as_of": "2026-01-11" + }, + "macro": { + "group": "raw_metrics", + "ticker": "MACRO", + "metrics": { + "gdp_growth": { + "value": 4.3, + "data_type": "Quarterly", + "as_of": "2025Q3", + "source": "BEA (Bureau of Economic Analysis)", + "fallback": false + }, + "interest_rate": { + "value": 3.72, + "data_type": "Monthly", + "as_of": "2025-12-01", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "cpi_inflation": { + "value": 2.74, + "data_type": "Monthly", + "as_of": "2025-November", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + }, + "unemployment": { + "value": 4.4, + "data_type": "Monthly", + "as_of": "2025-December", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + } + }, + "source": "macro-basket", + "as_of": "2026-01-11" + }, + "news": { + "group": "content_analysis", + "ticker": "V", + "query": "Visa Inc. (V) stock news", + "items": [ + { + "title": "After Three Hot Years, Will Stock Markets Sizzle Again?", + "content": "Returns have been fabulous but consider the potential for setbacks in this already hazardous year, our columnist says.", + "url": "https://www.nytimes.com/2026/01/09/business/stock-market-investing-returns.html", + "datetime": "2026-01-09", + "source": "New York Times" + }, + { + "title": "Bill Gates Makes a Multibillion-Dollar Divorce Payout", + "content": "The billionaire and philanthropist has made a nearly $8 billion payment to the private foundation of his ex-wife, Melinda French Gates, tax filings show.", + "url": "https://www.nytimes.com/2026/01/09/business/dealbook/gates-divorce-settlement.html", + "datetime": "2026-01-09", + "source": "New York Times" + }, + { + "title": "Is Visa Inc. (V) One of the Best Major Stocks to Invest in Right Now?", + "content": "Visa Inc. (NYSE:V) is one of the best major stocks to invest in right now. Monness, Crespi, Hardt & Co., Inc. reiterated a Hold rating on Visa Inc. (NYSE:V) ...", + "url": "https://finance.yahoo.com/news/visa-inc-v-one-best-092151784.html", + "datetime": "2026-01-09", + "source": "Yahoo Entertainment" + }, + { + "title": "All Eyes on the U.S. in 2026", + "content": "Our reporters in Washington tell us what they\u2019re watching from the Trump administration.", + "url": "https://www.nytimes.com/2026/01/07/world/us-trump-venezuela-2026.html", + "datetime": "2026-01-07", + "source": "New York Times" + }, + { + "title": "Why Trump\u2019s Greenland Strategy Has the World on Edge", + "content": "The president is again focused on acquiring the mineral-rich island. But lack of clarity about his goals and tactics is weighing on political and business leaders.", + "url": "https://www.nytimes.com/2026/01/07/business/dealbook/buy-invade-trump-greenland.html", + "datetime": "2026-01-07", + "source": "New York Times" + }, + { + "title": "Oil Stocks Rally as Investors Bet on Return to Venezuela", + "content": "The energy sector of the S&P 500 rose 2.7 percent on Monday, lifting the broader market.", + "url": "https://www.nytimes.com/2026/01/05/business/oil-stocks-venezuela.html", + "datetime": "2026-01-05", + "source": "New York Times" + }, + { + "title": "5 fantastic ASX ETFs for beginners in 2026", + "content": "These funds are highly rated for a reason. Here's what you need to know about them.\nThe post 5 fantastic ASX ETFs for beginners in 2026 appeared first on The Motley Fool Australia.", + "url": "https://www.fool.com.au/2026/01/02/5-fantastic-asx-etfs-for-beginners-in-2026/", + "datetime": "2026-01-02", + "source": "Motley Fool Australia" + }, + { + "title": "Visa (NYSE: V) Stock Price Prediction and Forecast 2026-2030 (Jan 2026)", + "content": "This year, Visa Inc. (NYSE: V) has unveiled a scam disruption initiative, adoption of its \u201cTap to Phone\u201d technology has soared, it unveiled its vision for artificial intelligence (AI) in commerce, and it expanded its capabilities in the digital currency space\u2026", + "url": "https://biztoc.com/x/c031d63d4b6abfd2", + "datetime": "2026-01-01", + "source": "Biztoc.com" + }, + { + "title": "Visa Stock Price and Chart \u2014 NYSE:V", + "content": "# Visa Inc. Visa (V): Emerging Uptrend Backed by Exceptional FundamentalsVisa is showing renewed technical strength, marked by the formation of higher highs and higher lows, with price action firmly holding above both the 20- and 50-day EMAs. This move follows a decisive breakout from a prolonged downtrend, signaling a shift in market structure toward a bullish trend. Visa Inc. 3.875% 15-MAY-2044. Visa Inc. 3.5% 15-MAY-2037. Watch Visa Inc.stock price performance more closely on the chart. For instance, on NYSEexchange Visa Inc.stocks are traded under the ticker V. Watch V chart and read a more detailed Visa Inc. stock forecast: see what analysts think of Visa Inc. and suggest that you do with its stocks. Track Visa Inc.stock price on the chart and check out the list of the most volatile stocks \u2014 is Visa Inc. there? You can trade Visa Inc.stock right from TradingView charts \u2014 choose your broker and connect to your account. Since market conditions are prone to changes, it's worth looking a bit further into the future \u2014 according to the 1 month rating Visa Inc.stock shows the buy signal.", + "url": "https://www.tradingview.com/symbols/NYSE-V/", + "datetime": null, + "source": null + }, + { + "title": "V: Visa Inc - Stock Price, Quote and News", + "content": "# Visa Inc V:NYSE. * 52 Week High Date06/11/25. * 52 Week Low Date04/07/25. ## Latest On Visa Inc. * CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and VisaCNBC.com. And payment giants are preparingCNBC.com. * Economy grows, chip tariff delay, new S&P 500 record and more in Morning SquawkCNBC.com. ## Latest On Visa Inc. * CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and VisaCNBC.com. And payment giants are preparingCNBC.com. * 52 Week High Date06/11/25. * 52 Week Low Date04/07/25. * P/E (TTM)34.66. * Earnings Date01/28/2026(est). * When Stock Recovery Defeats Securities Fraud Claims: The Visa DecisionTipRanks. * Monness Keeps Their Hold Rating on Visa (V)\")TipRanks. * Visa Stock Stumbles While Its Crypto Card Network Sees 525% Volume ExplosionTipRanks. * Visa Updates Class B Conversion Rates After Escrow DepositTipRanks. CNBC's Investment Committee's Top Picks for 2026: Amazon, Sabra and Visa. Visa Inc. is a global payments technology company. Vice Chairman, Chief People and Corporate Affairs Officer and Corporate Secretary. Independent Chairman of the Board.", + "url": "https://www.cnbc.com/quotes/V", + "datetime": null, + "source": null + }, + { + "title": "Buy or Sell Visa Stock - V Stock Price Quote & News", + "content": "On 2026-01-09, Visa(V) stock moved within a range of $349.00 to $354.70. With shares now at $355.54, the stock is trading +1.9% above its intraday low and +0.2%", + "url": "https://robinhood.com/us/en/stocks/V/", + "datetime": null, + "source": null + }, + { + "title": "Visa Inc. (V) Stock Price, News, Quote & History", + "content": "(V) Stock Price, News, Quote & History - Yahoo Finance. ### [News](https://www.yahoo.com/). * [US](https://www.yahoo.com/news/us/). ### [Finance](https://finance.yahoo.com/). * [My Portfolio](https://finance.yahoo.com/portfolios/). * [News](https://finance.yahoo.com/news/). + [Earnings](https://finance.yahoo.com/topic/earnings/). * [Markets](https://finance.yahoo.com/markets/). + [Stocks](https://finance.yahoo.com/markets/stocks/). - [Highest dividend](https://finance.yahoo.com/markets/stocks/highest-dividend/). - [Unusual volume](https://finance.yahoo.com/markets/stocks/unusual-volume-stocks/). + [Private companies](https://finance.yahoo.com/markets/private-companies/highest-valuation/). - [52 week gainers](https://finance.yahoo.com/markets/private-companies/52-week-gainers/). - [Most funded](https://finance.yahoo.com/markets/private-companies/most-funded/). + [Sectors](https://finance.yahoo.com/sectors/). - [Technology](https://finance.yahoo.com/sectors/technology/). - [Financial services](https://finance.yahoo.com/sectors/financial-services/). - [Industrials](https://finance.yahoo.com/sectors/industrials/). + [Futures](https://finance.yahoo.com/markets/commodities/). + [Currencies](https://finance.yahoo.com/markets/currencies/ ). + [Calendar](https://finance.yahoo.com/calendar/). - [Earnings](https://finance.yahoo.com/calendar/earnings/). - [First time homebuyer](https://finance.yahoo.com/personal-finance/mortgages/article/first-time-home-buyer-195246478.html). * [Videos](https://finance.yahoo.com/videos/). * [Watch Now](https://finance.yahoo.com/live/). ### [Sports](https://sports.yahoo.com/). * [Show all](https://sports.yahoo.com/). * [News](https://www.yahoo.com/). * [Finance](https://finance.yahoo.com/). * [Sports](https://sports.yahoo.com/). + [News](https://www.yahoo.com/). - [US](https://www.yahoo.com/news/us/). + [Finance](https://finance.yahoo.com/). - [My portfolio](https://finance.yahoo.com/portfolios/). - [Markets](https://finance.yahoo.com/calendar/). - [News](https://finance.yahoo.com/news/). - [Videos](https://finance.yahoo.com/videos/). - [Sectors](https://finance.yahoo.com/sectors/). + [Sports](https://sports.yahoo.com/). - [Show all](https://sports.yahoo.com/). 1. [My Portfolio](https://finance.yahoo.com/portfolios/). 2. [News](https://finance.yahoo.com/news/). 7. [Earnings](https://finance.yahoo.com/topic/earnings/). 3. [Markets](https://finance.yahoo.com/markets/). 1. [Stocks](https://finance.yahoo.com/markets/stocks/). 5. [Highest dividend](https://finance.yahoo.com/markets/stocks/highest-dividend/). [Unusual volume](https://finance.yahoo.com/markets/stocks/unusual-volume-stocks/). 4. [Private companies](https://finance.yahoo.com/markets/private-companies/highest-valuation/). 1. [52 week gainers](https://finance.yahoo.com/markets/private-companies/52-week-gainers/). 3. [Most funded](https://finance.yahoo.com/markets/private-companies/most-funded/). 5. [Sectors](https://finance.yahoo.com/sectors/). 1. [Technology](https://finance.yahoo.com/sectors/technology/). 2. [Financial services](https://finance.yahoo.com/sectors/financial-services/). 6. [Industrials](https://finance.yahoo.com/sectors/industrials/). 7. [Futures](https://finance.yahoo.com/markets/commodities/). 8. [Currencies](https://finance.yahoo.com/markets/currencies/ ). 3. [Calendar](https://finance.yahoo.com/calendar/). 1. [Earnings](https://finance.yahoo.com/calendar/earnings/). 1. [My Money BETA](https://finance.yahoo.com/my-money/). 4. [First time homebuyer](https://finance.yahoo.com/personal-finance/mortgages/article/first-time-home-buyer-195246478.html). 7. [Videos](https://finance.yahoo.com/videos/). 8. [Watch Now](https://finance.yahoo.com/live/).", + "url": "https://finance.yahoo.com/quote/V/", + "datetime": null, + "source": null + } + ], + "item_count": 12, + "sources_used": [ + "Tavily", + "NYT", + "NewsAPI" + ], + "source": "news-basket", + "as_of": "2026-01-11" + }, + "sentiment": { + "group": "content_analysis", + "ticker": "V", + "items": [ + { + "title": "Googl gains", + "content": "Bought after 2025 February earnings, doubled down in April dip, some more in May dip. Started with 60k. \n\nSold lots of weeklies to you regards against my long calls; just closed my position. \n\nWill dip my legs into goog again for the next run to $400. \n\nMicro contributors - aapl in Jan, Msft in May; avgo after earnings and MU. ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q9r6es/googl_gains/", + "datetime": "2026-01-11", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "5 Things That Won't Happen In 2026 (The Alpha Of Inertia)", + "content": "This article lays out five slow-moving certainties for 2026 that investors can lean on. Click here to read more.", + "url": "https://finnhub.io/api/news?id=dc02cc7e318de2d458d5b92b23ef870ba3ca8e58c355af40624245a0a5104aac", + "datetime": "2026-01-10", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Oklo ride to 1000$", + "content": "Call me skeptical boys but after losing a 160k I am going to let this ride to the moon. Not going to take profits until expiration. Diamond hands \ud83d\udc8e\ud83d\ude4c Let\u2019s see how much this can make me..", + "url": "https://reddit.com/r/wallstreetbets/comments/1q9b1qx/oklo_ride_to_1000/", + "datetime": "2026-01-10", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Rain Raises $250 Million at $1.95 Billion Valuation for Stablecoin Payments Expansion", + "content": "ICONIQ led the round as Rain targets growth across the Americas, Europe, Asia and Africa.", + "url": "https://finnhub.io/api/news?id=5b4865a899c398090c883407215234a2257358b44e7887e6273245102ea078e3", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa's Tokenization Push Is Becoming More Than a Security Play", + "content": "V's tokenization push is evolving beyond security, boosting transaction efficiency, AI-driven insights and seamless payments across devices.", + "url": "https://finnhub.io/api/news?id=6a13ef2d88f1a76f3e9aa488ff8145e902e35a956e8d738a011cb308c46e00bc", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Circle's Non-Interest Revenues Accelerate: Can the Momentum Continue?", + "content": "CRCL shows rapid growth in non-interest revenues, lifting 2025 guidance as subscriptions, services and transaction fees scale across its platform.", + "url": "https://finnhub.io/api/news?id=1289ce4bcc1cdc2007ebf983c1432f188ec44e19f416675d57c7840935a79e3a", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Mastercard Up 7.6% in a Month: Are Investors Looking Beyond AI Hype?", + "content": "MA shares jump 7.6% in a month as investors rotate from AI trades toward durable payment networks with steady growth drivers.", + "url": "https://finnhub.io/api/news?id=1f8cda21dfecdb07398c4a91e6db2482c248150caeb8f6e60fa8c75a5dba1866", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "The Influencer Marketing Factory Unveils Season 6 of \"The Influence Factor\" Podcast with Cutting-Edge Industry Leaders", + "content": "NEW YORK CITY, NEW YORK / ACCESS Newswire / January 9, 2026 / Global influencer marketing leader The Influencer Marketing Factory today announced Season 6 of its acclaimed podcast,The Influence Factor . Bi-weekly episodes will feature conversations ...", + "url": "https://finnhub.io/api/news?id=2d14c9529412c1c8e935a931fc5a332c7d5348f12652408c956d87fd2f6d3c10", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Fiserv: Potentially In A Bottoming Process", + "content": "Fiserv (FISV) stock plunged 70% and FY25 guidance reset.", + "url": "https://finnhub.io/api/news?id=a1e7f5530265f365630da945e31e0b63dd4badff90a7accd50a181ce8ead81b7", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Why is Mastercard Incorporated (MA) One of the Best Major Stocks to Invest in Right Now?", + "content": "Mastercard Incorporated (NYSE:MA) is one of the best major stocks to invest in right now. Monness Crespi Hardt & Co., Inc. reiterated a Hold rating on Mastercard Incorporated (NYSE:MA) on January 5 and set a price target of $525.00. In addition, Keefe, Bruyette & Woods maintained a Buy rating on the company on January 2 [\u2026]", + "url": "https://finnhub.io/api/news?id=8bd58b88bae91ac0c493840cb9b9f851b4fdf1b61c6ec880020ad51e4ebc2a04", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Is Visa Inc. (V) One of the Best Major Stocks to Invest in Right Now?", + "content": "Visa Inc. (NYSE:V) is one of the best major stocks to invest in right now. Monness, Crespi, Hardt & Co., Inc. reiterated a Hold rating on Visa Inc. (NYSE:V) on January 5 and set a price target of $330.00. In a separate development, Visa Inc. (NYSE:V) and Fiserv, Inc. announced on December 22 a strategic [\u2026]", + "url": "https://finnhub.io/api/news?id=4a7abccded3cd2b373deb1ecf6f665a30923fd414a0e26e1026f228f1885d732", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Brown & Brown: A Deep Value Opportunity", + "content": "Brown & Brown stock looks undervalued given its EPS growth and long dividend streak. Click here to see why I rate BRO stock a Buy.", + "url": "https://finnhub.io/api/news?id=bcbec1bc767dc8f96c4bca19f5565fcbc69129c77eb386e6a1e7e648e9452f04", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa and JPMorgan Use Solana Rails But One Risk Still Worries Banks", + "content": "Major financial institutions, including JPMorgan and Visa, are increasingly turning to .cwp-coin-chart svg path { stroke-width: 0.65 !important; } .cwp-coin-widget-container .cwp-graph-container.positive svg path:nth-of-type(2) { stroke: #008868 !important; } .cwp-coin-widget-container .cwp-coin-trend.positive { color: #008868 !important; background-color: transparent !important; } .cwp-coin-widget-container .cwp-coin-popup-holder .cwp-coin-trend.positive { border: 1px solid #008868; border-radi", + "url": "https://finnhub.io/api/news?id=d01493ec2df5e6ce762d753f6aa91b2b04a0071e70c227eb13ff0060fb4f0b88", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Assessing Visa (V) Valuation After Partnerships And Services Fuel Recent Share Price Momentum", + "content": "Visa (V) has been in focus after Deluxe adopted Visa Direct to power dlxFastFunds, offering near real-time funding for businesses and reducing typical settlement delays to support cash flow and day-to-day operations. See our latest analysis for Visa. Those product wins sit alongside a steady share price story, with Visa\u2019s 1 month share price return of 7.9% and 1 year total shareholder return of 13.5%. Its 3 year total shareholder return of 61.1% points to momentum that has built over time...", + "url": "https://finnhub.io/api/news?id=3bca1517d817f4be8e186900396305af36eaa89900335fb6238abe5373ed416f", + "datetime": "2026-01-09", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "$8K to $46K in my 0DTE account this week, took out $8K then blew the rest on SPY $588 puts. Wish I took out more, greed & too excited again.", + "content": "", + "url": "https://reddit.com/r/wallstreetbets/comments/1q7sy7u/8k_to_46k_in_my_0dte_account_this_week_took_out/", + "datetime": "2026-01-09", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "RDDT 250c 3/20", + "content": "Let\u2019s go 300 after ER. I can feel it in my plums ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q8ep76/rddt_250c_320/", + "datetime": "2026-01-09", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Visa (V) Stock Falls Amid Market Uptick: What Investors Need to Know", + "content": "Visa (V) closed the most recent trading day at $352.23, moving 1.03% from the previous trading session.", + "url": "https://finnhub.io/api/news?id=e1852c5ac307a1fb1db7702bebe0f1f73f99059bdac6261abfe19a0b2731d857", + "datetime": "2026-01-08", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Top 50 High-Quality Dividend Growth Stocks For January 2026", + "content": "2025 dividend growth stock screen: 50 picks, FCF valuation, +10.55% return. 17 stocks appear potentially undervalued, while 7 saw valuation downgrades. See more.", + "url": "https://finnhub.io/api/news?id=671f8c9315813f7a75e40f073a9858371b2150af06b9af85636d996aba0f4404", + "datetime": "2026-01-08", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Deluxe to Implement Visa Direct to Enable Fast, Seamless Payments with dlxFastFunds", + "content": "MINNEAPOLIS, January 08, 2026--Deluxe (NYSE: DLX), a trusted Payments and Data company, today announced its collaboration with Visa to implement Visa Direct. This collaboration introduces dlxFastFundsSM, a funding solution that leverages Visa\u2019s trusted payment network to help businesses take control of their cash flow by skipping the typical one- to two-day settlement delay.", + "url": "https://finnhub.io/api/news?id=39cfb0684de57341da1b77116e273e6971c05fd8c71cf3ab7f58736916546ba5", + "datetime": "2026-01-08", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Polygon Labs Launches Open Money Stack to Bridge Fiat and Onchain Settlement", + "content": "Polygon Labs has unveiled the Open Money Stack, a new set of rails designed to support regulated stablecoin payments and close the infrastructure gap between wallets, fiat access, routing and orchestration, compliance, and on-chain settlement. The next three years will define how money moves over the next thirty years.The Polygon ...", + "url": "https://finnhub.io/api/news?id=fdda05347f5ac2f705b44111e768cbfa316c11a0bb79c9d19991330d26e65f8d", + "datetime": "2026-01-08", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "EA SPORTS\u2122 Presents Madden Bowl to Headline Super Bowl LX Week With Luke Combs, Teddy Swims and More", + "content": "REDWOOD CITY, Calif., January 08, 2026--EA SPORTS\u2122 today unveiled the music lineup for EA SPORTS\u2122 Presents Madden Bowl in Partnership with Visa, delivering Super Bowl week\u2019s premier night of football and culture to the Bay. On Friday, February 6, Luke Combs, Teddy Swims, Stephen Wilson Jr, Gavin Adcock, and the Bay Area\u2019s own LaRussell will take over San Francisco\u2019s Chase Center for a high-energy celebration bringing fans, music, and football together under one roof.", + "url": "https://finnhub.io/api/news?id=2a92a99d52190b4eda53a7f562ce880f50cc2c51e624e686fbbf3a5c8a2fb74f", + "datetime": "2026-01-08", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "What Recent Analyst Calls Mean For Visa\u2019s (V) Evolving Story And Valuation", + "content": "Why Visa\u2019s Price Target Just Nudged Higher Visa\u2019s modeled fair value estimate has shifted only slightly to about US$395.85 from US$395.44, a small change that still reflects the company\u2019s central role in global payments and its large, widely used network. This modest move aligns with views that Visa\u2019s brand strength, cross border business and perceived quality versus some peers can support a higher price target, even as more cautious voices point to execution risks and already high...", + "url": "https://finnhub.io/api/news?id=7bdee8df49015189419a1c3f7dd04cbf86dc080cea8ec9bea3afc4d30a091aa0", + "datetime": "2026-01-08", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Casino Apps USA 2026: LoneStar's Leading Mobile Casino App", + "content": "A 2026 Look at LoneStar\u2019s Mobile-First Sweepstakes Casino Platform, Features, and User Experience in the U.S.Atlantic City, New Jersey, Jan. 07, 2026 (GLOBE NEWSWIRE) -- The LoneStar Casino app has quickly emerged as a top choice for American players since its successful launch in early 2025. Noting the impact of mobile gaming, the sweepstakes casino has mainly focused on convenience and flexibility through a mobile-optimized platform. As a newcomer to the sweepstakes community, LoneStar has ent", + "url": "https://finnhub.io/api/news?id=3cadc758fda0b63f99d52bdd5767671a30c6b458f5faa448dbe318d8954f46c7", + "datetime": "2026-01-08", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "ONDS--drones, so hot right now...drones.", + "content": "V cool and v legal company with a lot of room to grow in the coming years. Also, trains are involved...literally an autist's dream. ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q7jaje/ondsdrones_so_hot_right_nowdrones/", + "datetime": "2026-01-08", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Third time is charm for posting", + "content": "Whatever is the reason, it keeps getting removed so I am posting gain.", + "url": "https://reddit.com/r/wallstreetbets/comments/1q72kc9/third_time_is_charm_for_posting/", + "datetime": "2026-01-08", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "SCHD ETF Alternative Strategy Aimed For Higher Total Return", + "content": "4-factor dividend growth portfolio beats SCHD: 14.74% CAGR vs 7.28%. Portfolio turnover averages 40\u00e2\u0080\u009350% annually, with stable dividend growth. See more here.", + "url": "https://finnhub.io/api/news?id=dbd59f8f7ee961e57a20dfa24f9f4284f6813c4bfe3ddd2217fbd06f79f32720", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Global Card Fraud Losses at $33 Billion", + "content": "The Nilson Report Marks Decline in Card FraudSANTA BARBARA, Calif., Jan. 07, 2026 (GLOBE NEWSWIRE) -- Payment card fraud losses worldwide dipped 1.2% to $33.41 billion in 2024, according to the Nilson Report, the leading trade publication covering the global payment card industry. This fraud was tied to global card volume of $51.920 trillion. Losses to fraud are incurred by credit, debit and prepaid card issuers, merchants, processors of card payments from merchants and processors of card transa", + "url": "https://finnhub.io/api/news?id=243d4205b93aae6fa120b268c218a30c7586056da88b5a6dc353084ca0a44f1f", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "WEX or V: Which Is the Better Value Stock Right Now?", + "content": "WEX vs. V: Which Stock Is the Better Value Option?", + "url": "https://finnhub.io/api/news?id=db10552a92d9208e9243100ac6c641d30aa7d824a0385d058880091cad0be3df", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa Stock's Steady 14.4% Rise in A Year: Can 2026 Add More Firepower?", + "content": "V's quiet consistency powers growth, stablecoin expansion, and AI-ready rails, steady, not flashy, but hard to ignore.", + "url": "https://finnhub.io/api/news?id=99ab1a0174780352d537a5fa91fba8ed2d0753082ced1833a627155f34e12ebc", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "How nonpayments became big business at Visa and Mastercard", + "content": "With fintechs and legal cases pressuring payment fees, the card companies are leaning more on revenue from other sources.", + "url": "https://finnhub.io/api/news?id=12860aaa4567cbf6661f2e4326a988edcd3ac22f58674961e4a1ee7236f46bd3", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Investors Heavily Search Visa Inc. (V): Here is What You Need to Know", + "content": "Zacks.com users have recently been watching Visa (V) quite a bit. Thus, it is worth knowing the facts that could determine the stock's prospects.", + "url": "https://finnhub.io/api/news?id=f163bc032a9a11ef14cb90f78f037851789de56300db553a735c964c297773a6", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Diginex Limited Announces Signing of Definitive Agreement to Acquire Plan A, Creating One of Europe\u2019s Leading Integrated ESG, Carbon Accounting and Decarbonization Platforms", + "content": "The combined business will deliver a single sophisticated platform to expand beyond existing strategic relationships, including HSBC, Coca Cola, Visa, and BMW.LONDON, Jan. 07, 2026 (GLOBE NEWSWIRE) -- Diginex Limited (NASDAQ: DGNX), a leading provider of Sustainability RegTech and data management solutions, today announced the signing of a definitive share purchase and transfer agreement (the \u201cSPTA\u201d), to acquire PlanA.earth GmbH (\"Plan A\"), one of Europe's leading AI-powered carbon accounting an", + "url": "https://finnhub.io/api/news?id=b8513dc445d50098a89a945f4795b079d4972cd3dc7787af8eda7aee4eebb102", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa, Mastercard, ACI and Wex: 2026 predictions", + "content": "Major payments companies see several 2026 trends beyond the much-discussed stablecoin and agentic commerce crazes.", + "url": "https://finnhub.io/api/news?id=79b17d21b24b5c097282d9ccc00dd1840b91800c69c082c5decd99ee7b54b06a", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa Crypto Card Spending Jumps 525%: Is Mainstream Adoption Finally Here?", + "content": "Visa-linked crypto card spending jumped 525% in 2025, rising from $14.6M to $91.3M in net spend, according to a Dune dashboard from @obchakevich research. This shift fits a broader pattern. Stablecoins and payment rails now carry trillions of dollars in monthly volume, turning crypto from speculation into something closer to ...", + "url": "https://finnhub.io/api/news?id=d0893c2096069a6eafd7fdc5fbe197be07fa1a9b4aec82a1f15ed17941cfeeba", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa: The Easiest Way To Benefit From Consumers", + "content": "Visa stock is a \"Buy\" given its tollbooth role in the global economy. Here's what investors need to know.", + "url": "https://finnhub.io/api/news?id=0c74dc085ccd9cf2e470899b1d89b0d0b1a854e62e63b6bfea0f458623641e8e", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa (V): 3 Reasons We Love This Stock", + "content": "Since July 2025, Visa has been in a holding pattern, floating around $357.79. The stock also fell short of the S&P 500\u2019s 10.8% gain during that period.", + "url": "https://finnhub.io/api/news?id=a708d77d34904a4beb1e1a1e1e746cb8146edc91b6158a3f6d004c7582d0e2f2", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Is Visa (V) Still Reasonably Priced After Multi Year Share Price Strength?", + "content": "If you are wondering whether Visa's current share price still offers value, it helps to step back and look at what the recent numbers are actually telling you. Visa shares last closed at US$357.56, with returns of 1.1% over 7 days, 7.9% over 30 days, 3.2% year to date, 15.5% over 1 year, 65.4% over 3 years and 77.5% over 5 years. This gives you a clear snapshot of how the stock has behaved over different time frames. Recent headlines around Visa have largely focused on its role in global...", + "url": "https://finnhub.io/api/news?id=3eb7d568e1fd40b842e6a56df369e6dd7de93cd12ae832778e569cafb5017f62", + "datetime": "2026-01-07", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa vs. Mastercard: Which Is the Better Growth Stock for 2026?", + "content": "Both companies have impressive business momentum, but one stock looks like the better buy headed into 2026.", + "url": "https://finnhub.io/api/news?id=28bdd8a4f1c45d08c86afe55ddf494344df26baff255304b968ea9f9d8027865", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Here\u2019s the net worth and income of America\u2019s top 10%. Are you on track to catch up?", + "content": "Entry into the \"affluent class\" is harder than it used to be.", + "url": "https://finnhub.io/api/news?id=6eb2042ed3384118ef33e26ac6b10b8c56953db2d71f67857fbc85900ca01cb8", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "How Mastercard Is Diversifying Growth Beyond Card-Based Payments", + "content": "MA is expanding beyond card payments as value-added services like AI-driven security and data solutions fuel revenue growth.", + "url": "https://finnhub.io/api/news?id=9a9263d7cd877a1fb516f01e579bd5f3dc9b3559ae7e69203753c9bf99c6fd1a", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Why These 3 Mega-Caps Could Still Surprise Investors in 2026", + "content": "Three durable compounders for 2026: Visa\u2019s payment scale, Walmart\u2019s 52-year dividend streak, and Amazon\u2019s cloud-led rebound after a heavy AI spending cycle.", + "url": "https://finnhub.io/api/news?id=89558854e0cf2937e14daa88815503902d35d14bb220132d4322827d4ab7f471", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Russia\u2019s digital ruble will undercut its own card payment system, says analyst", + "content": "Digital ruble launch will see bank card market growth fall by up to 12% a year, expert says. MIR is Russia\u2019s answer to Visa and Mastercard. Digital ruble set to roll out in September. Foreign card firms \u201cwill never again dominate the Russian financial system.\u201d", + "url": "https://finnhub.io/api/news?id=d82bed2d9d6496004aca6f073533eedcac0a0f1292395f71a7c7a649714b526b", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Rose's Income Garden Portfolio: 8 December Raises Report", + "content": "RIG portfolio gained 12.97% in 2025, matching the Dow with a 6.29% forward yield.", + "url": "https://finnhub.io/api/news?id=8cc766fe43a5294b540bc70630cd3174b8a813bd5c2236ca9bafd8d16e15ff21", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Credit Card Processing for Nicotine Pouches is on Tower Payments' Radar for 2026", + "content": "Tower Payments today announced a focused payment gateway and merchant account service that helps nicotine pouch websites restore stable credit card processing after being declined by Stripe, Square, their bank, or PayPal. Looking toward next year, the service solves the immediate problem of sudden account closures and held deposits by providing an underwritten, nicotine\u2013friendly payment solution with clear pricing and one\u2013on\u2013one support.", + "url": "https://finnhub.io/api/news?id=7f539e4137ebba7f22056ecb47743b0df70f5f64c249ea021d011ad08d206e9b", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa, Mastercard track roughly 4% US holiday retail sales growth", + "content": "The 2025 holiday season saw consumers shopping across channels to land the best deals and maximize convenience, according to new reports.", + "url": "https://finnhub.io/api/news?id=0d94b758eca0cb33100bfb367ab90de5c2c566b4f52a7f4b95219fb52a772960", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Consumer groups attack card settlement", + "content": "Groups representing consumers and small business joined merchants in faulting a proposed Visa, Mastercard card fee settlement.", + "url": "https://finnhub.io/api/news?id=77a4e1444b457be52e1e557105ba25b259aa76d4b5c9a0539c2886f146274acb", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "My Dividend Growth Income: December 2025 Update", + "content": "Raised forward projected dividend income to $6,289.66 in December. Click here to read my portfolio analysis.", + "url": "https://finnhub.io/api/news?id=5c160c540ce5c6d099deb64a1fcf413c2fcbed9f7742d7e53f04a16119110529", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Freedom Capital Sees Strong Results at Mastercard (MA), Maintains Hold View", + "content": "Mastercard Incorporated (NYSE:MA) is included among the 13 Best January Dividend Stocks to Invest in. On December 25, Freedom Capital analyst Mikhail Paramonov raised the firm\u2019s price target on Mastercard Incorporated (NYSE:MA) to $655 from $635 and kept a Hold rating on the shares. The change followed what the firm described as \u201cstrong\u201d Q4 results. [\u2026]", + "url": "https://finnhub.io/api/news?id=4b3e435d2ca911a2998a9d3c7f0eb62b9128c63714bf66681a6856606d3e2928", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "PayPal Casinos 2026: High 5 Casino Selected as Leading PayPal Casino", + "content": "CasinoTop10 Highlights High 5 Casino\u2019s PayPal Integration, Fast Transactions, and Player-Focused Gaming Experience for 2026 CHICAGO, IL, Jan. 05, 2026 (GLOBE NEWSWIRE) -- CasinoTop10, a trusted voice in igaming reviews, has officially named High 5 Casino as the best PayPal Casino, ranking the platform as the best for lightning-fast PayPal deposits and instant payouts. The site ensures a seamless online gaming experience for all players, enabling them to top up their accounts and withdraw their h", + "url": "https://finnhub.io/api/news?id=be6cb4b91f43dc5e99e2f63c101a07e10068099c6783c6a1184a5aabcdb51bb1", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Prediction: The S&P 500 $1 Trillion Club Will Double Between 2026 and 2030", + "content": "Several companies could see massive gains by 2030 to join the vaunted club.", + "url": "https://finnhub.io/api/news?id=c21b8422372dcd0fd38b21180a0c04914df14d2c1119ec0bda01a12f79987df4", + "datetime": "2026-01-06", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Back again with Moderna calls 400 buys $36 calls", + "content": "This time I sold before posting to Reddit ", + "url": "https://reddit.com/r/wallstreetbets/comments/1q5tpzb/back_again_with_moderna_calls_400_buys_36_calls/", + "datetime": "2026-01-06", + "source": "Reddit", + "subreddit": "r/wallstreetbets" + }, + { + "title": "Why Does Visa Continue to Sit at the Center of Digital Payments?", + "content": "V remains central to digital payments thanks to its massive global network, transaction-based model and expanding value-added services as cash use declines.", + "url": "https://finnhub.io/api/news?id=b5eddf6787f624c20230022bc875bff0829bfbd65bf821721992595f16931e20", + "datetime": "2026-01-05", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa Inc. Cl A stock underperforms Monday when compared to competitors despite daily gains", + "content": "Visa Inc. Cl A stock underperforms Monday when compared to competitors despite daily gains", + "url": "https://finnhub.io/api/news?id=d571ceea6eeefe63ee6aaab7ecb7cfbbd580622a0b3d8dc8ed2de3f76a1a5e40", + "datetime": "2026-01-05", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "My Dividend Stock Portfolio: New November Dividend Record - 100 Holdings With 12 Buys", + "content": "Read about the latest moves in my dividend stock portfolio and why I trimmed NVDA/AMD on valuation and rotated into BDCs (ARCC, OBDC, HTGC) for yield.", + "url": "https://finnhub.io/api/news?id=c7d57cc52fce12c1c1fb6836f77982bdb9233d7d33af4280465110aaef63946d", + "datetime": "2026-01-05", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "This Week In Digital Payment - South Africa Embraces Cashless Revolution With Rapid Growth", + "content": "South Africa's digital payment landscape is experiencing significant growth, as highlighted by a report from EBC Financial Group. The rapid adoption of cashless transactions is fostering greater financial inclusion and economic participation, with card-based transactions projected to surpass ZAR2.9 trillion ($159 billion) by 2025. This growth is driven by increased merchant acceptance, advancements in contactless technology, and the proliferation of mobile-led payments. Moreover, the...", + "url": "https://finnhub.io/api/news?id=06324755f2f1329987484ca015be14f3b68d50036283538519a382208d4cede4", + "datetime": "2026-01-05", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "VIG: Proof That A Higher Yield Isn't Everything", + "content": "Vanguard Dividend Appreciation ETF (VIG) mixes dividend growth with favorable sectors. Read more about VIG here.", + "url": "https://finnhub.io/api/news?id=1c161cf429298579cc1f69edda183ab767a637d65062d78afe362969dd6b2950", + "datetime": "2026-01-05", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Visa-Issued Crypto Card Spending Jumps 525% in 2025", + "content": "Spending through Visa-issued crypto cards surged in 2025, with total net transaction volume rising 525% over the year, signaling growing consumer use of crypto-linked payment products for everyday purchases. Key Takeaways: Visa-issued crypto card spending jumped 525% in 2025, pointing to rising everyday use of crypto-linked payments. EtherFi led all ...", + "url": "https://finnhub.io/api/news?id=67b60cdfe08396c77986e5ad68bdda901e8afc1b6eba64040de0fa584688c7ec", + "datetime": "2026-01-05", + "source": "Finnhub", + "subreddit": null + }, + { + "title": "Why is no one seeing the bigger picture?", + "content": ">\\*Disclaimer\\*\n\n>The information provided is for educational and informational purposes only and does not constitute financial, investment, legal, or tax advice. Economic conditions, markets, and regulations can change rapidly, and past performance is not indicative of future results. You should conduct your own research and consult with a qualified professional before making any financial decisions.\n\n\n\nOk First of all i'm not good with words im more good with math and numbers... so i'll ", + "url": "https://reddit.com/r/stocks/comments/1q3e6jr/why_is_no_one_seeing_the_bigger_picture/", + "datetime": "2026-01-04", + "source": "Reddit", + "subreddit": "r/stocks" + } + ], + "item_count": 58, + "sources_used": [ + "Finnhub", + "Reddit" + ], + "source": "sentiment-basket", + "as_of": "2026-01-11" + } + }, + "multi_source": { + "financials_all": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "sec_edgar": { + "source": "SEC EDGAR XBRL", + "data": { + "financials": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_income": { + "value": 20058000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_income": { + "value": 23994000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "operating_margin_pct": { + "value": 59.98, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_margin_pct": { + "value": 50.14, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "revenue_growth_3yr": { + "value": 14.34, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_assets": { + "value": 99627000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "total_liabilities": { + "value": 61718000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "stockholders_equity": { + "value": 26437000000, + "data_type": "FY", + "end_date": "2011-09-30", + "filed": "2011-11-18", + "fiscal_year": 2011, + "form": "10-K" + } + }, + "debt": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "long_term_debt": { + "value": 20977000000, + "data_type": "FY", + "end_date": "2021-09-30", + "filed": "2021-11-18", + "fiscal_year": 2021, + "form": "10-K" + }, + "total_debt": { + "value": 25171000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "cash": { + "value": 17164000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "net_debt": { + "value": 8007000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "debt_to_equity": { + "value": 0.95, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + }, + "cash_flow": { + "ticker": "V", + "source": "SEC EDGAR XBRL", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23059000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "capital_expenditure": { + "value": 59000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + }, + "free_cash_flow": { + "value": 23000000000, + "data_type": "FY", + "end_date": "2025-09-30", + "filed": "2025-11-06", + "fiscal_year": 2025, + "form": "10-K" + } + } + } + }, + "yahoo_finance": { + "source": "Yahoo Finance", + "data": { + "financials": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "revenue": { + "value": 40000000000, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_income": { + "value": 19853000704, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_profit": { + "value": 39105998848, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "gross_margin_pct": { + "value": 97.76, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_margin_pct": { + "value": 49.63, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "debt": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "total_debt": { + "value": 26083999744, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "cash": { + "value": 18997000192, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "net_debt": { + "value": 7086999552, + "data_type": "Point-in-time", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + }, + "cash_flow": { + "ticker": "V", + "source": "Yahoo Finance", + "as_of": "2026-01-11", + "operating_cash_flow": { + "value": 23058999296, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + }, + "free_cash_flow": { + "value": 20072873984, + "data_type": "TTM", + "end_date": "2025-09-30", + "filed": "2026-01-09" + } + } + } + } + }, + "source": "fundamentals-basket", + "as_of": "2026-01-11" + }, + "valuation_all": { + "group": "source_comparison", + "ticker": "V", + "sources": { + "yahoo_finance": { + "source": "Yahoo Finance", + "regular_market_time": "2026-01-09", + "data": { + "current_price": 349.77, + "market_cap": 675020144640.0, + "enterprise_value": 677386649600.0, + "trailing_pe": 34.25759, + "forward_pe": 24.254278, + "ps_ratio": 16.875504, + "pb_ratio": 18.046125, + "ev_ebitda": 24.168, + "trailing_peg": 1.9228, + "forward_peg": null, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + }, + "alpha_vantage": { + "source": "Alpha Vantage", + "latest_quarter": "2025-09-30", + "fetched_at": "2026-01-09", + "data": { + "current_price": 339.81, + "market_cap": 675020145000.0, + "trailing_pe": 34.26, + "forward_pe": 27.32, + "ps_ratio": 16.88, + "pb_ratio": 18.15, + "ev_ebitda": 26.2, + "trailing_peg": 1.923, + "earnings_growth": -0.014, + "revenue_growth": 0.115 + } + } + }, + "source": "valuation-basket", + "as_of": "2026-01-11" + }, + "macro_all": { + "group": "raw_metrics", + "ticker": "MACRO", + "metrics": { + "gdp_growth": { + "value": 4.3, + "data_type": "Quarterly", + "as_of": "2025Q3", + "source": "BEA (Bureau of Economic Analysis)", + "fallback": false + }, + "interest_rate": { + "value": 3.72, + "data_type": "Monthly", + "as_of": "2025-12-01", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "cpi_inflation": { + "value": 2.74, + "data_type": "Monthly", + "as_of": "2025-November", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + }, + "unemployment": { + "value": 4.4, + "data_type": "Monthly", + "as_of": "2025-December", + "source": "BLS (Bureau of Labor Statistics)", + "fallback": false + } + }, + "source": "macro-basket", + "as_of": "2026-01-11" + }, + "volatility_all": { + "group": "raw_metrics", + "ticker": "V", + "metrics": { + "vix": { + "value": 15.45, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "vxn": { + "value": 20.15, + "data_type": "Daily", + "as_of": "2026-01-08", + "source": "FRED (Federal Reserve)", + "fallback": false + }, + "beta": { + "value": 0.785, + "data_type": "1Y", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "historical_volatility": { + "value": 22.16, + "data_type": "30D", + "as_of": "2026-01-09", + "source": "Calculated from Yahoo Finance data", + "fallback": false + }, + "implied_volatility": { + "value": 30.0, + "data_type": "Forward", + "as_of": "2026-01-11", + "source": "Market Average (estimated)", + "fallback": true + } + }, + "source": "volatility-basket", + "as_of": "2026-01-11" + } + }, + "conflict_resolution": { + "financials": { + "primary_source": "SEC EDGAR XBRL", + "secondary_source": "Yahoo Finance", + "conflicts": [] + }, + "valuation": { + "primary_source": "Yahoo Finance", + "secondary_source": "Alpha Vantage", + "conflicts": [] + } + }, + "aggregated_swot": { + "strengths": [], + "weaknesses": [], + "opportunities": [], + "threats": [] + }, + "completeness": { + "completeness_pct": 35.7, + "metrics_found": 5, + "metrics_total": 14, + "missing": { + "financials": [ + "revenue", + "net_income", + "eps", + "debt_to_equity" + ], + "valuation": [ + "trailing_pe", + "pb_ratio", + "ps_ratio" + ], + "news": [ + "results" + ], + "sentiment": [ + "composite_score" + ] + } + }, + "generated_at": "2026-01-11T13:17:36.583056" +} \ No newline at end of file diff --git a/docs/mcp_test_report_BAC.md b/docs/mcp_test_report_BAC.md new file mode 100644 index 0000000000000000000000000000000000000000..19cdbef8028e340bb99048d6b2298f761b5e8bb0 --- /dev/null +++ b/docs/mcp_test_report_BAC.md @@ -0,0 +1,69 @@ +# MCP E2E Test Report: Bank of America Corporation (BAC) + +## Summary + +| S/N | MCP | Status | Expected | Actual | Duration | Errors | Warnings | +|-----|-----|--------|----------|--------|----------|--------|----------| +| 1 | fundamentals | PASS | 9 | 9 | 10215ms | - | - | +| 2 | valuation | PASS | 11 | 11 | 902ms | - | - | +| 3 | volatility | PASS | 5 | 5 | 1873ms | - | - | +| 4 | macro | PASS | 4 | 4 | 4818ms | - | - | +| 5 | news | PASS | - | 4 | 1542ms | - | - | +| 6 | sentiment | PASS | - | 51 | 1826ms | - | - | + +--- + +## Quantitative Data + +| S/N | Metric | Value | Data Type | As Of | Source | Category | +|-----|--------|-------|-----------|-------|--------|----------| +| 1 | revenue | 101887000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 2 | net_income | 27132000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 3 | net_margin_pct | 26.63 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 4 | total_assets | 3261519000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 5 | total_liabilities | 2965960000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 6 | stockholders_equity | 295559000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 7 | operating_margin_pct | 35.29 | TTM | 2026-01-11 | Yahoo Finance | Fundamentals | +| 8 | total_debt | 763981987840 | TTM | 2026-01-11 | Yahoo Finance | Fundamentals | +| 9 | operating_cash_flow | 61471997952 | TTM | 2026-01-11 | Yahoo Finance | Fundamentals | +| 10 | current_price | 55.85 | - | 2026-01-11 | yahoo_finance | Valuation | +| 11 | market_cap | 422231572480.0 | - | 2026-01-11 | yahoo_finance | Valuation | +| 12 | enterprise_value | 422701367296.0 | - | 2026-01-11 | yahoo_finance | Valuation | +| 13 | trailing_pe | 15.2595625 | - | 2026-01-11 | yahoo_finance | Valuation | +| 14 | forward_pe | 12.838106 | - | 2026-01-11 | yahoo_finance | Valuation | +| 15 | ps_ratio | 4.1621723 | - | 2026-01-11 | yahoo_finance | Valuation | +| 16 | pb_ratio | 1.4716344 | - | 2026-01-11 | yahoo_finance | Valuation | +| 17 | trailing_peg | 1.0583 | - | 2026-01-11 | yahoo_finance | Valuation | +| 18 | forward_peg | 0.4075589206349206 | - | 2026-01-11 | yahoo_finance | Valuation | +| 19 | earnings_growth | 0.315 | - | 2026-01-11 | yahoo_finance | Valuation | +| 20 | revenue_growth | 0.126 | - | 2026-01-11 | yahoo_finance | Valuation | +| 21 | vix | 15.45 | Daily | 2026-01-08 | FRED (Federal Reserve) | Volatility | +| 22 | vxn | 20.15 | Daily | 2026-01-08 | FRED (Federal Reserve) | Volatility | +| 23 | beta | 1.007 | 1Y | 2026-01-09 | Calculated from Yahoo Finance data | Volatility | +| 24 | historical_volatility | 16.93 | 30D | 2026-01-09 | Calculated from Yahoo Finance data | Volatility | +| 25 | implied_volatility | 30.0 | Forward | 2026-01-11 | Market Average (estimated) | Volatility | +| 26 | gdp_growth | 4.3 | Quarterly | 2025Q3 | BEA (Bureau of Economic Analysis) | Macro | +| 27 | interest_rate | 3.72 | Monthly | 2025-12-01 | FRED (Federal Reserve) | Macro | +| 28 | cpi_inflation | 2.74 | Monthly | 2025-November | BLS (Bureau of Labor Statistics) | Macro | +| 29 | unemployment | 4.4 | Monthly | 2025-December | BLS (Bureau of Labor Statistics) | Macro | + +--- + +## Qualitative Data + +| S/N | Title | Date | Source | Subreddit | URL | Category | +|-----|-------|------|--------|-----------|-----|----------| +| 1 | Bank of America Corporation (BAC) Latest Press Releases ... | - | Tavily | - | [Link](https://finance.yahoo.com/quote/BAC/press-releases/) | News | +| 2 | BAC: Bank of America Corp - Stock Price, Quote and News | - | Tavily | - | [Link](https://www.cnbc.com/quotes/BAC) | News | +| 3 | BAC: Bank of America Corp - Stock Price, Quote and News | - | Tavily | - | [Link](https://www.cnbc.com/quotes/%20BAC) | News | +| 4 | Page 75 | Bank of America Corporation (BAC) Latest Stock News | - | Tavily | - | [Link](https://seekingalpha.com/symbol/BAC/news?page=75) | News | +| 5 | Wall Street Week Ahead | 2026-01-11 | Finnhub | - | [Link](https://finnhub.io/api/news?id=b22a32ad6036940dc56e0844256e89500603d818f63c8ba5a719d3f195f3951c) | Sentiment | +| 6 | Earnings Season To Kick Off As Banking Heavyweights Report, Inflation Data In Fo | 2026-01-10 | Finnhub | - | [Link](https://finnhub.io/api/news?id=df06e522851fab7e7e52a52c2772429b8519081ab650a97a5d595e492d60f9ab) | Sentiment | +| 7 | Visible Alpha Breakdown Of U.S. Banks' Fourth Quarter Earnings Expectations | 2026-01-10 | Finnhub | - | [Link](https://finnhub.io/api/news?id=8d45119c0787924e04b3d56b4f0d2d9fbc398ab60154496334914d4b77e78151) | Sentiment | +| 8 | Bank earnings, CPI inflation data, Fed comments: What to Watch | 2026-01-10 | Finnhub | - | [Link](https://finnhub.io/api/news?id=cd815b49c50a31ef2697ad510a56a230f483ed2ea86dbaf3af13ef99447303e0) | Sentiment | +| 9 | Stock Market Today, Jan. 9: NuScale Power Jumps After Bank of America Upgrade | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=ae754c1d8897be1cb2b593bf80c23cb7eac143b0e258bf78e79decd2d03cef2c) | Sentiment | +| 10 | Options: A look into the financial sector ahead of bank earnings | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=0d0046e5210ad5e8d8735c87c0e10035c1cae179e90bfa25be379d35288965fb) | Sentiment | +| 11 | Bank of America Announces Redemption of $3,000,000,000 5.080% Fixed/Floating Rat | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=0ae27a3122896c2a97b30731b6801478fe3f164afda93b40c21af24e3503857c) | Sentiment | +| 12 | Why The Narrative Around West Pharmaceutical Services (WST) Is Shifting On 2026 | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=42a8ba501a8f44026b9193eb01272e0da59d9639e79ed8e902d0ef045e872283) | Sentiment | +| 13 | Is It Too Late To Consider Bank Of America (BAC) After A 24% One Year Gain? | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=ec4ed6055fee024455757a96ae11683a3d136cad46d86f8ebbb941508666c804) | Sentiment | +| 14 | Jim Cramer says don’t trade Apple and Nvidia as money rotates into overlooked st | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=3a0779d04224c658a3decaf2dea43f2c985a332b942b20b8923c9394e4d942b2) | Sentiment | diff --git a/docs/mcp_test_report_KO.md b/docs/mcp_test_report_KO.md new file mode 100644 index 0000000000000000000000000000000000000000..c8711af40eff0916ce18529c465f3a8e4593e68f --- /dev/null +++ b/docs/mcp_test_report_KO.md @@ -0,0 +1,69 @@ +# MCP E2E Test Report: The Coca-Cola Company (KO) + +## Summary + +| S/N | MCP | Status | Expected | Actual | Duration | Errors | Warnings | +|-----|-----|--------|----------|--------|----------|--------|----------| +| 1 | fundamentals | PASS | 9 | 9 | 15284ms | - | - | +| 2 | valuation | PASS | 11 | 11 | 2224ms | - | - | +| 3 | volatility | PASS | 5 | 5 | 2277ms | - | - | +| 4 | macro | PASS | 4 | 4 | 7671ms | - | - | +| 5 | news | PASS | - | 4 | 3370ms | - | - | +| 6 | sentiment | PASS | - | 50 | 1736ms | - | - | + +--- + +## Quantitative Data + +| S/N | Metric | Value | Data Type | As Of | Source | Category | +|-----|--------|-------|-----------|-------|--------|----------| +| 1 | revenue | 47061000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 2 | net_income | 10631000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 3 | net_margin_pct | 22.59 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 4 | total_assets | 100549000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 5 | stockholders_equity | 24856000000 | FY | 2024-12-31 | SEC EDGAR | Fundamentals | +| 6 | operating_margin_pct | 32.37 | TTM | 2026-01-11 | Yahoo Finance | Fundamentals | +| 7 | total_debt | 48161001472 | TTM | 2026-01-11 | Yahoo Finance | Fundamentals | +| 8 | operating_cash_flow | 7602999808 | TTM | 2026-01-11 | Yahoo Finance | Fundamentals | +| 9 | free_cash_flow | 1412875008 | TTM | 2026-01-11 | Yahoo Finance | Fundamentals | +| 10 | current_price | 70.51 | - | 2026-01-11 | yahoo_finance | Valuation | +| 11 | market_cap | 303451570176.0 | - | 2026-01-11 | yahoo_finance | Valuation | +| 12 | enterprise_value | 337706450944.0 | - | 2026-01-11 | yahoo_finance | Valuation | +| 13 | trailing_pe | 23.347683 | - | 2026-01-11 | yahoo_finance | Valuation | +| 14 | forward_pe | 21.887728 | - | 2026-01-11 | yahoo_finance | Valuation | +| 15 | ps_ratio | 6.366606 | - | 2026-01-11 | yahoo_finance | Valuation | +| 16 | pb_ratio | 9.70811 | - | 2026-01-11 | yahoo_finance | Valuation | +| 17 | trailing_peg | 2.1982 | - | 2026-01-11 | yahoo_finance | Valuation | +| 18 | forward_peg | 0.7271670431893688 | - | 2026-01-11 | yahoo_finance | Valuation | +| 19 | earnings_growth | 0.301 | - | 2026-01-11 | yahoo_finance | Valuation | +| 20 | revenue_growth | 0.051 | - | 2026-01-11 | yahoo_finance | Valuation | +| 21 | vix | 15.45 | Daily | 2026-01-08 | FRED (Federal Reserve) | Volatility | +| 22 | vxn | 20.15 | Daily | 2026-01-08 | FRED (Federal Reserve) | Volatility | +| 23 | beta | 0.042 | 1Y | 2026-01-09 | Calculated from Yahoo Finance data | Volatility | +| 24 | historical_volatility | 16.41 | 30D | 2026-01-09 | Calculated from Yahoo Finance data | Volatility | +| 25 | implied_volatility | 30.0 | Forward | 2026-01-11 | Market Average (estimated) | Volatility | +| 26 | gdp_growth | 4.3 | Quarterly | 2025Q3 | BEA (Bureau of Economic Analysis) | Macro | +| 27 | interest_rate | 3.72 | Monthly | 2025-12-01 | FRED (Federal Reserve) | Macro | +| 28 | cpi_inflation | 2.74 | Monthly | 2025-November | BLS (Bureau of Labor Statistics) | Macro | +| 29 | unemployment | 4.4 | Monthly | 2025-December | BLS (Bureau of Labor Statistics) | Macro | + +--- + +## Qualitative Data + +| S/N | Title | Date | Source | Subreddit | URL | Category | +|-----|-------|------|--------|-----------|-----|----------| +| 1 | The Coca-Cola Company (KO) Latest Stock News & ... | - | Tavily | - | [Link](https://finance.yahoo.com/quote/KO/news/) | News | +| 2 | The Coca-Cola Company (KO) Stock Price, News, Quote & History | - | Tavily | - | [Link](https://ca.finance.yahoo.com/quote/KO/latest-news/) | News | +| 3 | The Coca-Cola Company (KO) Latest Press Releases & ... | - | Tavily | - | [Link](https://ca.finance.yahoo.com/quote/KO/press-releases/) | News | +| 4 | KO The Coca-Cola Company Stock Price & Overview | - | Tavily | - | [Link](https://seekingalpha.com/symbol/KO) | News | +| 5 | January Dogs Of The Dow: One Ideal 'Safer' Dividend Buy | 2026-01-10 | Finnhub | - | [Link](https://finnhub.io/api/news?id=1660b2a2ba8c3d9e6bd7a573907b58ceded4a1a0c4d63c9ba3a114cc975bf4d3) | Sentiment | +| 6 | Coca-Cola Consolidated, Inc. Announces First Quarter Dividend | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=7c03bea3f2fd3da7e1123de8ae152fed743c7045fbd27a719f6ec895fb4af5fe) | Sentiment | +| 7 | The Best Warren Buffett Stocks to Buy With $2,500 Right Now | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=61ca0e60be9146fb269b7e554fb1aa61cba1c75836b1e4648e42c9575017e819) | Sentiment | +| 8 | PepsiCo's Stock Valuation Looks Attractive: Buy or Wait for Now? | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=01073ece29404b65546702142a72ce42e29b47dfda613081c5a9f2c4e25a510c) | Sentiment | +| 9 | The Best Stocks to Buy With $1,000 Right Now | 2026-01-09 | Finnhub | - | [Link](https://finnhub.io/api/news?id=140f1634015c1d3869855516b7d0df434fa67f215dec82d0d059558f7604c256) | Sentiment | +| 10 | 5 Under-the-Radar Consumer Staples Stocks With Pricing Power | 2026-01-08 | Finnhub | - | [Link](https://finnhub.io/api/news?id=9e436f5e94ba7056919dec63710b46705b14eb7e5386c37f11d889e7e290b31b) | Sentiment | +| 11 | Wells Fargo Adds Coca-Cola (KO) to Q1 2026 Tactical Ideas List | 2026-01-08 | Finnhub | - | [Link](https://finnhub.io/api/news?id=909e7eb1a346dc9067b8ee2498b8a2f55b12324ff41187b8611e951d40671984) | Sentiment | +| 12 | The 3 Best Dividend Aristocrats to Buy for 2026 | 2026-01-08 | Finnhub | - | [Link](https://finnhub.io/api/news?id=d385258e91b24623ae1bf88aec7116e8cf0b0fb695ac0755ed1e3172f629f3d6) | Sentiment | +| 13 | Betting Markets Say Supreme Court Will Scrap Trump Tariffs. How to Play It. | 2026-01-08 | Finnhub | - | [Link](https://finnhub.io/api/news?id=6e0fc5222e594ab3889889bab0e6ffd74edf31d51dce852ef5242d2e5bb21f59) | Sentiment | +| 14 | The Dividend Aristocrats No One’s Talking About (And Their 30+ Year Track Record | 2026-01-08 | Finnhub | - | [Link](https://finnhub.io/api/news?id=2ecc1591039f3c4ed06c25fc03db644f3a68a8f82c1d14447587d7c340c7532d) | Sentiment | diff --git a/docs/metrics_schema_emitted.md b/docs/metrics_schema_emitted.md new file mode 100644 index 0000000000000000000000000000000000000000..dcbed5b7d25716fdf839054727b11302605a9576 --- /dev/null +++ b/docs/metrics_schema_emitted.md @@ -0,0 +1,236 @@ +## MCP Output Schema - Emitted Format + +JSON/dict structure returned by each `get_all_sources_*()` function. + +--- + +## 1. `get_all_sources_fundamentals(ticker)` + +``` +{ + "ticker": str, + "sec_edgar": { + "source": str, + "as_of": str, + "data": { + "revenue": {"value": float, "end_date": str, "fiscal_year": int, "form": str} | null, + "net_income": {"value": float, "end_date": str, "fiscal_year": int, "form": str} | null, + "net_margin_pct": {"value": float, "end_date": str, "fiscal_year": int, "form": str} | null, + "total_assets": {"value": float, "end_date": str, "fiscal_year": int, "form": str} | null, + "total_liabilities": {"value": float, "end_date": str, "fiscal_year": int, "form": str} | null, + "stockholders_equity": {"value": float, "end_date": str, "fiscal_year": int, "form": str} | null + } + }, + "yahoo_finance": { + "source": str, + "as_of": str, + "data": { + "operating_margin_pct": {"value": float} | null, + "total_debt": {"value": float} | null, + "operating_cash_flow": {"value": float} | null, + "free_cash_flow": {"value": float} | null + } + }, + "generated_at": str +} +``` + +**Notes:** +- SEC EDGAR: 6 universal metrics (FY data with temporal fields) +- Yahoo Finance: 4 supplementary metrics (TTM data) +- If SEC fails, Yahoo provides fallback core metrics (revenue, net_income, net_margin_pct, total_assets) + +--- + +## 2. `get_all_sources_valuation(ticker)` + +``` +{ + "group": "source_comparison", + "ticker": str, + "sources": { + "yahoo_finance": { + "source": str, + "regular_market_time": str, + "data": { + "current_price": float | null, + "market_cap": float | null, + "enterprise_value": float | null, + "trailing_pe": float | null, + "forward_pe": float | null, + "ps_ratio": float | null, + "pb_ratio": float | null, + "trailing_peg": float | null, + "forward_peg": float | null, + "earnings_growth": float | null, + "revenue_growth": float | null + } + } + }, + "source": str, + "as_of": str +} +``` + +**Notes:** +- 11 universal metrics (excludes ev_ebitda - banks don't report EBITDA) +- If Yahoo fails, `alpha_vantage` key replaces `yahoo_finance` + +--- + +## 3. `get_all_sources_volatility(ticker)` + +``` +{ + "group": "raw_metrics", + "ticker": str, + "metrics": { + "vix": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + }, + "vxn": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + }, + "beta": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + }, + "historical_volatility": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + }, + "implied_volatility": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + } + }, + "source": str, + "as_of": str +} +``` + +**Notes:** +- `data_type` values: "Daily" (VIX/VXN), "1Y" (beta), "30D" (historical_vol), "Forward" (implied_vol) + +--- + +## 4. `get_all_sources_macro()` + +``` +{ + "group": "raw_metrics", + "ticker": "MACRO", + "metrics": { + "gdp_growth": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + }, + "interest_rate": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + }, + "cpi_inflation": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + }, + "unemployment": { + "value": float | null, + "data_type": str, + "as_of": str, + "source": str, + "fallback": bool + } + }, + "source": str, + "as_of": str +} +``` + +**Notes:** +- `data_type` values: "Quarterly" (GDP), "Monthly" (interest_rate, cpi, unemployment) +- `as_of` format varies: "2025Q3" (GDP), "2025-01" (monthly) + +--- + +## 5. `get_all_sources_news(ticker, company_name)` + +``` +{ + "group": "content_analysis", + "ticker": str, + "query": str, + "items": [ + { + "title": str | null, + "content": str | null, + "url": str | null, + "datetime": str | null, + "source": str + } + ], + "item_count": int, + "sources_used": [str], + "source": str, + "as_of": str +} +``` + +**Notes:** +- `sources_used`: ["Tavily", "NYT", "NewsAPI"] +- `datetime`: YYYY-MM-DD format + +--- + +## 6. `get_all_sources_sentiment(ticker, company_name)` + +``` +{ + "group": "content_analysis", + "ticker": str, + "items": [ + { + "title": str | null, + "content": str | null, + "url": str | null, + "datetime": str | null, + "source": str, + "subreddit": str | null + } + ], + "item_count": int, + "sources_used": [str], + "source": str, + "as_of": str +} +``` + +**Notes:** +- `sources_used`: ["Finnhub", "Reddit"] +- `subreddit`: Only populated for Reddit items (e.g., "r/wallstreetbets") +- `datetime`: YYYY-MM-DD format diff --git a/docs/metrics_schema_human_readable.md b/docs/metrics_schema_human_readable.md new file mode 100644 index 0000000000000000000000000000000000000000..7797a8d3491fb8f837e11ee9e75402cc46848ffe --- /dev/null +++ b/docs/metrics_schema_human_readable.md @@ -0,0 +1,180 @@ +## Overview + +Standardized output schema for financial data across ALL industries (banks, consumer goods, tech, etc.) + +--- + +## Quantitative Metrics + +### 1. Fundamentals (9 metrics) + +| Source | Metric | Description | Universal? | +|--------|--------|-------------|:----------:| +| **SEC EDGAR** | `revenue` | Total revenue (FY) | ✓ | +| **SEC EDGAR** | `net_income` | Net income (FY) | ✓ | +| **SEC EDGAR** | `net_margin_pct` | Net Income / Revenue % | ✓ | +| **SEC EDGAR** | `total_assets` | Total assets | ✓ | +| **SEC EDGAR** | `total_liabilities` | Total liabilities | ✓ | +| **SEC EDGAR** | `stockholders_equity` | Shareholders' equity | ✓ | +| **Yahoo Finance** | `operating_margin_pct` | Operating margin % (TTM) | Supplementary | +| **Yahoo Finance** | `total_debt` | Total debt (TTM) | Supplementary | +| **Yahoo Finance** | `operating_cash_flow` | Operating cash flow (TTM) | Supplementary | +| **Yahoo Finance** | `free_cash_flow` | Free cash flow (TTM) | Supplementary | + +**Sources:** +``` +┌────┬───────────────┬───────────┬───────────────────────────────────────┐ +│ # │ Source │ Data Type │ Notes │ +├────┼───────────────┼───────────┼───────────────────────────────────────┤ +│ 1 │ SEC EDGAR │ FY (10-K) │ Primary - official filings │ +│ 2 │ Yahoo Finance │ TTM │ Supplementary + Fallback if SEC fails │ +└────┴───────────────┴───────────┴───────────────────────────────────────┘ +``` + +--- + +### 2. Valuation (11 metrics) + +| Source | Metric | Description | +|--------|--------|-------------| +| **Yahoo Finance** | `current_price` | Current stock price | +| **Yahoo Finance** | `market_cap` | Market capitalization | +| **Yahoo Finance** | `enterprise_value` | Enterprise value | +| **Yahoo Finance** | `trailing_pe` | Trailing P/E ratio | +| **Yahoo Finance** | `forward_pe` | Forward P/E ratio | +| **Yahoo Finance** | `ps_ratio` | Price-to-Sales ratio | +| **Yahoo Finance** | `pb_ratio` | Price-to-Book ratio | +| **Yahoo Finance** | `trailing_peg` | Trailing PEG ratio | +| **Yahoo Finance** | `forward_peg` | Forward PEG ratio | +| **Yahoo Finance** | `earnings_growth` | Earnings growth rate | +| **Yahoo Finance** | `revenue_growth` | Revenue growth rate | + +**Sources:** +``` +┌────┬───────────────┬─────────────────────────────────┐ +│ # │ Source │ Notes │ +├────┼───────────────┼─────────────────────────────────┤ +│ 1 │ Yahoo Finance │ Primary - real-time quotes │ +│ 2 │ Alpha Vantage │ Fallback if Yahoo Finance fails │ +└────┴───────────────┴─────────────────────────────────┘ +``` +**Excluded:** `ev_ebitda` (banks don't report EBITDA) + +--- + +### 3. Volatility (5 metrics) + +| Source | Metric | Description | +|--------|--------|-------------| +| **FRED** | `vix` | CBOE Volatility Index (S&P 500) | +| **FRED** | `vxn` | CBOE NASDAQ Volatility Index | +| **Calculated** | `beta` | 1-year beta vs S&P 500 | +| **Calculated** | `historical_volatility` | 30-day historical volatility | +| **Estimated** | `implied_volatility` | Forward implied volatility | + +**Sources:** +``` +┌─────────────────────┬─────────────────┬───────────────────────────┐ +│ Metric │ Primary │ Fallback │ +├─────────────────────┼─────────────────┼───────────────────────────┤ +│ vix, vxn │ FRED │ - │ +│ beta │ Yahoo Finance │ Alpha Vantage │ +│ historical_vol │ Yahoo Finance │ Alpha Vantage │ +│ implied_vol │ Options Chain │ Market average (30%) │ +└─────────────────────┴─────────────────┴───────────────────────────┘ +``` + +--- + +### 4. Macro (4 metrics) + +| Source | Metric | Description | +|--------|--------|-------------| +| **BEA** | `gdp_growth` | GDP growth rate (quarterly) | +| **FRED** | `interest_rate` | Federal funds rate | +| **BLS** | `cpi_inflation` | CPI inflation rate | +| **BLS** | `unemployment` | Unemployment rate | + +**Sources:** +``` +┌─────────────────────┬─────────────────┬───────────────────────────┐ +│ Metric │ Primary │ Fallback │ +├─────────────────────┼─────────────────┼───────────────────────────┤ +│ gdp_growth │ BEA │ FRED │ +│ interest_rate │ FRED │ - │ +│ cpi_inflation │ BLS │ FRED │ +│ unemployment │ BLS │ FRED │ +└─────────────────────┴─────────────────┴───────────────────────────┘ +``` + +--- + +## Qualitative Data + +### 5. News (variable count) + +| Field | Description | +|-------|-------------| +| `title` | Article headline | +| `date` | Publication date | +| `source` | News source (Tavily, NYT, NewsAPI) | +| `url` | Link to article | + +**Sources (parallel, equally weighted):** +``` +┌─────────┬─────────────────────────────┐ +│ Source │ Notes │ +├─────────┼─────────────────────────────┤ +│ Tavily │ AI-powered search │ +│ NewsAPI │ Financial news aggregator │ +│ NYT │ New York Times API │ +└─────────┴─────────────────────────────┘ +``` + +--- + +### 6. Sentiment (variable count) + +| Field | Description | +|-------|-------------| +| `title` | Post/article headline | +| `date` | Publication date | +| `source` | Source (Finnhub, Reddit) | +| `subreddit` | Reddit subreddit (if applicable) | +| `url` | Link to source | + +**Sources (parallel, equally weighted):** +``` +┌─────────┬───────────────────────────────────┐ +│ Source │ Notes │ +├─────────┼───────────────────────────────────┤ +│ Finnhub │ Company news & sentiment │ +│ Reddit │ r/wallstreetbets, r/stocks │ +└─────────┴───────────────────────────────────┘ +``` + +--- + +## Expected Counts + +| MCP | Expected | Rationale | +|-----|:--------:|-----------| +| fundamentals | 9 | SEC (6) + Yahoo (4 supplementary, ~1 null) | +| valuation | 11 | Yahoo Finance only, universal metrics | +| volatility | 5 | All universal | +| macro | 4 | All universal | +| news | - | Variable (depends on news cycle) | +| sentiment | - | Variable (depends on activity) | + +--- + +## Files Implementing This Schema + +| File | Environment | Purpose | +| ---------------------------------------------------------- | ----------- | ----------------------------------- | +| `mcp-servers/fundamentals-basket/server_legacy.py` | Test | Direct import for E2E tests | +| `mcp-servers/fundamentals-basket/services/orchestrator.py` | Production | Via http_server.py → server.py | +| `mcp-servers/valuation-basket/server.py` | Both | get_all_sources_valuation() | +| `mcp-servers/volatility-basket/server.py` | Both | get_all_sources_volatility() | +| `mcp-servers/macro-basket/server.py` | Both | get_all_sources_macro() | +| `tests/test_mcp_e2e.py` | Test | E2E validation with expected counts | diff --git a/docs/newsapi_data_schema.md b/docs/newsapi_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..9d5f032fc654b85a83265aeb6972d8277914f8b8 --- /dev/null +++ b/docs/newsapi_data_schema.md @@ -0,0 +1,41 @@ +NewsAPI Data Schema +=================== + +Endpoint: https://newsapi.org/v2/everything +Method: GET + +Note: Free tier has 24-hour delay on articles + + +Request Parameters + +| field | type | description | +|----------|--------|------------------------------------------| +| apiKey | string | API key | +| q | string | Search query | +| sortBy | string | "publishedAt", "relevancy", "popularity" | +| language | string | Language code (e.g., "en") | +| pageSize | int | Results per page (max 100) | + + +Response (articles[]) + +| field | type | description | +|-------------|--------|-----------------------------| +| title | string | Article title | +| url | string | Article URL | +| description | string | Article description | +| content | string | Article content (truncated) | +| publishedAt | string | ISO date | +| source.name | string | Publisher name | + + +Example Result + +| field | value | +|-------------|----------------------------------------| +| title | "Apple Announces New Product Line" | +| url | "https://techcrunch.com/apple-new..." | +| description | "Apple unveiled its latest products..."| +| publishedAt | "2025-01-08T10:15:00Z" | +| source.name | "TechCrunch" | diff --git a/docs/nyt_data_schema.md b/docs/nyt_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..e921a93041ed66529027995405b9ac2b7b640018 --- /dev/null +++ b/docs/nyt_data_schema.md @@ -0,0 +1,40 @@ +NYT Article Search Data Schema +============================== + +Endpoint: https://api.nytimes.com/svc/search/v2/articlesearch.json +Method: GET + + +Request Parameters + +| field | type | description | +|------------|--------|---------------------------------| +| api-key | string | API key | +| q | string | Search query | +| sort | string | "newest", "oldest", "relevance" | +| begin_date | string | YYYYMMDD format | +| end_date | string | YYYYMMDD format | +| page | int | Pagination (0-indexed) | + + +Response (response.docs[]) + +| field | type | description | +|----------------|--------|------------------| +| headline.main | string | Article headline | +| web_url | string | Article URL | +| snippet | string | Article snippet | +| lead_paragraph | string | First paragraph | +| pub_date | string | ISO date | +| section_name | string | NYT section | + + +Example Result + +| field | value | +|----------------|----------------------------------------| +| headline.main | "Apple Stock Surges on Earnings" | +| web_url | "https://nytimes.com/2025/01/apple..." | +| snippet | "Apple shares climbed on strong..." | +| pub_date | "2025-01-09T15:30:00Z" | +| section_name | "Business" | diff --git a/docs/reddit_data_schema.md b/docs/reddit_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..ebcf47159f28859c4f09fe71c33d8044d9b9583b --- /dev/null +++ b/docs/reddit_data_schema.md @@ -0,0 +1,39 @@ +Reddit Data Schema +================== + +Endpoint: https://www.reddit.com/r/{subreddit}/search.json +Method: GET +Subreddits: wallstreetbets, stocks + + +Request Parameters + +| field | type | description | +| ----------- | ------ | ---------------------------- | +| q | string | Search query (ticker) | +| sort | string | "relevance", "new", etc. | +| t | string | Time filter ("week") | +| limit | int | Max results | +| restrict_sr | string | "true" to limit to subreddit | + + +Response (data.children[].data) + +| field | type | description | +|-------------|--------|------------------| +| title | string | Post title | +| selftext | string | Post body text | +| ups | int | Upvote count | +| permalink | string | Reddit permalink | +| created_utc | int | Unix timestamp | + + +Example Result + +| field | value | +|-------------|-------------------------------------------| +| title | "AAPL earnings crush - bullish long term" | +| selftext | "Just saw the Q4 numbers and..." | +| ups | 2450 | +| permalink | "/r/stocks/comments/abc123/..." | +| created_utc | 1736351400 | diff --git a/docs/schema_normalization.md b/docs/schema_normalization.md new file mode 100644 index 0000000000000000000000000000000000000000..28737bf4b95c925535f9c2978ffa52ef17643fb8 --- /dev/null +++ b/docs/schema_normalization.md @@ -0,0 +1,54 @@ +## Problem + +| MCP | Emits | Analyzer Expects | +|-----|-------|------------------| +| volatility | `{"metrics": {"vix": ...}}` | `{"yahoo_finance": {"data": {...}}}` | +| macro | `{"metrics": {"gdp_growth": ...}}` | `{"bea_bls": {"data": {...}}}` | + +## Solution + +| Step | Component | Action | +|:----:|-----------|--------| +| 1 | MCP servers | Emit raw schemas | +| 2 | **mcp_client.py** | `_normalize_*()` adapters | +| 3 | A2A | Pass normalized data | +| 4 | Analyzer | Consume consistent schema | + +## Why Source-Centric (not MCP-Centric) + +| Human View (6 MCPs) | | Processing View (by source) | | +|---------------------|---|-----------------------------|----| +| fundamentals | → | SEC EDGAR | primary | +| valuation | → | Yahoo Finance | fallback | +| volatility | → | Alpha Vantage | fallback | +| macro | → | FRED / BEA / BLS | | +| news | → | Tavily / NYT / NewsAPI | | +| sentiment | → | Finnhub / Reddit | | + +**MCPs** = fetch boundaries · **Sources** = conflict resolution + +### Example: Fundamentals MCP + +| Human View | | Processing View | | +|------------|---|-----------------|---| +| **fundamentals** | | **SEC EDGAR** | primary | +| - revenue | → | - revenue | | +| - net_income | | - net_income | | +| - margins | | | | +| | | **Yahoo Finance** | fallback | +| | | - operating_cf | | + +## Target Schema (Source-Keyed) + +```python +{ + "sources": { + "sec_edgar": {"revenue": {...}, "net_income": {...}}, + "yahoo_finance": {"beta": {...}, "trailing_pe": {...}}, + "fred": {"vix": {...}, "interest_rate": {...}}, + "bea_bls": {"gdp_growth": {...}, "unemployment": {...}}, + "tavily": {"items": [...]}, + "finnhub": {"items": [...]}, + } +} +``` diff --git a/docs/tavily_data_schema.md b/docs/tavily_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..fd44f89f4d48db4920b18daf778d2879b42c5132 --- /dev/null +++ b/docs/tavily_data_schema.md @@ -0,0 +1,40 @@ +Tavily Data Schema +================== + +Endpoint: https://api.tavily.com/search +Method: POST + + +Request Parameters + +| field | type | description | +|-----------------|--------|-----------------------------| +| api_key | string | API key | +| query | string | Search query | +| search_depth | string | "basic" or "advanced" | +| max_results | int | 1-10 results | +| include_answer | bool | Include AI-generated answer | +| include_domains | array | Limit to specific domains | +| exclude_domains | array | Exclude specific domains | + + +Response (results[]) + +| field | type | description | +|----------------|--------|-------------------------| +| title | string | Article title | +| url | string | Article URL | +| content | string | Article snippet/content | +| score | float | Relevance score (0-1) | +| published_date | string | Publication date | + + +Example Result + +| field | value | +|----------------|------------------------------------------| +| title | "Apple Q4 Earnings Beat Expectations" | +| url | "https://example.com/apple-earnings" | +| content | "Apple reported revenue of $119.6B..." | +| score | 0.89 | +| published_date | "2025-01-09" | diff --git a/docs/vader_data_schema.md b/docs/vader_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..32f89b697bc3bf899557959fc9407b2b3679ff52 --- /dev/null +++ b/docs/vader_data_schema.md @@ -0,0 +1,39 @@ +VADER Sentiment Data Schema +=========================== + +Library: vaderSentiment.vaderSentiment.SentimentIntensityAnalyzer +Method: polarity_scores(text) + +VADER (Valence Aware Dictionary and sEntiment Reasoner) is a lexicon and +rule-based sentiment analysis tool specifically attuned to social media. + + +Input + +| field | type | description | +|-------|--------|-----------------| +| text | string | Text to analyze | + + +Output (polarity_scores) + +| field | type | range | description | +|----------|-------|-------------|--------------------------| +| neg | float | 0.0 - 1.0 | Negative sentiment ratio | +| neu | float | 0.0 - 1.0 | Neutral sentiment ratio | +| pos | float | 0.0 - 1.0 | Positive sentiment ratio | +| compound | float | -1.0 - +1.0 | Normalized composite | + +Note: neg + neu + pos = 1.0 + + +Example + +Input: "Apple reports record earnings, stock surges on strong iPhone sales" + +| field | value | +|----------|-------| +| neg | 0.0 | +| neu | 0.594 | +| pos | 0.406 | +| compound | 0.765 | diff --git a/docs/valuation_data_schema.md b/docs/valuation_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..0beeedcd732da1b02b68df2c2adbe9ba4f6ab51f --- /dev/null +++ b/docs/valuation_data_schema.md @@ -0,0 +1,31 @@ +Valuation Data Schema +===================== + +Single source of truth for valuation-basket MCP server output. +Example: AAPL (Apple Inc) + +| S/N | metric | value | temporal info | source | +|-----|-----------------------|------------|-----------------|---------------| +| | **Price & Size** | | | | +| 1 | Current Price | $259.02 | Market Time | Yahoo Finance | +| 2 | Market Cap | $3.83T | Market Time | Yahoo Finance | +| 3 | Enterprise Value | $3.89T | Market Time | Yahoo Finance | +| | **Earnings Multiples**| | | | +| 4 | Trailing P/E | 34.72x | Market Time | Yahoo Finance | +| 5 | Forward P/E | 28.34x | Market Time | Yahoo Finance | +| 6 | Trailing PEG | 2.64 | Market Time | Yahoo Finance | +| 7 | Forward PEG | 2.28 | Market Time | Yahoo Finance | +| | **Revenue Multiples** | | | | +| 8 | P/S Ratio | 9.24x | Market Time | Yahoo Finance | +| 9 | EV/Revenue | 9.35x | Market Time | Yahoo Finance | +| 10 | EV/EBITDA | 26.88x | Market Time | Yahoo Finance | +| | **Asset Multiples** | | | | +| 11 | P/B Ratio | 52.17x | Market Time | Yahoo Finance | +| | **Risk** | | | | +| 12 | Beta | 1.09 | Market Time | Yahoo Finance | +| | **Alpha Vantage Only**| | | | +| 13 | 50 Day Moving Avg | $273.01 | Market Time | Alpha Vantage | +| 14 | 200 Day Moving Avg | $232.75 | Market Time | Alpha Vantage | +| 15 | 52 Week High | $288.62 | Market Time | Alpha Vantage | +| 16 | 52 Week Low | $168.63 | Market Time | Alpha Vantage | +| 17 | Analyst Target Price | $287.71 | Market Time | Alpha Vantage | diff --git a/docs/volatility_data_schema.md b/docs/volatility_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..c692aff10d3cad78eef754947c03f9b84dc8f8d3 --- /dev/null +++ b/docs/volatility_data_schema.md @@ -0,0 +1,15 @@ +Volatility Data Schema +====================== + +Single source of truth for volatility-basket MCP server output. +Example: AAPL (Apple Inc) + +| S/N | metric | value | temporal info | source | +|-----|-----------------------|---------|---------------|---------------------| +| | **Market Indices** | | | | +| 1 | VIX | 15.45 | 2026-01-08 | FRED | +| 2 | VXN | 20.15 | 2026-01-08 | FRED | +| | **Stock-Specific** | | | | +| 3 | Beta | 1.29 | 1yr rolling | Yahoo Finance | +| 4 | Historical Volatility | 12.33% | 30-day | Yahoo Finance | +| 5 | Implied Volatility | 28.45% | ATM option | Yahoo Finance Options| diff --git a/docs/yahoo_data_schema.md b/docs/yahoo_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..7dc2c88f9b5a71d8c883d243e31e69bddfd3921a --- /dev/null +++ b/docs/yahoo_data_schema.md @@ -0,0 +1,87 @@ +Yahoo Finance Data Schema +========================= + +Example: AAPL (Apple Inc) + +Time Categories: +- Market Time: Real-time price data (regularMarketTime) +- Fiscal Time: Periodic accounting data (mostRecentQuarter, lastFiscalYearEnd) + + +Company Info + +| field | value | +| -------- | ------------------ | +| longName | Apple Inc. | +| address1 | One Apple Park Way | +| city | Cupertino | +| state | CA | +| country | United States | + + +Valuation (Market Time: regularMarketTime) + +| field | value | +|---------------------|---------------| +| regularMarketTime | 1767992401 | +| marketCap | 3832542658560 | +| enterpriseValue | 3889336156160 | +| trailingPE | 34.721554 | +| forwardPE | 28.341707 | +| enterpriseToEbitda | 26.87 | +| enterpriseToRevenue | 9.346 | +| priceToBook | 51.967537 | + + +Margins, Returns & Growth (Fiscal Time: mostRecentQuarter) + +| field | value | +|-------------------------|------------| +| mostRecentQuarter | 1758931200 | +| grossMargins | 0.46905 | +| ebitdaMargins | 0.34782 | +| operatingMargins | 0.31647 | +| returnOnEquity | 1.71422 | +| returnOnAssets | 0.22964 | +| revenueGrowth | 0.079 | +| earningsQuarterlyGrowth | 0.864 | + + +Earnings (Fiscal Time: lastFiscalYearEnd / earningsTimestamp) + +| field | value | +|-------------------|------------| +| lastFiscalYearEnd | 1758931200 | +| trailingEps | 7.47 | +| earningsTimestamp | 1769720400 | +| forwardEps | 9.15153 | + + +Cash Flow & Liquidity/Debt (Fiscal Time: mostRecentQuarter) + +| field | value | +|-------------------|--------------| +| mostRecentQuarter | 1758931200 | +| freeCashflow | 78862254080 | +| operatingCashflow | 111482003456 | +| totalCash | 54697000960 | +| currentRatio | 0.893 | +| quickRatio | 0.771 | +| debtToEquity | 152.411 | +| totalDebt | 112377004032 | + + +Risk (Market Time: regularMarketTime) + +| field | value | +|-------------------|------------| +| regularMarketTime | 1767992401 | +| beta | 1.093 | + + +Dividends (exDividendDate) + +| field | value | +|----------------|------------| +| exDividendDate | 1762732800 | +| payoutRatio | 0.1367 | diff --git a/docs/yahoo_options_data_schema.md b/docs/yahoo_options_data_schema.md new file mode 100644 index 0000000000000000000000000000000000000000..4feac18744c68f93f2a336ee50b72ab68214a7fc --- /dev/null +++ b/docs/yahoo_options_data_schema.md @@ -0,0 +1,96 @@ +Yahoo Finance Options Data Schema +================================== + +Example: AAPL (Apple Inc) + +Endpoint: https://query1.finance.yahoo.com/v7/finance/options/{ticker} + + +Response Structure + +| field | description | +|------------------------|-------------------------------------| +| optionChain | Root container for options data | +| optionChain.result | Array of result objects | +| result[0].quote | Underlying stock quote data | +| result[0].options | Array of options by expiration | +| result[0].strikes | Available strike prices | +| result[0].expirationDates | Unix timestamps of expirations | + + +quote (Underlying Stock) + +| field | description | +|---------------------------|---------------------------| +| symbol | Ticker symbol | +| regularMarketPrice | Current stock price | +| regularMarketTime | Quote timestamp (Unix) | +| regularMarketChange | Price change | +| regularMarketChangePercent| Price change percentage | + + +Expiration Dates + +| field | description | +|-----------------|---------------------------------------| +| expirationDates | Array of Unix timestamps | +| count | Number of available expiration dates | +| first | Nearest expiration (Unix timestamp) | +| last | Furthest expiration (Unix timestamp) | + + +Strike Prices + +| field | description | +|---------|------------------------------| +| strikes | Array of available strikes | +| count | Number of strike prices | +| min | Lowest available strike | +| max | Highest available strike | + + +options[0] (First Expiration) + +| field | description | +|----------------|----------------------------------| +| expirationDate | Expiration date (Unix timestamp) | +| calls[] | Array of call option contracts | +| puts[] | Array of put option contracts | + + +Contract Fields (calls[] / puts[]) + +| field | description | +|-------------------|------------------------------------| +| contractSymbol | Option contract symbol | +| strike | Strike price | +| currency | Currency (USD) | +| lastPrice | Last traded price | +| change | Price change | +| percentChange | Price change percentage | +| volume | Trading volume | +| openInterest | Open interest | +| bid | Bid price | +| ask | Ask price | +| impliedVolatility | Implied volatility (decimal, 0-1+) | +| inTheMoney | Boolean: in the money | +| expiration | Expiration timestamp | +| lastTradeDate | Last trade timestamp | + + +Implied Volatility Extraction +----------------------------- + +To get ATM implied volatility: +1. Get regularMarketPrice from quote +2. Find call with strike closest to current price +3. Read impliedVolatility field (multiply by 100 for %) + +Example: + +| field | value | +|-------------------------|---------| +| currentPrice | 259.02 | +| atmStrike | 260.00 | +| impliedVolatility (raw) | 0.2845 | +| impliedVolatility (%) | 28.45% | diff --git a/mcp-servers/financials-basket/README.md b/mcp-servers/fundamentals-basket/README.md similarity index 97% rename from mcp-servers/financials-basket/README.md rename to mcp-servers/fundamentals-basket/README.md index a13cf59a0de4a5f448cdb4f8577cfc79161aa72e..ca84974b4b2014ebd55382ab20a8f90d6f8bbffe 100644 --- a/mcp-servers/financials-basket/README.md +++ b/mcp-servers/fundamentals-basket/README.md @@ -5,7 +5,7 @@ MCP server for fetching fundamental financial data from SEC EDGAR XBRL. ## Installation ```bash -cd mcp-servers/financials-basket +cd mcp-servers/fundamentals-basket python3 -m venv venv source venv/bin/activate pip install -r requirements.txt @@ -124,9 +124,9 @@ Add to `~/.config/claude/claude_desktop_config.json`: ```json { "mcpServers": { - "financials-basket": { + "fundamentals-basket": { "command": "/path/to/venv/bin/python", - "args": ["/path/to/mcp-servers/financials-basket/server.py"] + "args": ["/path/to/mcp-servers/fundamentals-basket/server.py"] } } } @@ -186,7 +186,7 @@ Add to `~/.config/claude/claude_desktop_config.json`: ## Files ``` -financials-basket/ +fundamentals-basket/ ├── server.py # MCP server implementation ├── test_fetchers.py # Standalone test script ├── requirements.txt # Python dependencies diff --git a/mcp-servers/fundamentals-basket/config.py b/mcp-servers/fundamentals-basket/config.py new file mode 100644 index 0000000000000000000000000000000000000000..ef223cde944f2c19615c8c8a10cc3e8018405d1a --- /dev/null +++ b/mcp-servers/fundamentals-basket/config.py @@ -0,0 +1,143 @@ +""" +Configuration for Financials-Basket MCP Server + +Centralized configuration for timeouts, rate limits, circuit breaker, +and SWOT analysis thresholds. +""" + +# ============================================================================= +# TIMEOUTS (seconds) - Increased for completeness-first mode +# ============================================================================= + +# Global timeout for MCP tool execution +TOOL_TIMEOUT = 60.0 + +# Per-source timeouts (increased for reliability) +SEC_EDGAR_TIMEOUT = 30.0 +SEC_EDGAR_DOCUMENT_TIMEOUT = 45.0 # For fetching full 10-K documents +YAHOO_FINANCE_TIMEOUT = 30.0 +CIK_LOOKUP_TIMEOUT = 15.0 + +# ============================================================================= +# RATE LIMITING +# ============================================================================= + +# SEC EDGAR: 10 requests per second (official limit) +SEC_RATE_LIMIT_REQUESTS = 10 +SEC_RATE_LIMIT_PERIOD = 1.0 # seconds + +# Yahoo Finance: 5 requests per second (conservative) +YAHOO_RATE_LIMIT_REQUESTS = 5 +YAHOO_RATE_LIMIT_PERIOD = 1.0 + +# ============================================================================= +# RETRY CONFIGURATION +# ============================================================================= + +# Exponential backoff: 1s, 2s, 4s +RETRY_MAX_ATTEMPTS = 3 +RETRY_BASE_DELAY = 1.0 +RETRY_EXPONENTIAL_BASE = 2 + +# HTTP status codes that trigger retry +RETRY_STATUS_CODES = {429, 500, 502, 503, 504} + +# ============================================================================= +# CIRCUIT BREAKER +# ============================================================================= + +# SEC EDGAR circuit breaker +SEC_CB_FAILURE_THRESHOLD = 5 # Open after 5 consecutive failures +SEC_CB_SUCCESS_THRESHOLD = 3 # Close after 3 consecutive successes +SEC_CB_HALF_OPEN_TIMEOUT = 30.0 # seconds + +# Yahoo Finance circuit breaker +YAHOO_CB_FAILURE_THRESHOLD = 3 +YAHOO_CB_SUCCESS_THRESHOLD = 2 +YAHOO_CB_HALF_OPEN_TIMEOUT = 60.0 + +# ============================================================================= +# CACHE TTL (seconds) +# ============================================================================= + +# CIK mappings rarely change +CIK_CACHE_TTL = 86400 # 24 hours + +# Company facts change with filings +FACTS_CACHE_TTL = 3600 # 1 hour + +# Company info (name, SIC, etc.) +COMPANY_INFO_CACHE_TTL = 86400 # 24 hours + +# ============================================================================= +# SWOT ANALYSIS THRESHOLDS +# ============================================================================= + +# Revenue growth (3-year CAGR) +REVENUE_GROWTH_STRONG = 15.0 # > 15% = strength +REVENUE_GROWTH_POSITIVE = 5.0 # > 5% = positive +REVENUE_GROWTH_DECLINING = 0.0 # < 0% = weakness + +# Net margin +NET_MARGIN_HIGH = 15.0 # > 15% = strength (high profitability) +NET_MARGIN_HEALTHY = 5.0 # > 5% = healthy +NET_MARGIN_THIN = 5.0 # < 5% = thin margins (weakness) +NET_MARGIN_UNPROFITABLE = 0.0 # < 0% = unprofitable (weakness) + +# Operating margin +OPERATING_MARGIN_STRONG = 20.0 # > 20% = strong efficiency + +# Debt to equity +DEBT_TO_EQUITY_HIGH = 2.0 # > 2.0 = threat (high leverage) +DEBT_TO_EQUITY_ELEVATED = 1.0 # > 1.0 = weakness (elevated debt) +DEBT_TO_EQUITY_LOW = 0.5 # < 0.5 = strength (low leverage) + +# R&D as percentage of revenue +RD_HIGH_INVESTMENT = 10.0 # > 10% = opportunity (high R&D investment) + +# ============================================================================= +# API ENDPOINTS +# ============================================================================= + +# SEC EDGAR +SEC_BASE_URL = "https://data.sec.gov" +SEC_COMPANY_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json" +SEC_SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik}.json" +SEC_COMPANY_FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json" + +# Required headers for SEC EDGAR +SEC_HEADERS = { + "User-Agent": "AI-Strategy-Copilot/1.0 (contact@example.com)", + "Accept": "application/json", +} + +# Yahoo Finance headers +YAHOO_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", +} + +# ============================================================================= +# THREAD POOL (for blocking libraries like yfinance) +# ============================================================================= + +YFINANCE_THREAD_POOL_SIZE = 3 +YFINANCE_SEMAPHORE_LIMIT = 3 + +# ============================================================================= +# HTTP SERVER CONFIGURATION (for load-balanced deployment) +# ============================================================================= + +import os + +# HTTP Server +HTTP_HOST = os.getenv("HTTP_HOST", "0.0.0.0") +HTTP_PORT = int(os.getenv("HTTP_PORT", "8001")) + +# Load Balancer +NGINX_PORT = 8080 +INSTANCE_PORTS = [8001, 8002, 8003] + +# Instance identification +INSTANCE_ID = os.getenv("INSTANCE_ID", f"financials-default") diff --git a/mcp-servers/financials-basket/fetchers.py b/mcp-servers/fundamentals-basket/fetchers.py similarity index 97% rename from mcp-servers/financials-basket/fetchers.py rename to mcp-servers/fundamentals-basket/fetchers.py index 3d428b5e9e6592f363b01696749eb8456d6e8939..afefc4b0eba829d3a3863c6a831760f3672bfe1f 100644 --- a/mcp-servers/financials-basket/fetchers.py +++ b/mcp-servers/fundamentals-basket/fetchers.py @@ -80,7 +80,8 @@ def get_latest_value(facts: dict, concept: str, unit: str = "USD") -> Optional[d "value": latest.get("val"), "end_date": latest.get("end"), "fiscal_year": latest.get("fy"), - "form": latest.get("form") + "form": latest.get("form"), + "filed": latest.get("filed"), } return None except Exception as e: @@ -131,8 +132,8 @@ async def fetch_financials_sec(ticker: str) -> dict: facts = data.get("facts", {}) - revenue = get_latest_value(facts, "Revenues") or \ - get_latest_value(facts, "RevenueFromContractWithCustomerExcludingAssessedTax") or \ + revenue = get_latest_value(facts, "RevenueFromContractWithCustomerExcludingAssessedTax") or \ + get_latest_value(facts, "Revenues") or \ get_latest_value(facts, "SalesRevenueNet") net_income = get_latest_value(facts, "NetIncomeLoss") @@ -171,7 +172,7 @@ async def fetch_financials_sec(ticker: str) -> dict: "total_liabilities": total_liabilities, "stockholders_equity": stockholders_equity, "source": "SEC EDGAR XBRL", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: logger.error(f"Financials error: {e}") @@ -227,7 +228,7 @@ async def fetch_debt_metrics_sec(ticker: str) -> dict: "net_debt": {"value": net_debt} if net_debt else None, "debt_to_equity": debt_to_equity, "source": "SEC EDGAR XBRL", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: logger.error(f"Debt metrics error: {e}") @@ -266,7 +267,7 @@ async def fetch_cash_flow_sec(ticker: str) -> dict: "free_cash_flow": {"value": fcf} if fcf else None, "rd_expense": rd_expense, "source": "SEC EDGAR XBRL", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: logger.error(f"Cash flow error: {e}") @@ -347,7 +348,7 @@ def _fetch_yfinance_financials_sync(ticker: str) -> dict: "source": "Yahoo Finance (fallback)", "fallback": True, "fallback_reason": "CIK not found in SEC EDGAR", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: @@ -537,5 +538,5 @@ async def get_sec_fundamentals_basket(ticker: str) -> dict: "cash_flow": cashflow, "swot_summary": swot_summary, "source": "SEC EDGAR", - "generated_at": datetime.now().isoformat() + "generated_at": datetime.now().strftime("%Y-%m-%d") } diff --git a/mcp-servers/fundamentals-basket/http_server.py b/mcp-servers/fundamentals-basket/http_server.py new file mode 100644 index 0000000000000000000000000000000000000000..64d1443042ec1e39c4251aa7bd46bc0dcee9c045 --- /dev/null +++ b/mcp-servers/fundamentals-basket/http_server.py @@ -0,0 +1,249 @@ +""" +Financials Basket HTTP Server + +FastAPI wrapper around the microservices architecture for HTTP-based load balancing. +Runs as a persistent service instead of spawning new processes per request. + +Usage: + uvicorn http_server:app --host 0.0.0.0 --port 8001 + +Multiple instances can run on different ports behind nginx load balancer. +""" + +import asyncio +import logging +import os +from datetime import datetime +from typing import Any, Dict, Optional + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from services.orchestrator import get_orchestrator_service +from config import TOOL_TIMEOUT + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("financials-http") + +# Instance identification +INSTANCE_ID = os.getenv("INSTANCE_ID", f"financials-{os.getpid()}") + +# FastAPI app +app = FastAPI( + title="Financials Basket HTTP API", + description="HTTP interface for SEC EDGAR and Yahoo Finance data with load balancing support", + version="1.0.0", +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Initialize orchestrator service (warm on startup) +orchestrator = get_orchestrator_service() + + +# ============================================================================= +# REQUEST/RESPONSE MODELS +# ============================================================================= + +class ToolRequest(BaseModel): + """Request body for tool calls.""" + ticker: Optional[str] = None + limit: Optional[int] = None + + +class HealthResponse(BaseModel): + """Health check response.""" + status: str + instance: str + uptime_seconds: float + cache_stats: Dict[str, Any] + + +# Track startup time +_startup_time = datetime.now() + + +# ============================================================================= +# ENDPOINTS +# ============================================================================= + +@app.get("/health", response_model=HealthResponse) +async def health_check(): + """ + Health check endpoint for nginx load balancer. + + Returns instance status and cache statistics. + """ + uptime = (datetime.now() - _startup_time).total_seconds() + status = orchestrator.get_status() + + return HealthResponse( + status="ok", + instance=INSTANCE_ID, + uptime_seconds=uptime, + cache_stats=status.get("cache", {}), + ) + + +@app.get("/status") +async def detailed_status(): + """ + Detailed status including circuit breaker and rate limiter state. + """ + status = orchestrator.get_status() + return { + "instance": INSTANCE_ID, + "uptime_seconds": (datetime.now() - _startup_time).total_seconds(), + **status, + } + + +@app.post("/tools/{tool_name}") +async def call_tool(tool_name: str, request: ToolRequest): + """ + Execute a tool by name. + + Supported tools: + - get_company_info + - get_financials + - get_debt_metrics + - get_cash_flow + - get_sec_fundamentals + - get_all_sources_fundamentals + - get_material_events + - get_ownership_filings + - get_going_concern + """ + logger.info(f"[{INSTANCE_ID}] Tool call: {tool_name} ticker={request.ticker}") + + # Build arguments + arguments = {} + if request.ticker: + arguments["ticker"] = request.ticker.upper() + if request.limit is not None: + arguments["limit"] = request.limit + + # Validate required arguments + tools_requiring_ticker = { + "get_company_info", + "get_financials", + "get_debt_metrics", + "get_cash_flow", + "get_sec_fundamentals", + "get_all_sources_fundamentals", + "get_material_events", + "get_ownership_filings", + "get_going_concern", + } + + if tool_name in tools_requiring_ticker and not arguments.get("ticker"): + raise HTTPException(status_code=400, detail="ticker is required") + + try: + # Execute via orchestrator with timeout + result = await asyncio.wait_for( + orchestrator.execute_tool(tool_name, arguments), + timeout=TOOL_TIMEOUT + ) + + # Add instance metadata + if isinstance(result, dict): + result["_instance"] = INSTANCE_ID + + return result + + except asyncio.TimeoutError: + logger.error(f"[{INSTANCE_ID}] Tool {tool_name} timed out") + raise HTTPException( + status_code=504, + detail=f"Tool execution timed out after {TOOL_TIMEOUT} seconds" + ) + except Exception as e: + logger.error(f"[{INSTANCE_ID}] Tool {tool_name} error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# CONVENIENCE ENDPOINTS (Direct tool access) +# ============================================================================= + +@app.get("/company/{ticker}") +async def get_company_info(ticker: str): + """Get company information for a ticker.""" + return await call_tool("get_company_info", ToolRequest(ticker=ticker)) + + +@app.get("/financials/{ticker}") +async def get_financials(ticker: str): + """Get financial metrics for a ticker.""" + return await call_tool("get_financials", ToolRequest(ticker=ticker)) + + +@app.get("/fundamentals/{ticker}") +async def get_fundamentals(ticker: str): + """Get full SEC fundamentals basket with SWOT.""" + return await call_tool("get_sec_fundamentals", ToolRequest(ticker=ticker)) + + +@app.get("/all-sources/{ticker}") +async def get_all_sources(ticker: str): + """Get financials from all sources (SEC EDGAR + Yahoo Finance).""" + return await call_tool("get_all_sources_fundamentals", ToolRequest(ticker=ticker)) + + +# ============================================================================= +# ERROR HANDLERS +# ============================================================================= + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Global exception handler for unhandled errors.""" + logger.error(f"[{INSTANCE_ID}] Unhandled error: {exc}") + return JSONResponse( + status_code=500, + content={ + "error": str(exc), + "instance": INSTANCE_ID, + "timestamp": datetime.now().strftime("%Y-%m-%d"), + } + ) + + +# ============================================================================= +# STARTUP/SHUTDOWN EVENTS +# ============================================================================= + +@app.on_event("startup") +async def startup_event(): + """Warm up services on startup.""" + logger.info(f"[{INSTANCE_ID}] Starting HTTP server...") + logger.info(f"[{INSTANCE_ID}] Orchestrator initialized: {orchestrator}") + logger.info(f"[{INSTANCE_ID}] Ready to accept requests") + + +@app.on_event("shutdown") +async def shutdown_event(): + """Clean up on shutdown.""" + logger.info(f"[{INSTANCE_ID}] Shutting down...") + + +# ============================================================================= +# MAIN +# ============================================================================= + +if __name__ == "__main__": + import uvicorn + + port = int(os.getenv("HTTP_PORT", "8001")) + host = os.getenv("HTTP_HOST", "0.0.0.0") + + uvicorn.run(app, host=host, port=port) diff --git a/mcp-servers/financials-basket/mcp_sec_edgar.md b/mcp-servers/fundamentals-basket/mcp_sec_edgar.md similarity index 100% rename from mcp-servers/financials-basket/mcp_sec_edgar.md rename to mcp-servers/fundamentals-basket/mcp_sec_edgar.md diff --git a/mcp-servers/fundamentals-basket/models/__init__.py b/mcp-servers/fundamentals-basket/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..55bd62de33e8ee433b00ec43586339942084aea8 --- /dev/null +++ b/mcp-servers/fundamentals-basket/models/__init__.py @@ -0,0 +1,41 @@ +""" +Models package for Financials-Basket MCP Server + +Contains data schemas and error definitions. +""" + +from .schemas import ( + TemporalMetric, + ParsedFinancials, + DebtMetrics, + CashFlowMetrics, + SwotSummary, + FinancialsBasket, + FetchResult, +) +from .errors import ( + ServiceError, + ErrorCodes, + CIKNotFoundError, + APITimeoutError, + CircuitOpenError, + RateLimitError, +) + +__all__ = [ + # Schemas + "TemporalMetric", + "ParsedFinancials", + "DebtMetrics", + "CashFlowMetrics", + "SwotSummary", + "FinancialsBasket", + "FetchResult", + # Errors + "ServiceError", + "ErrorCodes", + "CIKNotFoundError", + "APITimeoutError", + "CircuitOpenError", + "RateLimitError", +] diff --git a/mcp-servers/fundamentals-basket/models/errors.py b/mcp-servers/fundamentals-basket/models/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..01df8694c0774b50139f5c71761bae7575e88880 --- /dev/null +++ b/mcp-servers/fundamentals-basket/models/errors.py @@ -0,0 +1,150 @@ +""" +Error definitions for Financials-Basket MCP Server + +Defines error codes and custom exceptions for inter-service communication. +""" + +from dataclasses import dataclass +from typing import Optional + + +class ErrorCodes: + """Standardized error codes for service communication.""" + + # Fetcher errors + CIK_NOT_FOUND = "CIK_NOT_FOUND" + SEC_TIMEOUT = "SEC_TIMEOUT" + SEC_RATE_LIMIT = "SEC_RATE_LIMIT" + SEC_API_ERROR = "SEC_API_ERROR" + + YAHOO_TIMEOUT = "YAHOO_TIMEOUT" + YAHOO_NO_DATA = "YAHOO_NO_DATA" + YAHOO_API_ERROR = "YAHOO_API_ERROR" + + # Circuit breaker errors + CIRCUIT_OPEN = "CIRCUIT_OPEN" + + # Parser errors + INVALID_FACTS = "INVALID_FACTS" + CONCEPT_NOT_FOUND = "CONCEPT_NOT_FOUND" + PARSE_ERROR = "PARSE_ERROR" + + # General errors + TIMEOUT = "TIMEOUT" + UNKNOWN_ERROR = "UNKNOWN_ERROR" + + +@dataclass +class ServiceError: + """ + Structured error for service communication. + + Includes metadata for error handling and retry decisions. + """ + code: str + message: str + source: str + recoverable: bool = True + retry_after: Optional[float] = None # seconds + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + result = { + "error": self.code, + "message": self.message, + "source": self.source, + "recoverable": self.recoverable, + } + if self.retry_after: + result["retry_after"] = self.retry_after + return result + + +class FinancialsServiceError(Exception): + """Base exception for financials service errors.""" + + def __init__(self, code: str, message: str, source: str = "Unknown"): + self.code = code + self.message = message + self.source = source + super().__init__(f"[{code}] {message}") + + def to_service_error(self, recoverable: bool = True) -> ServiceError: + """Convert to ServiceError for API response.""" + return ServiceError( + code=self.code, + message=self.message, + source=self.source, + recoverable=recoverable, + ) + + +class CIKNotFoundError(FinancialsServiceError): + """Raised when CIK cannot be found for a ticker.""" + + def __init__(self, ticker: str): + super().__init__( + code=ErrorCodes.CIK_NOT_FOUND, + message=f"Could not find CIK for ticker {ticker}", + source="SEC EDGAR", + ) + self.ticker = ticker + + +class APITimeoutError(FinancialsServiceError): + """Raised when an API call times out.""" + + def __init__(self, source: str, timeout: float): + super().__init__( + code=ErrorCodes.TIMEOUT, + message=f"API call timed out after {timeout}s", + source=source, + ) + self.timeout = timeout + + +class CircuitOpenError(FinancialsServiceError): + """Raised when circuit breaker is open.""" + + def __init__(self, source: str, retry_after: float): + super().__init__( + code=ErrorCodes.CIRCUIT_OPEN, + message=f"Circuit breaker open for {source}", + source=source, + ) + self.retry_after = retry_after + + def to_service_error(self, recoverable: bool = True) -> ServiceError: + """Convert to ServiceError with retry_after.""" + error = super().to_service_error(recoverable) + error.retry_after = self.retry_after + return error + + +class RateLimitError(FinancialsServiceError): + """Raised when rate limit is exceeded.""" + + def __init__(self, source: str, retry_after: float): + super().__init__( + code=ErrorCodes.SEC_RATE_LIMIT if "SEC" in source else ErrorCodes.TIMEOUT, + message=f"Rate limit exceeded for {source}", + source=source, + ) + self.retry_after = retry_after + + def to_service_error(self, recoverable: bool = True) -> ServiceError: + """Convert to ServiceError with retry_after.""" + error = super().to_service_error(recoverable) + error.retry_after = self.retry_after + return error + + +class ParseError(FinancialsServiceError): + """Raised when parsing fails.""" + + def __init__(self, message: str, source: str = "Parser"): + super().__init__( + code=ErrorCodes.PARSE_ERROR, + message=message, + source=source, + ) diff --git a/mcp-servers/fundamentals-basket/models/schemas.py b/mcp-servers/fundamentals-basket/models/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..6dd8fba48c785a0f2849cceddfa60660059fe2d9 --- /dev/null +++ b/mcp-servers/fundamentals-basket/models/schemas.py @@ -0,0 +1,248 @@ +""" +Data schemas for Financials-Basket MCP Server + +Defines the data contracts between services using dataclasses. +All temporal metadata (end_date, fiscal_year, form) is preserved. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, List, Dict, Any + + +@dataclass +class TemporalMetric: + """ + A metric value with temporal metadata from SEC filings. + + Preserves audit period context for calculated ratios. + """ + value: Optional[float] = None + data_type: Optional[str] = None # "FY", "Q", "TTM", "Point-in-time", "Real-time" + end_date: Optional[str] = None # YYYY-MM-DD (period end date / "as of" date) + filed: Optional[str] = None # YYYY-MM-DD (SEC filing date / updated date) + fiscal_year: Optional[int] = None + form: Optional[str] = None # "10-K", "10-Q", etc. + + def to_dict(self) -> dict: + """Convert to dictionary, excluding None values.""" + result = {} + if self.value is not None: + result["value"] = self.value + if self.data_type: + result["data_type"] = self.data_type + if self.end_date: + result["end_date"] = self.end_date + if self.filed: + result["filed"] = self.filed + if self.fiscal_year: + result["fiscal_year"] = self.fiscal_year + if self.form: + result["form"] = self.form + return result if result else {"value": None} + + @classmethod + def from_dict(cls, data: Optional[dict]) -> "TemporalMetric": + """Create from dictionary.""" + if not data: + return cls() + if isinstance(data, (int, float)): + return cls(value=float(data)) + return cls( + value=data.get("value"), + data_type=data.get("data_type"), + end_date=data.get("end_date"), + filed=data.get("filed"), + fiscal_year=data.get("fiscal_year"), + form=data.get("form"), + ) + + +@dataclass +class ParsedFinancials: + """Parsed financial metrics from SEC EDGAR or Yahoo Finance.""" + ticker: str + revenue: Optional[TemporalMetric] = None + net_income: Optional[TemporalMetric] = None + gross_profit: Optional[TemporalMetric] = None + operating_income: Optional[TemporalMetric] = None + gross_margin_pct: Optional[TemporalMetric] = None + operating_margin_pct: Optional[TemporalMetric] = None + net_margin_pct: Optional[TemporalMetric] = None + revenue_growth_3yr: Optional[TemporalMetric] = None + total_assets: Optional[TemporalMetric] = None + total_liabilities: Optional[TemporalMetric] = None + stockholders_equity: Optional[TemporalMetric] = None + source: str = "Unknown" + as_of: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d")) + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + result = { + "ticker": self.ticker, + "source": self.source, + "as_of": self.as_of, + } + + # Add temporal metrics + for field_name in [ + "revenue", "net_income", "gross_profit", "operating_income", + "gross_margin_pct", "operating_margin_pct", "net_margin_pct", + "revenue_growth_3yr", "total_assets", "total_liabilities", "stockholders_equity" + ]: + value = getattr(self, field_name) + if value: + result[field_name] = value.to_dict() if isinstance(value, TemporalMetric) else value + + return result + + +@dataclass +class DebtMetrics: + """Debt and leverage metrics.""" + ticker: str + long_term_debt: Optional[TemporalMetric] = None + short_term_debt: Optional[TemporalMetric] = None + total_debt: Optional[TemporalMetric] = None + cash: Optional[TemporalMetric] = None + net_debt: Optional[TemporalMetric] = None + debt_to_equity: Optional[TemporalMetric] = None + source: str = "Unknown" + as_of: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d")) + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + result = { + "ticker": self.ticker, + "source": self.source, + "as_of": self.as_of, + } + + for field_name in [ + "long_term_debt", "short_term_debt", "total_debt", + "cash", "net_debt", "debt_to_equity" + ]: + value = getattr(self, field_name) + if value: + result[field_name] = value.to_dict() if isinstance(value, TemporalMetric) else value + + return result + + +@dataclass +class CashFlowMetrics: + """Cash flow metrics.""" + ticker: str + operating_cash_flow: Optional[TemporalMetric] = None + capital_expenditure: Optional[TemporalMetric] = None + free_cash_flow: Optional[TemporalMetric] = None + rd_expense: Optional[TemporalMetric] = None + source: str = "Unknown" + as_of: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d")) + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + result = { + "ticker": self.ticker, + "source": self.source, + "as_of": self.as_of, + } + + for field_name in [ + "operating_cash_flow", "capital_expenditure", + "free_cash_flow", "rd_expense" + ]: + value = getattr(self, field_name) + if value: + result[field_name] = value.to_dict() if isinstance(value, TemporalMetric) else value + + return result + + +@dataclass +class SwotSummary: + """SWOT analysis summary generated from financial metrics.""" + strengths: List[str] = field(default_factory=list) + weaknesses: List[str] = field(default_factory=list) + opportunities: List[str] = field(default_factory=list) + threats: List[str] = field(default_factory=list) + note: Optional[str] = None + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + result = { + "strengths": self.strengths, + "weaknesses": self.weaknesses, + "opportunities": self.opportunities, + "threats": self.threats, + } + if self.note: + result["note"] = self.note + return result + + +@dataclass +class FinancialsBasket: + """Complete financials basket response.""" + ticker: str + company: Dict[str, Any] = field(default_factory=dict) + financials: Optional[ParsedFinancials] = None + debt: Optional[DebtMetrics] = None + cash_flow: Optional[CashFlowMetrics] = None + swot_summary: Optional[SwotSummary] = None + source: str = "Unknown" + fallback: bool = False + fallback_reason: Optional[str] = None + generated_at: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d")) + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + result = { + "ticker": self.ticker, + "company": self.company, + "source": self.source, + "generated_at": self.generated_at, + } + + if self.financials: + result["financials"] = self.financials.to_dict() + if self.debt: + result["debt"] = self.debt.to_dict() + if self.cash_flow: + result["cash_flow"] = self.cash_flow.to_dict() + if self.swot_summary: + result["swot_summary"] = self.swot_summary.to_dict() + if self.fallback: + result["fallback"] = self.fallback + if self.fallback_reason: + result["fallback_reason"] = self.fallback_reason + + return result + + +@dataclass +class FetchResult: + """Result from a fetch operation.""" + success: bool + data: Optional[Dict[str, Any]] = None + error: Optional[str] = None + source: str = "Unknown" + latency_ms: float = 0.0 + retries_used: int = 0 + is_fallback: bool = False + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + result = { + "success": self.success, + "source": self.source, + "latency_ms": self.latency_ms, + "retries_used": self.retries_used, + } + if self.data: + result["data"] = self.data + if self.error: + result["error"] = self.error + if self.is_fallback: + result["is_fallback"] = self.is_fallback + return result diff --git a/mcp-servers/fundamentals-basket/nginx.conf b/mcp-servers/fundamentals-basket/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..3a48903e515b22a31de59d2e03e159fb54d3d375 --- /dev/null +++ b/mcp-servers/fundamentals-basket/nginx.conf @@ -0,0 +1,140 @@ +# Financials Basket Load Balancer Configuration +# +# Distributes requests across multiple HTTP server instances using least_conn algorithm. +# Run with: nginx -c /path/to/nginx.conf +# +# Instances should be started on ports 8001, 8002, 8003 before starting nginx. + +# Run as daemon (set to 'off' for foreground/debugging) +daemon off; + +# Worker processes (auto = number of CPU cores) +worker_processes auto; + +# Error log location +error_log /tmp/financials-nginx-error.log warn; + +# PID file +pid /tmp/financials-nginx.pid; + +events { + # Maximum simultaneous connections per worker + worker_connections 1024; + + # Use epoll for Linux + use epoll; + + # Accept multiple connections at once + multi_accept on; +} + +http { + # Basic settings + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Logging + access_log /tmp/financials-nginx-access.log; + + # Upstream cluster definition + upstream financials_cluster { + # Use least connections algorithm for better load distribution + # (routes to the server with fewest active connections) + least_conn; + + # Server instances + server 127.0.0.1:8001 weight=1 max_fails=3 fail_timeout=30s; + server 127.0.0.1:8002 weight=1 max_fails=3 fail_timeout=30s; + server 127.0.0.1:8003 weight=1 max_fails=3 fail_timeout=30s; + + # Keep connections alive for reuse + keepalive 32; + } + + server { + # Listen on port 8080 + listen 8080; + server_name localhost; + + # Health check endpoint + location /health { + proxy_pass http://financials_cluster/health; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_connect_timeout 2s; + proxy_read_timeout 5s; + } + + # Status endpoint + location /status { + proxy_pass http://financials_cluster/status; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_connect_timeout 2s; + proxy_read_timeout 5s; + } + + # Tool endpoints (main API) + location /tools/ { + proxy_pass http://financials_cluster; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Connection ""; + + # Timeouts for tool execution + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffer settings + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + + # Convenience endpoints + location /company/ { + proxy_pass http://financials_cluster; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + } + + location /financials/ { + proxy_pass http://financials_cluster; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 45s; + } + + location /fundamentals/ { + proxy_pass http://financials_cluster; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 60s; + } + + location /all-sources/ { + proxy_pass http://financials_cluster; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 60s; + } + + # Error handling + error_page 502 503 504 /50x.json; + location = /50x.json { + default_type application/json; + return 503 '{"error": "Service temporarily unavailable", "retry_after": 5}'; + } + } +} diff --git a/mcp-servers/financials-basket/requirements.txt b/mcp-servers/fundamentals-basket/requirements.txt similarity index 100% rename from mcp-servers/financials-basket/requirements.txt rename to mcp-servers/fundamentals-basket/requirements.txt diff --git a/mcp-servers/fundamentals-basket/server.py b/mcp-servers/fundamentals-basket/server.py new file mode 100644 index 0000000000000000000000000000000000000000..2f9a84f763d41e4693d426f79a6b621ec8718412 --- /dev/null +++ b/mcp-servers/fundamentals-basket/server.py @@ -0,0 +1,570 @@ +""" +Fundamentals Basket MCP Server + +Thin facade that delegates to microservices: +- OrchestratorService: Coordinates fetcher, parser, cache +- FetcherService: HTTP calls with retry, rate limiting, circuit breaker +- ParserService: XBRL parsing, ratio calculations +- CacheService: CIK and facts caching with TTL + +This file only handles: +- MCP protocol (tool definitions, call_tool decorator) +- Response formatting +- Legacy tools not yet migrated (material_events, ownership_filings, going_concern) +""" + +import asyncio +import json +import logging +import os +from datetime import datetime +from pathlib import Path +from typing import Optional + +# Load environment variables +from dotenv import load_dotenv + +env_paths = [ + Path.home() / ".env", + Path(__file__).parent / ".env", + Path(__file__).parent.parent.parent / ".env", +] +for env_path in env_paths: + if env_path.exists(): + load_dotenv(env_path) + break + +# MCP SDK +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +# Services +from services.orchestrator import get_orchestrator_service +from services.cache import get_cache_service +from services.fetcher import get_fetcher_service +from config import TOOL_TIMEOUT + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("fundamentals-basket") + +# Initialize MCP server +server = Server("fundamentals-basket") + +# Get orchestrator service +orchestrator = get_orchestrator_service() + + +# ============================================================================= +# LEGACY TOOLS (Not yet migrated to microservices) +# ============================================================================= + +async def fetch_material_events(ticker: str, limit: int = 20) -> dict: + """ + Fetch recent 8-K material events (legacy implementation). + + Parses 8-K filings for material events like: + - Item 1.02: Termination of material agreement + - Item 1.03: Bankruptcy or receivership + - Item 2.04: Asset impairment + - Item 5.02: Executive changes + """ + cache = get_cache_service() + fetcher = get_fetcher_service() + + cik = await cache.get_cik(ticker.upper()) + if not cik: + cik = await fetcher.fetch_cik(ticker) + if cik: + await cache.set_cik(ticker.upper(), cik) + + if not cik: + return {"ticker": ticker.upper(), "error": "CIK not found", "events": []} + + try: + submissions = await fetcher.fetch_company_submissions(cik) + recent = submissions.get("filings", {}).get("recent", {}) + + forms = recent.get("form", []) + dates = recent.get("filingDate", []) + accessions = recent.get("accessionNumber", []) + items_list = recent.get("items", []) + + events = [] + eight_k_indices = [i for i, f in enumerate(forms) if f == "8-K"][:limit] + + # High-priority item codes + high_priority_items = { + "1.02": "Termination of material agreement", + "1.03": "Bankruptcy or receivership", + "2.04": "Asset impairment", + "2.05": "Delisting", + "2.06": "Material impairment", + "3.01": "Notice of delisting", + "4.01": "Changes in auditors", + "4.02": "Non-reliance on financial statements", + "5.02": "Executive changes", + } + + for idx in eight_k_indices: + items = items_list[idx] if idx < len(items_list) else "" + item_codes = [i.strip() for i in items.split(",") if i.strip()] + + is_high_priority = any( + code in high_priority_items for code in item_codes + ) + + events.append({ + "form": "8-K", + "filing_date": dates[idx] if idx < len(dates) else None, + "accession": accessions[idx] if idx < len(accessions) else None, + "items": item_codes, + "high_priority": is_high_priority, + "descriptions": [ + high_priority_items.get(code, f"Item {code}") + for code in item_codes + if code in high_priority_items + ], + }) + + high_priority_count = sum(1 for e in events if e.get("high_priority")) + + return { + "ticker": ticker.upper(), + "total_8k_filings": len(eight_k_indices), + "high_priority_events": high_priority_count, + "events": events, + "swot_implications": { + "threats": [ + f"Found {high_priority_count} high-priority material events" + ] if high_priority_count > 0 else [], + }, + "source": "SEC EDGAR", + } + + except Exception as e: + logger.error(f"Material events fetch error for {ticker}: {e}") + return {"ticker": ticker.upper(), "error": str(e), "events": []} + + +async def fetch_ownership_filings(ticker: str, limit: int = 20) -> dict: + """ + Fetch ownership filings (legacy implementation). + + Includes: + - 13D/13G: 5%+ ownership changes + - Form 4: Insider transactions + """ + cache = get_cache_service() + fetcher = get_fetcher_service() + + cik = await cache.get_cik(ticker.upper()) + if not cik: + cik = await fetcher.fetch_cik(ticker) + if cik: + await cache.set_cik(ticker.upper(), cik) + + if not cik: + return {"ticker": ticker.upper(), "error": "CIK not found"} + + try: + submissions = await fetcher.fetch_company_submissions(cik) + recent = submissions.get("filings", {}).get("recent", {}) + + forms = recent.get("form", []) + dates = recent.get("filingDate", []) + accessions = recent.get("accessionNumber", []) + + # 13D/13G filings (5%+ owners) + thirteen_d_forms = ["SC 13D", "SC 13D/A", "SC 13G", "SC 13G/A"] + thirteen_d_indices = [ + i for i, f in enumerate(forms) if f in thirteen_d_forms + ][:limit] + + ownership_filings = [] + for idx in thirteen_d_indices: + ownership_filings.append({ + "form": forms[idx], + "filing_date": dates[idx] if idx < len(dates) else None, + "accession": accessions[idx] if idx < len(accessions) else None, + }) + + # Form 4 filings (insider trades) + form4_indices = [i for i, f in enumerate(forms) if f == "4"][:limit] + insider_filings = [] + for idx in form4_indices: + insider_filings.append({ + "form": "4", + "filing_date": dates[idx] if idx < len(dates) else None, + "accession": accessions[idx] if idx < len(accessions) else None, + }) + + return { + "ticker": ticker.upper(), + "ownership_5pct_filings": { + "count": len(ownership_filings), + "filings": ownership_filings, + }, + "insider_transactions": { + "count": len(insider_filings), + "filings": insider_filings, + }, + "swot_implications": { + "opportunities": [ + f"Active institutional interest: {len(ownership_filings)} 13D/13G filings" + ] if ownership_filings else [], + }, + "source": "SEC EDGAR", + } + + except Exception as e: + logger.error(f"Ownership filings fetch error for {ticker}: {e}") + return {"ticker": ticker.upper(), "error": str(e)} + + +async def fetch_going_concern(ticker: str) -> dict: + """ + Search 10-K for going concern warnings (legacy implementation). + + Looks for keywords indicating substantial doubt about continuing operations. + """ + cache = get_cache_service() + fetcher = get_fetcher_service() + + cik = await cache.get_cik(ticker.upper()) + if not cik: + cik = await fetcher.fetch_cik(ticker) + if cik: + await cache.set_cik(ticker.upper(), cik) + + if not cik: + return {"ticker": ticker.upper(), "error": "CIK not found"} + + try: + submissions = await fetcher.fetch_company_submissions(cik) + recent = submissions.get("filings", {}).get("recent", {}) + + forms = recent.get("form", []) + accessions = recent.get("accessionNumber", []) + primary_docs = recent.get("primaryDocument", []) + + # Find latest 10-K + ten_k_idx = None + for i, f in enumerate(forms): + if f in ["10-K", "10-K/A"]: + ten_k_idx = i + break + + if ten_k_idx is None: + return { + "ticker": ticker.upper(), + "warning": "No 10-K filing found", + "going_concern_found": False, + } + + # Construct document URL + accession = accessions[ten_k_idx].replace("-", "") + doc = primary_docs[ten_k_idx] + url = f"https://www.sec.gov/Archives/edgar/data/{cik.lstrip('0')}/{accession}/{doc}" + + # Fetch document + doc_text = await fetcher.fetch_10k_document(url) + + # Search for going concern keywords + keywords = [ + "going concern", + "substantial doubt", + "ability to continue", + "liquidity concerns", + "material uncertainty", + ] + + matches = [] + doc_lower = doc_text.lower() + for keyword in keywords: + if keyword in doc_lower: + matches.append(keyword) + + # Determine risk level + if len(matches) >= 3: + risk_level = "high" + elif len(matches) >= 1: + risk_level = "medium" + else: + risk_level = "none" + + return { + "ticker": ticker.upper(), + "going_concern_found": len(matches) > 0, + "risk_level": risk_level, + "keywords_found": matches, + "filing_url": url, + "swot_implications": { + "threats": [ + f"Going concern warning: {', '.join(matches)}" + ] if matches else [], + }, + "source": "SEC EDGAR 10-K", + } + + except Exception as e: + logger.error(f"Going concern search error for {ticker}: {e}") + return {"ticker": ticker.upper(), "error": str(e)} + + +# ============================================================================= +# MCP TOOL DEFINITIONS +# ============================================================================= + +@server.list_tools() +async def list_tools(): + """List available SEC EDGAR tools.""" + return [ + Tool( + name="get_company_info", + description="Get basic company information from SEC EDGAR (name, industry, CIK).", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol (e.g., AAPL, TSLA)" + } + }, + "required": ["ticker"] + } + ), + Tool( + name="get_financials", + description="Get key financial metrics from SEC filings (revenue, income, margins).", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } + ), + Tool( + name="get_debt_metrics", + description="Get debt and leverage metrics (debt levels, debt-to-equity ratio).", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } + ), + Tool( + name="get_cash_flow", + description="Get cash flow metrics (operating CF, CapEx, free cash flow, R&D).", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } + ), + Tool( + name="get_sec_fundamentals", + description="Get complete SEC fundamentals basket with aggregated SWOT summary.", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } + ), + Tool( + name="get_material_events", + description="Get recent 8-K material events (bankruptcy, impairments, executive changes, delisting).", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + }, + "limit": { + "type": "integer", + "description": "Number of recent 8-K filings to return (default: 20)", + "default": 20 + } + }, + "required": ["ticker"] + } + ), + Tool( + name="get_ownership_filings", + description="Get ownership filings: 13D/13G (5%+ ownership changes), Form 4 (insider transactions).", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + }, + "limit": { + "type": "integer", + "description": "Number of filings per category to return (default: 20)", + "default": 20 + } + }, + "required": ["ticker"] + } + ), + Tool( + name="get_going_concern", + description="Search latest 10-K for going concern warnings (substantial doubt, liquidity issues).", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } + ), + Tool( + name="get_all_sources_fundamentals", + description="Get financials from ALL sources (SEC EDGAR + Yahoo Finance) for side-by-side comparison.", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } + ) + ] + + +# ============================================================================= +# MCP CALL TOOL HANDLER +# ============================================================================= + +async def _execute_tool(name: str, ticker: str, arguments: dict) -> dict: + """Execute a tool by name.""" + # Orchestrator-handled tools + orchestrator_tools = { + "get_company_info", + "get_financials", + "get_debt_metrics", + "get_cash_flow", + "get_sec_fundamentals", + "get_all_sources_fundamentals", + } + + if name in orchestrator_tools: + return await orchestrator.execute_tool(name, {"ticker": ticker, **arguments}) + + # Legacy tools + if name == "get_material_events": + limit = arguments.get("limit", 20) + return await fetch_material_events(ticker, limit) + + elif name == "get_ownership_filings": + limit = arguments.get("limit", 20) + return await fetch_ownership_filings(ticker, limit) + + elif name == "get_going_concern": + return await fetch_going_concern(ticker) + + else: + return {"error": f"Unknown tool: {name}"} + + +@server.call_tool() +async def call_tool(name: str, arguments: dict): + """ + Handle tool invocations with GUARANTEED JSON-RPC response. + + This function ALWAYS returns a valid TextContent response, even if: + - External APIs timeout + - Exceptions occur during processing + - Any unexpected error happens + + This ensures MCP protocol compliance and prevents client hangs. + """ + try: + ticker = arguments.get("ticker", "").upper() + if not ticker: + return [TextContent(type="text", text=json.dumps({ + "error": "ticker is required", + "ticker": None, + "source": "fundamentals-basket" + }))] + + # Execute tool with global timeout + try: + result = await asyncio.wait_for( + _execute_tool(name, ticker, arguments), + timeout=TOOL_TIMEOUT + ) + except asyncio.TimeoutError: + logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s for {ticker}") + result = { + "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds", + "ticker": ticker, + "tool": name, + "source": "fundamentals-basket", + "fallback": True + } + + # Ensure result is JSON serializable + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + + except json.JSONDecodeError as e: + logger.error(f"JSON serialization error for {name}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"JSON serialization failed: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "fundamentals-basket" + }))] + + except Exception as e: + # Catch-all: ALWAYS return valid JSON-RPC response + logger.error(f"Unexpected error in {name}: {type(e).__name__}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"{type(e).__name__}: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "fundamentals-basket", + "fallback": True + }))] + + +# ============================================================================= +# MAIN +# ============================================================================= + +async def main(): + """Run the MCP server.""" + logger.info("Starting fundamentals-basket MCP server (microservices architecture)") + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mcp-servers/financials-basket/server.py b/mcp-servers/fundamentals-basket/server_legacy.py similarity index 79% rename from mcp-servers/financials-basket/server.py rename to mcp-servers/fundamentals-basket/server_legacy.py index 7b875fd5765dce07d759ac881654397f2e8de032..e1f81c0963de62f6852318a2affcd9f7356bd83c 100644 --- a/mcp-servers/financials-basket/server.py +++ b/mcp-servers/fundamentals-basket/server_legacy.py @@ -42,7 +42,7 @@ import yfinance as yf from concurrent.futures import ThreadPoolExecutor logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("financials-basket") +logger = logging.getLogger("fundamentals-basket") # Thread pool for yfinance (synchronous library) _executor = ThreadPoolExecutor(max_workers=2) @@ -68,7 +68,7 @@ def create_temporal_metric(value, source_metric) -> dict: return {"value": value} # Initialize MCP server -server = Server("financials-basket") +server = Server("fundamentals-basket") # SEC EDGAR requires User-Agent with contact info SEC_HEADERS = { @@ -257,10 +257,19 @@ async def fetch_financials(ticker: str) -> dict: facts = data.get("facts", {}) - # Extract key metrics - revenue = get_latest_value(facts, "Revenues") or \ - get_latest_value(facts, "RevenueFromContractWithCustomerExcludingAssessedTax") or \ - get_latest_value(facts, "SalesRevenueNet") + # Extract key metrics - pick concept with most recent date + # (Companies change GAAP concepts over time, e.g., Apple switched from + # "Revenues" to "RevenueFromContractWithCustomerExcludingAssessedTax" after FY2018) + revenue_candidates = [ + get_latest_value(facts, "RevenueFromContractWithCustomerExcludingAssessedTax"), + get_latest_value(facts, "Revenues"), + get_latest_value(facts, "SalesRevenueNet"), + ] + revenue = max( + [r for r in revenue_candidates if r and r.get("end_date")], + key=lambda x: x.get("end_date", ""), + default=None + ) net_income = get_latest_value(facts, "NetIncomeLoss") @@ -297,8 +306,10 @@ async def fetch_financials(ticker: str) -> dict: ) # Revenue growth - revenue_growth = calculate_growth(facts, "Revenues") or \ - calculate_growth(facts, "RevenueFromContractWithCustomerExcludingAssessedTax") + # Calculate revenue growth using the same concept as revenue + revenue_growth = calculate_growth(facts, "RevenueFromContractWithCustomerExcludingAssessedTax") or \ + calculate_growth(facts, "Revenues") or \ + calculate_growth(facts, "SalesRevenueNet") return { "ticker": ticker.upper(), @@ -314,7 +325,7 @@ async def fetch_financials(ticker: str) -> dict: "total_liabilities": total_liabilities, "stockholders_equity": stockholders_equity, "source": "SEC EDGAR XBRL", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: logger.error(f"Financials error: {e}") @@ -337,15 +348,35 @@ async def fetch_debt_metrics(ticker: str) -> dict: facts = data.get("facts", {}) - # Debt metrics - long_term_debt = get_latest_value(facts, "LongTermDebt") or \ - get_latest_value(facts, "LongTermDebtNoncurrent") + # Debt metrics - prefer concepts with most recent data + # Some companies use different concepts in different years + def get_most_recent(*concepts): + """Get the value with the most recent end_date among concepts.""" + candidates = [] + for concept in concepts: + val = get_latest_value(facts, concept) + if val and val.get("end_date"): + candidates.append(val) + if not candidates: + return None + # Sort by end_date descending and return most recent + candidates.sort(key=lambda x: x.get("end_date", ""), reverse=True) + return candidates[0] + + long_term_debt = get_most_recent( + "LongTermDebtAndCapitalLeaseObligations", # Most comprehensive, often most recent + "LongTermDebt", + "LongTermDebtNoncurrent" + ) short_term_debt = get_latest_value(facts, "ShortTermBorrowings") or \ get_latest_value(facts, "DebtCurrent") - total_debt = get_latest_value(facts, "DebtAndCapitalLeaseObligations") or \ - get_latest_value(facts, "LongTermDebtAndCapitalLeaseObligations") + total_debt = get_most_recent( + "DebtAndCapitalLeaseObligations", + "LongTermDebtAndCapitalLeaseObligations", + "LongTermDebt" + ) cash = get_latest_value(facts, "CashAndCashEquivalentsAtCarryingValue") or \ get_latest_value(facts, "Cash") @@ -384,7 +415,7 @@ async def fetch_debt_metrics(ticker: str) -> dict: "net_debt": {"value": net_debt} if net_debt else None, "debt_to_equity": debt_to_equity, "source": "SEC EDGAR XBRL", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: logger.error(f"Debt metrics error: {e}") @@ -427,7 +458,7 @@ async def fetch_cash_flow(ticker: str) -> dict: "free_cash_flow": {"value": fcf} if fcf else None, "rd_expense": rd_expense, "source": "SEC EDGAR XBRL", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: logger.error(f"Cash flow error: {e}") @@ -577,7 +608,7 @@ async def fetch_material_events(ticker: str, limit: int = 20) -> dict: "high_priority_events": high_priority_events[:5], "swot_implications": swot_implications, "source": "SEC EDGAR", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: logger.error(f"Material events error: {e}") @@ -709,7 +740,7 @@ async def fetch_going_concern(ticker: str) -> dict: "keyword_matches": matches, "swot_implications": swot_implications, "source": "SEC EDGAR 10-K", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: @@ -796,7 +827,7 @@ async def fetch_ownership_filings(ticker: str, limit: int = 20) -> dict: }, "swot_implications": swot_implications, "source": "SEC EDGAR", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: logger.error(f"Ownership filings error: {e}") @@ -888,7 +919,7 @@ def _fetch_yfinance_financials_sync(ticker: str) -> dict: "source": "Yahoo Finance (fallback)", "fallback": True, "fallback_reason": "CIK not found in SEC EDGAR", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: @@ -904,6 +935,54 @@ async def fetch_yfinance_fallback(ticker: str) -> dict: return await loop.run_in_executor(_executor, _fetch_yfinance_financials_sync, ticker) +def get_minimal_fallback(ticker: str) -> dict: + """ + Third-tier fallback: return minimal valid response when all sources fail. + Ensures 100% response rate even when SEC EDGAR and Yahoo Finance are unavailable. + """ + return { + "ticker": ticker.upper(), + "company": { + "name": ticker.upper(), + "cik": None, + "sic": None, + "exchange": None + }, + "financials": { + "note": "Financial data temporarily unavailable", + "revenue": None, + "net_income": None, + "gross_margin": None, + "operating_margin": None, + "net_margin": None + }, + "debt": { + "note": "Debt metrics temporarily unavailable", + "total_debt": None, + "debt_to_equity": None, + "current_ratio": None + }, + "cash_flow": { + "note": "Cash flow data temporarily unavailable", + "operating_cash_flow": None, + "free_cash_flow": None + }, + "swot_summary": { + "strengths": [], + "weaknesses": [], + "opportunities": [], + "threats": [], + "note": "SWOT analysis unavailable - data sources temporarily unavailable" + }, + "source": "Minimal Fallback (estimated)", + "fallback": True, + "fallback_reason": "SEC EDGAR and Yahoo Finance both unavailable", + "swot_category": "NEUTRAL", + "estimated": True, + "generated_at": datetime.now().strftime("%Y-%m-%d") + } + + def _build_swot_from_fallback(data: dict) -> dict: """ Build SWOT summary from Yahoo Finance fallback data. @@ -988,13 +1067,9 @@ async def get_sec_fundamentals_basket(ticker: str) -> dict: fallback_data = await fetch_yfinance_fallback(ticker) if "error" in fallback_data: - return { - "ticker": ticker.upper(), - "error": fallback_data["error"], - "source": "Yahoo Finance (fallback)", - "fallback": True, - "fallback_reason": "CIK not found in SEC EDGAR" - } + # Third-tier fallback: minimal valid response + logger.info(f"Yahoo Finance also failed for {ticker}, using minimal fallback") + return get_minimal_fallback(ticker) # Build SWOT from fallback data swot_summary = _build_swot_from_fallback(fallback_data) @@ -1092,7 +1167,90 @@ async def get_sec_fundamentals_basket(ticker: str) -> dict: "debt": debt, "cash_flow": cashflow, "swot_summary": swot_summary, - "generated_at": datetime.now().isoformat() + "generated_at": datetime.now().strftime("%Y-%m-%d") + } + + +async def get_all_sources_fundamentals(ticker: str) -> dict: + """ + Fetch financials from ALL sources (SEC EDGAR AND Yahoo Finance) in parallel. + Returns both results for comparison, not as a fallback chain. + """ + # Fetch from both sources in parallel + sec_task = get_sec_fundamentals_basket(ticker) + yfinance_task = fetch_yfinance_fallback(ticker) + + sec_result, yfinance_result = await asyncio.gather(sec_task, yfinance_task) + + # Format SEC EDGAR results + sec_data = { + "source": "SEC EDGAR XBRL", + "as_of": sec_result.get("generated_at"), + "data": {} + } + + if sec_result.get("financials") and "error" not in sec_result.get("financials", {}): + fin = sec_result["financials"] + # Only 6 universal metrics that work across ALL industries + sec_data["data"] = { + "revenue": fin.get("revenue"), + "net_income": fin.get("net_income"), + "net_margin_pct": fin.get("net_margin_pct"), + "total_assets": fin.get("total_assets"), + "total_liabilities": fin.get("total_liabilities"), + "stockholders_equity": fin.get("stockholders_equity"), + } + + # Format Yahoo Finance results + yfinance_data = { + "source": "Yahoo Finance", + "as_of": yfinance_result.get("as_of") or datetime.now().strftime("%Y-%m-%d"), + "data": {} + } + + if "error" not in yfinance_result: + fin = yfinance_result.get("financials", {}) + debt = yfinance_result.get("debt", {}) + cf = yfinance_result.get("cash_flow", {}) + + # Helper to extract raw value (handles both dict and non-dict) + def get_val(d, key): + v = d.get(key) + if isinstance(v, dict): + return v.get("value") + return v + + # Check if SEC EDGAR failed (no data) + sec_failed = not sec_data.get("data") + + if sec_failed: + # FALLBACK: Yahoo provides core metrics when SEC fails + yfinance_data["data"] = { + "revenue": {"value": get_val(fin, "revenue"), "period": "TTM"} if get_val(fin, "revenue") else None, + "net_income": {"value": get_val(fin, "net_income"), "period": "TTM"} if get_val(fin, "net_income") else None, + "net_margin_pct": {"value": get_val(fin, "net_margin")} if get_val(fin, "net_margin") else None, + "total_assets": {"value": get_val(debt, "total_assets")} if get_val(debt, "total_assets") else None, + "operating_margin_pct": {"value": get_val(fin, "operating_margin_pct")} if get_val(fin, "operating_margin_pct") else None, + "total_debt": {"value": get_val(debt, "total_debt")} if get_val(debt, "total_debt") else None, + "operating_cash_flow": {"value": get_val(cf, "operating_cash_flow")} if get_val(cf, "operating_cash_flow") else None, + "free_cash_flow": {"value": get_val(cf, "free_cash_flow")} if get_val(cf, "free_cash_flow") else None, + } + else: + # SUPPLEMENTARY: Only additional metrics not in SEC EDGAR + yfinance_data["data"] = { + "operating_margin_pct": {"value": get_val(fin, "operating_margin_pct")} if get_val(fin, "operating_margin_pct") else None, + "total_debt": {"value": get_val(debt, "total_debt")} if get_val(debt, "total_debt") else None, + "operating_cash_flow": {"value": get_val(cf, "operating_cash_flow")} if get_val(cf, "operating_cash_flow") else None, + "free_cash_flow": {"value": get_val(cf, "free_cash_flow")} if get_val(cf, "free_cash_flow") else None, + } + else: + yfinance_data["error"] = yfinance_result.get("error") + + return { + "ticker": ticker.upper(), + "sec_edgar": sec_data, + "yahoo_finance": yfinance_data, + "generated_at": datetime.now().strftime("%Y-%m-%d") } @@ -1225,44 +1383,113 @@ async def list_tools(): }, "required": ["ticker"] } + ), + Tool( + name="get_all_sources_fundamentals", + description="Get financials from ALL sources (SEC EDGAR + Yahoo Finance) for side-by-side comparison.", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } ) ] +# Global timeout for all tool operations (seconds) +TOOL_TIMEOUT = 45.0 + + +async def _execute_tool_with_timeout(name: str, ticker: str, arguments: dict) -> dict: + """Execute a tool with timeout. Returns result dict or error dict.""" + if name == "get_company_info": + return await fetch_company_info(ticker) + elif name == "get_financials": + return await fetch_financials(ticker) + elif name == "get_debt_metrics": + return await fetch_debt_metrics(ticker) + elif name == "get_cash_flow": + return await fetch_cash_flow(ticker) + elif name == "get_sec_fundamentals": + return await get_sec_fundamentals_basket(ticker) + elif name == "get_material_events": + limit = arguments.get("limit", 20) + return await fetch_material_events(ticker, limit) + elif name == "get_ownership_filings": + limit = arguments.get("limit", 20) + return await fetch_ownership_filings(ticker, limit) + elif name == "get_going_concern": + return await fetch_going_concern(ticker) + elif name == "get_all_sources_fundamentals": + return await get_all_sources_fundamentals(ticker) + else: + return {"error": f"Unknown tool: {name}"} + + @server.call_tool() async def call_tool(name: str, arguments: dict): - """Handle tool invocations.""" + """ + Handle tool invocations with GUARANTEED JSON-RPC response. + + This function ALWAYS returns a valid TextContent response, even if: + - External APIs timeout + - Exceptions occur during processing + - Any unexpected error happens + + This ensures MCP protocol compliance and prevents client hangs. + """ try: ticker = arguments.get("ticker", "").upper() if not ticker and name != "list_tools": - return [TextContent(type="text", text="Error: ticker is required")] - - if name == "get_company_info": - result = await fetch_company_info(ticker) - elif name == "get_financials": - result = await fetch_financials(ticker) - elif name == "get_debt_metrics": - result = await fetch_debt_metrics(ticker) - elif name == "get_cash_flow": - result = await fetch_cash_flow(ticker) - elif name == "get_sec_fundamentals": - result = await get_sec_fundamentals_basket(ticker) - elif name == "get_material_events": - limit = arguments.get("limit", 20) - result = await fetch_material_events(ticker, limit) - elif name == "get_ownership_filings": - limit = arguments.get("limit", 20) - result = await fetch_ownership_filings(ticker, limit) - elif name == "get_going_concern": - result = await fetch_going_concern(ticker) - else: - return [TextContent(type="text", text=f"Unknown tool: {name}")] + return [TextContent(type="text", text=json.dumps({ + "error": "ticker is required", + "ticker": None, + "source": "fundamentals-basket" + }))] + + # Execute tool with global timeout + try: + result = await asyncio.wait_for( + _execute_tool_with_timeout(name, ticker, arguments), + timeout=TOOL_TIMEOUT + ) + except asyncio.TimeoutError: + logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s for {ticker}") + result = { + "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds", + "ticker": ticker, + "tool": name, + "source": "fundamentals-basket", + "fallback": True + } + + # Ensure result is JSON serializable + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] - return [TextContent(type="text", text=json.dumps(result, indent=2))] + except json.JSONDecodeError as e: + logger.error(f"JSON serialization error for {name}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"JSON serialization failed: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "fundamentals-basket" + }))] except Exception as e: - logger.error(f"Tool error {name}: {e}") - return [TextContent(type="text", text=f"Error: {str(e)}")] + # Catch-all: ALWAYS return valid JSON-RPC response + logger.error(f"Unexpected error in {name}: {type(e).__name__}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"{type(e).__name__}: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "fundamentals-basket", + "fallback": True + }))] # ============================================================ diff --git a/mcp-servers/fundamentals-basket/services/__init__.py b/mcp-servers/fundamentals-basket/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ab7dfe802aa59ed755cc8d58f94024cd9d1896ff --- /dev/null +++ b/mcp-servers/fundamentals-basket/services/__init__.py @@ -0,0 +1,21 @@ +""" +Services package for Financials-Basket MCP Server + +Contains the microservices: +- CacheService: CIK and facts caching with TTL +- FetcherService: HTTP clients with retry, rate limiting, circuit breaker +- ParserService: XBRL parsing, ratio calculations, temporal metadata +- OrchestratorService: Request routing, fallback chain coordination +""" + +from .cache import CacheService +from .fetcher import FetcherService +from .parser import ParserService +from .orchestrator import OrchestratorService + +__all__ = [ + "CacheService", + "FetcherService", + "ParserService", + "OrchestratorService", +] diff --git a/mcp-servers/fundamentals-basket/services/cache.py b/mcp-servers/fundamentals-basket/services/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..8c2e51b212104fef68b547810bd3ee1704794b0a --- /dev/null +++ b/mcp-servers/fundamentals-basket/services/cache.py @@ -0,0 +1,270 @@ +""" +Cache Service for Financials-Basket MCP Server + +Provides in-memory caching with TTL for: +- CIK lookups (24h TTL - rarely changes) +- Company facts (1h TTL - changes with filings) +- Company info (24h TTL) + +Thread-safe with asyncio.Lock. +""" + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Optional, Dict, Any + +from config import ( + CIK_CACHE_TTL, + FACTS_CACHE_TTL, + COMPANY_INFO_CACHE_TTL, +) + +logger = logging.getLogger("fundamentals-basket.cache") + + +@dataclass +class CacheEntry: + """A cached value with timestamp and TTL.""" + value: Any + timestamp: float = field(default_factory=time.time) + ttl: float = 3600 # Default 1 hour + + def is_expired(self) -> bool: + """Check if the cache entry has expired.""" + return (time.time() - self.timestamp) >= self.ttl + + +class CacheService: + """ + In-memory cache service with TTL support. + + Features: + - Async-safe with locks + - Automatic expiration checking + - Separate caches for CIK, facts, and company info + - Metrics for cache hits/misses + """ + + def __init__(self): + self._cik_cache: Dict[str, CacheEntry] = {} + self._facts_cache: Dict[str, CacheEntry] = {} + self._company_info_cache: Dict[str, CacheEntry] = {} + + self._lock = asyncio.Lock() + + # Metrics + self._hits = 0 + self._misses = 0 + + # ========================================================================= + # CIK CACHE + # ========================================================================= + + async def get_cik(self, ticker: str) -> Optional[str]: + """ + Get CIK for a ticker from cache. + + Args: + ticker: Stock ticker symbol + + Returns: + CIK string if cached and not expired, None otherwise + """ + ticker = ticker.upper() + async with self._lock: + entry = self._cik_cache.get(ticker) + if entry and not entry.is_expired(): + self._hits += 1 + logger.debug(f"Cache HIT: CIK for {ticker}") + return entry.value + elif entry: + # Expired, remove it + del self._cik_cache[ticker] + + self._misses += 1 + logger.debug(f"Cache MISS: CIK for {ticker}") + return None + + async def set_cik(self, ticker: str, cik: str) -> None: + """ + Cache CIK for a ticker. + + Args: + ticker: Stock ticker symbol + cik: The CIK value to cache + """ + ticker = ticker.upper() + async with self._lock: + self._cik_cache[ticker] = CacheEntry( + value=cik, + ttl=CIK_CACHE_TTL, + ) + logger.debug(f"Cache SET: CIK for {ticker} = {cik}") + + # ========================================================================= + # FACTS CACHE + # ========================================================================= + + async def get_company_facts(self, cik: str) -> Optional[Dict[str, Any]]: + """ + Get company facts from cache. + + Args: + cik: CIK identifier (10-digit padded) + + Returns: + Company facts dict if cached and not expired, None otherwise + """ + async with self._lock: + entry = self._facts_cache.get(cik) + if entry and not entry.is_expired(): + self._hits += 1 + logger.debug(f"Cache HIT: Facts for CIK {cik}") + return entry.value + elif entry: + del self._facts_cache[cik] + + self._misses += 1 + logger.debug(f"Cache MISS: Facts for CIK {cik}") + return None + + async def set_company_facts( + self, + cik: str, + facts: Dict[str, Any], + ttl: Optional[float] = None + ) -> None: + """ + Cache company facts. + + Args: + cik: CIK identifier + facts: Company facts dictionary + ttl: Optional custom TTL (defaults to FACTS_CACHE_TTL) + """ + async with self._lock: + self._facts_cache[cik] = CacheEntry( + value=facts, + ttl=ttl or FACTS_CACHE_TTL, + ) + logger.debug(f"Cache SET: Facts for CIK {cik}") + + # ========================================================================= + # COMPANY INFO CACHE + # ========================================================================= + + async def get_company_info(self, ticker: str) -> Optional[Dict[str, Any]]: + """ + Get company info from cache. + + Args: + ticker: Stock ticker symbol + + Returns: + Company info dict if cached and not expired, None otherwise + """ + ticker = ticker.upper() + async with self._lock: + entry = self._company_info_cache.get(ticker) + if entry and not entry.is_expired(): + self._hits += 1 + logger.debug(f"Cache HIT: Info for {ticker}") + return entry.value + elif entry: + del self._company_info_cache[ticker] + + self._misses += 1 + logger.debug(f"Cache MISS: Info for {ticker}") + return None + + async def set_company_info( + self, + ticker: str, + info: Dict[str, Any], + ttl: Optional[float] = None + ) -> None: + """ + Cache company info. + + Args: + ticker: Stock ticker symbol + info: Company info dictionary + ttl: Optional custom TTL (defaults to COMPANY_INFO_CACHE_TTL) + """ + ticker = ticker.upper() + async with self._lock: + self._company_info_cache[ticker] = CacheEntry( + value=info, + ttl=ttl or COMPANY_INFO_CACHE_TTL, + ) + logger.debug(f"Cache SET: Info for {ticker}") + + # ========================================================================= + # CACHE MANAGEMENT + # ========================================================================= + + async def clear(self) -> None: + """Clear all caches.""" + async with self._lock: + self._cik_cache.clear() + self._facts_cache.clear() + self._company_info_cache.clear() + logger.info("All caches cleared") + + async def clear_expired(self) -> int: + """ + Remove all expired entries from caches. + + Returns: + Number of entries removed + """ + removed = 0 + async with self._lock: + for cache in [ + self._cik_cache, + self._facts_cache, + self._company_info_cache + ]: + expired_keys = [ + k for k, v in cache.items() if v.is_expired() + ] + for key in expired_keys: + del cache[key] + removed += 1 + + if removed > 0: + logger.info(f"Cleared {removed} expired cache entries") + return removed + + def get_stats(self) -> Dict[str, Any]: + """ + Get cache statistics. + + Returns: + Dict with cache stats + """ + total = self._hits + self._misses + hit_rate = (self._hits / total * 100) if total > 0 else 0 + + return { + "hits": self._hits, + "misses": self._misses, + "hit_rate_pct": round(hit_rate, 2), + "cik_cache_size": len(self._cik_cache), + "facts_cache_size": len(self._facts_cache), + "company_info_cache_size": len(self._company_info_cache), + } + + +# Global cache instance +_cache_service: Optional[CacheService] = None + + +def get_cache_service() -> CacheService: + """Get or create the global cache service instance.""" + global _cache_service + if _cache_service is None: + _cache_service = CacheService() + return _cache_service diff --git a/mcp-servers/fundamentals-basket/services/fetcher.py b/mcp-servers/fundamentals-basket/services/fetcher.py new file mode 100644 index 0000000000000000000000000000000000000000..5701c3da47752998c3f779d6bbd0631f24c092ea --- /dev/null +++ b/mcp-servers/fundamentals-basket/services/fetcher.py @@ -0,0 +1,628 @@ +""" +Fetcher Service for Financials-Basket MCP Server + +Handles all external API calls with: +- Retry logic with exponential backoff +- Rate limiting (10 req/s for SEC EDGAR) +- Circuit breaker for fault tolerance +- ThreadPoolExecutor for blocking yfinance library +""" + +import asyncio +import logging +import time +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, Callable +from enum import Enum +from collections import deque + +import httpx + +from config import ( + SEC_EDGAR_TIMEOUT, + SEC_EDGAR_DOCUMENT_TIMEOUT, + YAHOO_FINANCE_TIMEOUT, + CIK_LOOKUP_TIMEOUT, + SEC_RATE_LIMIT_REQUESTS, + SEC_RATE_LIMIT_PERIOD, + RETRY_MAX_ATTEMPTS, + RETRY_BASE_DELAY, + RETRY_EXPONENTIAL_BASE, + RETRY_STATUS_CODES, + SEC_CB_FAILURE_THRESHOLD, + SEC_CB_SUCCESS_THRESHOLD, + SEC_CB_HALF_OPEN_TIMEOUT, + YAHOO_CB_FAILURE_THRESHOLD, + YAHOO_CB_SUCCESS_THRESHOLD, + YAHOO_CB_HALF_OPEN_TIMEOUT, + SEC_COMPANY_TICKERS_URL, + SEC_SUBMISSIONS_URL, + SEC_COMPANY_FACTS_URL, + SEC_HEADERS, + YAHOO_HEADERS, + YFINANCE_THREAD_POOL_SIZE, + YFINANCE_SEMAPHORE_LIMIT, +) +from models.errors import ( + CIKNotFoundError, + APITimeoutError, + CircuitOpenError, + RateLimitError, +) + +logger = logging.getLogger("fundamentals-basket.fetcher") + + +# ============================================================================= +# CIRCUIT BREAKER +# ============================================================================= + +class CircuitState(Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +@dataclass +class CircuitBreakerConfig: + """Configuration for circuit breaker behavior.""" + failure_threshold: int = 5 + success_threshold: int = 3 + half_open_timeout: float = 30.0 + + +@dataclass +class CircuitBreaker: + """Circuit breaker for fault tolerance.""" + name: str + config: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig) + state: CircuitState = field(default=CircuitState.CLOSED) + failure_count: int = 0 + success_count: int = 0 + last_failure_time: Optional[float] = None + last_state_change: float = field(default_factory=time.monotonic) + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def _transition(self, new_state: CircuitState): + """Transition to a new state.""" + if self.state != new_state: + logger.info(f"Circuit breaker {self.name}: {self.state.value} -> {new_state.value}") + self.state = new_state + self.last_state_change = time.monotonic() + if new_state == CircuitState.CLOSED: + self.failure_count = 0 + self.success_count = 0 + elif new_state == CircuitState.HALF_OPEN: + self.success_count = 0 + + def allow_request(self) -> bool: + """Check if a request should be allowed through.""" + with self._lock: + now = time.monotonic() + + if self.state == CircuitState.CLOSED: + return True + elif self.state == CircuitState.OPEN: + if self.last_failure_time: + elapsed = now - self.last_failure_time + if elapsed >= self.config.half_open_timeout: + self._transition(CircuitState.HALF_OPEN) + return True + return False + elif self.state == CircuitState.HALF_OPEN: + return True + + return False + + def record_success(self): + """Record a successful request.""" + with self._lock: + if self.state == CircuitState.HALF_OPEN: + self.success_count += 1 + if self.success_count >= self.config.success_threshold: + self._transition(CircuitState.CLOSED) + elif self.state == CircuitState.CLOSED: + self.failure_count = max(0, self.failure_count - 1) + + def record_failure(self, error: Optional[str] = None): + """Record a failed request.""" + with self._lock: + self.last_failure_time = time.monotonic() + + if self.state == CircuitState.CLOSED: + self.failure_count += 1 + if self.failure_count >= self.config.failure_threshold: + self._transition(CircuitState.OPEN) + elif self.state == CircuitState.HALF_OPEN: + self._transition(CircuitState.OPEN) + + +# ============================================================================= +# RATE LIMITER (Token Bucket) +# ============================================================================= + +@dataclass +class TokenBucket: + """Token bucket rate limiter.""" + rate: float # Tokens per second + capacity: int # Maximum tokens + tokens: float = field(init=False) + last_update: float = field(init=False) + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def __post_init__(self): + self.tokens = float(self.capacity) + self.last_update = time.monotonic() + + def _refill(self): + """Refill tokens based on elapsed time.""" + now = time.monotonic() + elapsed = now - self.last_update + self.tokens = min(self.capacity, self.tokens + elapsed * self.rate) + self.last_update = now + + def acquire(self, tokens: int = 1) -> bool: + """Try to acquire tokens. Returns True if successful.""" + with self._lock: + self._refill() + if self.tokens >= tokens: + self.tokens -= tokens + return True + return False + + async def acquire_async(self, tokens: int = 1, timeout: float = 30.0) -> bool: + """Async version - waits until tokens available or timeout.""" + start = time.monotonic() + while time.monotonic() - start < timeout: + if self.acquire(tokens): + return True + wait_time = min(0.1, (tokens - self.tokens) / self.rate) + await asyncio.sleep(max(0.01, wait_time)) + return False + + +# ============================================================================= +# FETCHER SERVICE +# ============================================================================= + +class FetcherService: + """ + Fetcher service for external API calls. + + Features: + - Retry logic with exponential backoff + - Rate limiting for SEC EDGAR (10 req/s) + - Circuit breakers for SEC and Yahoo + - ThreadPoolExecutor for blocking yfinance + """ + + def __init__(self): + # HTTP client + self._client: Optional[httpx.AsyncClient] = None + + # Rate limiters + self._sec_rate_limiter = TokenBucket( + rate=SEC_RATE_LIMIT_REQUESTS, + capacity=SEC_RATE_LIMIT_REQUESTS + ) + + # Circuit breakers + self._circuit_breakers = { + "sec_edgar": CircuitBreaker( + name="sec_edgar", + config=CircuitBreakerConfig( + failure_threshold=SEC_CB_FAILURE_THRESHOLD, + success_threshold=SEC_CB_SUCCESS_THRESHOLD, + half_open_timeout=SEC_CB_HALF_OPEN_TIMEOUT, + ) + ), + "yahoo_finance": CircuitBreaker( + name="yahoo_finance", + config=CircuitBreakerConfig( + failure_threshold=YAHOO_CB_FAILURE_THRESHOLD, + success_threshold=YAHOO_CB_SUCCESS_THRESHOLD, + half_open_timeout=YAHOO_CB_HALF_OPEN_TIMEOUT, + ) + ), + } + + # Thread pool for yfinance + self._yfinance_executor = ThreadPoolExecutor( + max_workers=YFINANCE_THREAD_POOL_SIZE, + thread_name_prefix="yfinance-" + ) + self._yfinance_semaphore = asyncio.Semaphore(YFINANCE_SEMAPHORE_LIMIT) + + # Company tickers cache (loaded once) + self._company_tickers: Optional[Dict[str, str]] = None + self._company_tickers_lock = asyncio.Lock() + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create HTTP client.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=30.0) + return self._client + + async def close(self): + """Close resources.""" + if self._client: + await self._client.aclose() + self._yfinance_executor.shutdown(wait=False) + + # ========================================================================= + # RETRY LOGIC + # ========================================================================= + + async def _fetch_with_retry( + self, + url: str, + headers: Dict[str, str], + timeout: float, + source: str, + rate_limiter: Optional[TokenBucket] = None, + ) -> Dict[str, Any]: + """ + Fetch URL with retry logic and rate limiting. + + Args: + url: URL to fetch + headers: Request headers + timeout: Request timeout in seconds + source: Source name for logging + rate_limiter: Optional rate limiter to use + + Returns: + JSON response as dict + + Raises: + APITimeoutError: On timeout after retries + CircuitOpenError: If circuit breaker is open + """ + circuit_breaker = self._circuit_breakers.get(source.lower().replace(" ", "_")) + + # Check circuit breaker + if circuit_breaker and not circuit_breaker.allow_request(): + raise CircuitOpenError(source, circuit_breaker.config.half_open_timeout) + + last_error = None + client = await self._get_client() + + for attempt in range(RETRY_MAX_ATTEMPTS): + try: + # Rate limiting + if rate_limiter: + if not await rate_limiter.acquire_async(timeout=5.0): + raise RateLimitError(source, 1.0) + + # Make request + start_time = time.time() + response = await client.get(url, headers=headers, timeout=timeout) + latency_ms = (time.time() - start_time) * 1000 + + # Check for retry-able status codes + if response.status_code in RETRY_STATUS_CODES: + last_error = f"HTTP {response.status_code}" + if attempt < RETRY_MAX_ATTEMPTS - 1: + delay = RETRY_BASE_DELAY * (RETRY_EXPONENTIAL_BASE ** attempt) + logger.warning( + f"{source} returned {response.status_code}, " + f"retrying in {delay}s (attempt {attempt + 1}/{RETRY_MAX_ATTEMPTS})" + ) + await asyncio.sleep(delay) + continue + + response.raise_for_status() + data = response.json() + + # Record success + if circuit_breaker: + circuit_breaker.record_success() + + logger.debug(f"{source} fetch successful ({latency_ms:.0f}ms)") + return data + + except httpx.TimeoutException as e: + last_error = str(e) + if circuit_breaker: + circuit_breaker.record_failure(last_error) + if attempt < RETRY_MAX_ATTEMPTS - 1: + delay = RETRY_BASE_DELAY * (RETRY_EXPONENTIAL_BASE ** attempt) + logger.warning(f"{source} timeout, retrying in {delay}s") + await asyncio.sleep(delay) + continue + + except httpx.HTTPStatusError as e: + last_error = f"HTTP {e.response.status_code}" + if circuit_breaker: + circuit_breaker.record_failure(last_error) + if e.response.status_code in RETRY_STATUS_CODES and attempt < RETRY_MAX_ATTEMPTS - 1: + delay = RETRY_BASE_DELAY * (RETRY_EXPONENTIAL_BASE ** attempt) + await asyncio.sleep(delay) + continue + raise + + except Exception as e: + last_error = str(e) + if circuit_breaker: + circuit_breaker.record_failure(last_error) + raise + + # All retries exhausted + raise APITimeoutError(source, timeout) + + # ========================================================================= + # SEC EDGAR FETCHERS + # ========================================================================= + + async def _load_company_tickers(self) -> Dict[str, str]: + """Load and cache company tickers mapping (ticker -> CIK).""" + async with self._company_tickers_lock: + if self._company_tickers is not None: + return self._company_tickers + + try: + data = await self._fetch_with_retry( + url=SEC_COMPANY_TICKERS_URL, + headers=SEC_HEADERS, + timeout=CIK_LOOKUP_TIMEOUT, + source="SEC EDGAR", + rate_limiter=self._sec_rate_limiter, + ) + + # Build ticker -> CIK mapping + self._company_tickers = {} + for entry in data.values(): + ticker = entry.get("ticker", "").upper() + cik = str(entry.get("cik_str", "")) + if ticker and cik: + self._company_tickers[ticker] = cik + + logger.info(f"Loaded {len(self._company_tickers)} company tickers") + return self._company_tickers + + except Exception as e: + logger.error(f"Failed to load company tickers: {e}") + self._company_tickers = {} + return self._company_tickers + + async def fetch_cik(self, ticker: str) -> Optional[str]: + """ + Fetch CIK for a ticker. + + Args: + ticker: Stock ticker symbol + + Returns: + CIK string (10-digit padded) or None if not found + """ + ticker = ticker.upper() + tickers = await self._load_company_tickers() + cik = tickers.get(ticker) + + if cik: + # Pad to 10 digits + return cik.zfill(10) + + logger.warning(f"CIK not found for ticker {ticker}") + return None + + async def fetch_company_submissions(self, cik: str) -> Dict[str, Any]: + """ + Fetch company submissions (metadata and filings) from SEC EDGAR. + + Args: + cik: CIK identifier (10-digit padded) + + Returns: + Submissions data dict + """ + url = SEC_SUBMISSIONS_URL.format(cik=cik) + return await self._fetch_with_retry( + url=url, + headers=SEC_HEADERS, + timeout=SEC_EDGAR_TIMEOUT, + source="SEC EDGAR", + rate_limiter=self._sec_rate_limiter, + ) + + async def fetch_company_facts(self, cik: str) -> Dict[str, Any]: + """ + Fetch company facts (XBRL data) from SEC EDGAR. + + Args: + cik: CIK identifier (10-digit padded) + + Returns: + Company facts dict containing us-gaap concepts + """ + url = SEC_COMPANY_FACTS_URL.format(cik=cik) + return await self._fetch_with_retry( + url=url, + headers=SEC_HEADERS, + timeout=SEC_EDGAR_TIMEOUT, + source="SEC EDGAR", + rate_limiter=self._sec_rate_limiter, + ) + + async def fetch_10k_document(self, url: str) -> str: + """ + Fetch raw 10-K document text. + + Args: + url: Full URL to the 10-K document + + Returns: + Document text content + """ + client = await self._get_client() + + # Rate limiting + await self._sec_rate_limiter.acquire_async(timeout=5.0) + + response = await client.get(url, headers=SEC_HEADERS, timeout=SEC_EDGAR_DOCUMENT_TIMEOUT) + response.raise_for_status() + return response.text + + # ========================================================================= + # YAHOO FINANCE FETCHER + # ========================================================================= + + def _fetch_yfinance_sync(self, ticker: str) -> Dict[str, Any]: + """ + Synchronous yfinance fetch (runs in thread pool). + + Args: + ticker: Stock ticker symbol + + Returns: + Financials data dict + """ + try: + import yfinance as yf + + stock = yf.Ticker(ticker) + info = stock.info + + if not info or len(info) < 5: + return {"error": f"No data found for {ticker}"} + + # Convert Unix timestamps to dates (use UTC for correct trading date) + from datetime import datetime as dt, timezone + most_recent_quarter = info.get("mostRecentQuarter") + last_fiscal_year_end = info.get("lastFiscalYearEnd") + + most_recent_quarter_date = None + last_fiscal_year_end_date = None + + if most_recent_quarter: + try: + most_recent_quarter_date = dt.fromtimestamp(most_recent_quarter, tz=timezone.utc).strftime("%Y-%m-%d") + except (ValueError, OSError): + pass + + if last_fiscal_year_end: + try: + last_fiscal_year_end_date = dt.fromtimestamp(last_fiscal_year_end, tz=timezone.utc).strftime("%Y-%m-%d") + except (ValueError, OSError): + pass + + regular_market_time = info.get("regularMarketTime") + regular_market_time_date = None + if regular_market_time: + try: + regular_market_time_date = dt.fromtimestamp(regular_market_time, tz=timezone.utc).strftime("%Y-%m-%d") + except (ValueError, OSError): + pass + + # Extract relevant fields + return { + "ticker": ticker.upper(), + "name": info.get("longName") or info.get("shortName"), + "sector": info.get("sector"), + "industry": info.get("industry"), + "revenue": info.get("totalRevenue"), + "gross_profit": info.get("grossProfits"), + "operating_income": info.get("operatingIncome"), + "net_income": info.get("netIncomeToCommon"), + "total_assets": info.get("totalAssets"), + "total_liabilities": info.get("totalLiab"), + "stockholders_equity": info.get("totalStockholderEquity"), + "total_debt": info.get("totalDebt"), + "cash": info.get("totalCash"), + "operating_cash_flow": info.get("operatingCashflow"), + "free_cash_flow": info.get("freeCashflow"), + "market_cap": info.get("marketCap"), + "pe_ratio": info.get("trailingPE"), + "forward_pe": info.get("forwardPE"), + "most_recent_quarter": most_recent_quarter_date, + "last_fiscal_year_end": last_fiscal_year_end_date, + "regular_market_time": regular_market_time_date, + "source": "Yahoo Finance", + } + + except Exception as e: + logger.error(f"yfinance error for {ticker}: {e}") + return {"error": str(e), "ticker": ticker} + + async def fetch_yfinance(self, ticker: str) -> Dict[str, Any]: + """ + Fetch financials from Yahoo Finance (async wrapper). + + Uses ThreadPoolExecutor since yfinance is blocking. + + Args: + ticker: Stock ticker symbol + + Returns: + Financials data dict + """ + circuit_breaker = self._circuit_breakers["yahoo_finance"] + + # Check circuit breaker + if not circuit_breaker.allow_request(): + raise CircuitOpenError("Yahoo Finance", circuit_breaker.config.half_open_timeout) + + async with self._yfinance_semaphore: + loop = asyncio.get_event_loop() + try: + result = await asyncio.wait_for( + loop.run_in_executor( + self._yfinance_executor, + self._fetch_yfinance_sync, + ticker + ), + timeout=YAHOO_FINANCE_TIMEOUT + ) + + if "error" not in result: + circuit_breaker.record_success() + else: + circuit_breaker.record_failure(result.get("error")) + + return result + + except asyncio.TimeoutError: + circuit_breaker.record_failure("Timeout") + raise APITimeoutError("Yahoo Finance", YAHOO_FINANCE_TIMEOUT) + + except Exception as e: + circuit_breaker.record_failure(str(e)) + raise + + # ========================================================================= + # STATUS + # ========================================================================= + + def get_status(self) -> Dict[str, Any]: + """Get fetcher service status.""" + return { + "circuit_breakers": { + name: { + "state": cb.state.value, + "failure_count": cb.failure_count, + "success_count": cb.success_count, + } + for name, cb in self._circuit_breakers.items() + }, + "rate_limiter": { + "sec_edgar": { + "available_tokens": self._sec_rate_limiter.tokens, + "capacity": self._sec_rate_limiter.capacity, + } + }, + "company_tickers_loaded": self._company_tickers is not None, + } + + +# Global fetcher instance +_fetcher_service: Optional[FetcherService] = None + + +def get_fetcher_service() -> FetcherService: + """Get or create the global fetcher service instance.""" + global _fetcher_service + if _fetcher_service is None: + _fetcher_service = FetcherService() + return _fetcher_service diff --git a/mcp-servers/fundamentals-basket/services/orchestrator.py b/mcp-servers/fundamentals-basket/services/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..789122f434676b9b3a602c3eb3f58a585d5210e5 --- /dev/null +++ b/mcp-servers/fundamentals-basket/services/orchestrator.py @@ -0,0 +1,654 @@ +""" +Orchestrator Service for Financials-Basket MCP Server + +Coordinates Fetcher, Parser, and Cache services: +- Request routing and tool execution +- Fallback chain: SEC EDGAR → Yahoo Finance → Minimal +- Timeout enforcement +- Response aggregation and SWOT compilation +""" + +import asyncio +import logging +from datetime import datetime +from typing import Optional, Dict, Any + +from config import TOOL_TIMEOUT +from models.schemas import ( + TemporalMetric, + ParsedFinancials, + DebtMetrics, + CashFlowMetrics, + SwotSummary, + FinancialsBasket, +) +from models.errors import ( + CIKNotFoundError, + APITimeoutError, + CircuitOpenError, +) +from services.cache import CacheService, get_cache_service +from services.fetcher import FetcherService, get_fetcher_service +from services.parser import ParserService, get_parser_service + +logger = logging.getLogger("fundamentals-basket.orchestrator") + + +class OrchestratorService: + """ + Orchestrator service for coordinating data flow. + + Features: + - 3-tier fallback chain (SEC EDGAR → Yahoo → Minimal) + - Guarantees 100% response rate + - Per-tool timeout enforcement + - Cache-first data retrieval + """ + + def __init__( + self, + cache: Optional[CacheService] = None, + fetcher: Optional[FetcherService] = None, + parser: Optional[ParserService] = None, + ): + self.cache = cache or get_cache_service() + self.fetcher = fetcher or get_fetcher_service() + self.parser = parser or get_parser_service() + + # ========================================================================= + # COMPANY INFO + # ========================================================================= + + async def get_company_info(self, ticker: str) -> Dict[str, Any]: + """ + Get company information. + + Args: + ticker: Stock ticker symbol + + Returns: + Company info dict with name, CIK, SIC, etc. + """ + ticker = ticker.upper() + + # Check cache first + cached = await self.cache.get_company_info(ticker) + if cached: + return cached + + # Get CIK + cik = await self._get_cik_with_cache(ticker) + if not cik: + return { + "ticker": ticker, + "error": "CIK not found", + "fallback": True, + } + + try: + # Fetch submissions + submissions = await self.fetcher.fetch_company_submissions(cik) + + info = { + "ticker": ticker, + "cik": cik, + "name": submissions.get("name"), + "sic": submissions.get("sic"), + "sic_description": submissions.get("sicDescription"), + "state_of_incorporation": submissions.get("stateOfIncorporation"), + "fiscal_year_end": submissions.get("fiscalYearEnd"), + "source": "SEC EDGAR", + } + + # Cache the result + await self.cache.set_company_info(ticker, info) + + return info + + except Exception as e: + logger.error(f"Failed to get company info for {ticker}: {e}") + return { + "ticker": ticker, + "error": str(e), + "fallback": True, + } + + # ========================================================================= + # FINANCIALS + # ========================================================================= + + async def get_financials(self, ticker: str) -> Dict[str, Any]: + """ + Get financial metrics. + + Args: + ticker: Stock ticker symbol + + Returns: + Financials dict with revenue, margins, etc. + """ + ticker = ticker.upper() + + # Get CIK + cik = await self._get_cik_with_cache(ticker) + if not cik: + # Fallback to Yahoo Finance + return await self._get_yfinance_financials(ticker) + + try: + # Fetch company facts + facts = await self._get_facts_with_cache(cik) + if not facts: + return await self._get_yfinance_financials(ticker) + + # Parse financials + financials = self.parser.parse_financials(facts, ticker) + return financials.to_dict() + + except (APITimeoutError, CircuitOpenError) as e: + logger.warning(f"SEC EDGAR failed for {ticker}, using Yahoo fallback: {e}") + return await self._get_yfinance_financials(ticker) + + except Exception as e: + logger.error(f"Failed to get financials for {ticker}: {e}") + return await self._get_yfinance_financials(ticker) + + async def get_debt_metrics(self, ticker: str) -> Dict[str, Any]: + """ + Get debt and leverage metrics. + + Args: + ticker: Stock ticker symbol + + Returns: + Debt metrics dict + """ + ticker = ticker.upper() + + cik = await self._get_cik_with_cache(ticker) + if not cik: + return {"ticker": ticker, "error": "CIK not found", "fallback": True} + + try: + facts = await self._get_facts_with_cache(cik) + if not facts: + return {"ticker": ticker, "error": "No facts available", "fallback": True} + + debt = self.parser.parse_debt_metrics(facts, ticker) + return debt.to_dict() + + except Exception as e: + logger.error(f"Failed to get debt metrics for {ticker}: {e}") + return {"ticker": ticker, "error": str(e), "fallback": True} + + async def get_cash_flow(self, ticker: str) -> Dict[str, Any]: + """ + Get cash flow metrics. + + Args: + ticker: Stock ticker symbol + + Returns: + Cash flow metrics dict + """ + ticker = ticker.upper() + + cik = await self._get_cik_with_cache(ticker) + if not cik: + return {"ticker": ticker, "error": "CIK not found", "fallback": True} + + try: + facts = await self._get_facts_with_cache(cik) + if not facts: + return {"ticker": ticker, "error": "No facts available", "fallback": True} + + cash_flow = self.parser.parse_cash_flow(facts, ticker) + return cash_flow.to_dict() + + except Exception as e: + logger.error(f"Failed to get cash flow for {ticker}: {e}") + return {"ticker": ticker, "error": str(e), "fallback": True} + + # ========================================================================= + # SEC FUNDAMENTALS BASKET (Main Aggregator) + # ========================================================================= + + async def get_sec_fundamentals_basket(self, ticker: str) -> Dict[str, Any]: + """ + Get complete SEC fundamentals basket with SWOT. + + This is the primary aggregator that: + 1. Fetches all data from SEC EDGAR + 2. Falls back to Yahoo Finance if SEC fails + 3. Falls back to minimal response if all fail + 4. Always generates a SWOT summary + + Args: + ticker: Stock ticker symbol + + Returns: + Complete financials basket with SWOT + """ + ticker = ticker.upper() + logger.info(f"Getting SEC fundamentals basket for {ticker}") + + # Try SEC EDGAR first + cik = await self._get_cik_with_cache(ticker) + + if cik: + try: + result = await self._get_sec_basket(ticker, cik) + if result and "error" not in result: + return result + except Exception as e: + logger.warning(f"SEC EDGAR failed for {ticker}: {e}") + + # Fallback to Yahoo Finance + try: + result = await self._get_yahoo_basket(ticker) + if result and "error" not in result: + return result + except Exception as e: + logger.warning(f"Yahoo Finance failed for {ticker}: {e}") + + # Final fallback - minimal response + return self._get_minimal_fallback(ticker) + + async def _get_sec_basket(self, ticker: str, cik: str) -> Dict[str, Any]: + """Get complete basket from SEC EDGAR.""" + # Fetch company facts + facts = await self._get_facts_with_cache(cik) + if not facts: + raise ValueError("No company facts available") + + # Parse all metrics + financials = self.parser.parse_financials(facts, ticker) + debt = self.parser.parse_debt_metrics(facts, ticker) + cash_flow = self.parser.parse_cash_flow(facts, ticker) + + # Build SWOT + swot = self.parser.build_swot_summary(financials, debt, cash_flow) + + # Get company info + company_info = await self.get_company_info(ticker) + + # Build basket + basket = FinancialsBasket( + ticker=ticker, + company=company_info, + financials=financials, + debt=debt, + cash_flow=cash_flow, + swot_summary=swot, + source="SEC EDGAR XBRL", + ) + + return basket.to_dict() + + async def _get_yahoo_basket(self, ticker: str) -> Dict[str, Any]: + """Get complete basket from Yahoo Finance (fallback).""" + # Fetch from Yahoo + data = await self.fetcher.fetch_yfinance(ticker) + + if "error" in data: + raise ValueError(data["error"]) + + # Parse Yahoo data + financials, debt, cash_flow = self.parser.parse_yfinance_data(data, ticker) + + # Build SWOT + swot = self.parser.build_swot_summary(financials, debt, cash_flow) + + # Build basket + basket = FinancialsBasket( + ticker=ticker, + company={ + "ticker": ticker, + "name": data.get("name"), + "sector": data.get("sector"), + "industry": data.get("industry"), + }, + financials=financials, + debt=debt, + cash_flow=cash_flow, + swot_summary=swot, + source="Yahoo Finance", + fallback=True, + fallback_reason="SEC EDGAR unavailable", + ) + + return basket.to_dict() + + def _get_minimal_fallback(self, ticker: str) -> Dict[str, Any]: + """ + Get minimal fallback response. + + This ALWAYS succeeds and returns a valid response structure. + """ + return { + "ticker": ticker.upper(), + "company": {"name": ticker.upper()}, + "financials": {"note": "Data temporarily unavailable"}, + "debt": {"note": "Data temporarily unavailable"}, + "cash_flow": {"note": "Data temporarily unavailable"}, + "swot_summary": { + "strengths": [], + "weaknesses": [], + "opportunities": [], + "threats": [], + "note": "SWOT unavailable - data sources temporarily unavailable", + }, + "source": "Minimal Fallback", + "fallback": True, + "fallback_reason": "All data sources unavailable", + "generated_at": datetime.now().strftime("%Y-%m-%d"), + } + + # ========================================================================= + # ALL SOURCES (Multi-Source Comparison) + # ========================================================================= + + async def get_all_sources_fundamentals(self, ticker: str) -> Dict[str, Any]: + """ + Get financials from ALL sources for comparison. + Returns NORMALIZED schema for source_comparison group. + + Fetches from SEC EDGAR and Yahoo Finance in parallel, + returning side-by-side comparison. + + Args: + ticker: Stock ticker symbol + + Returns: + Normalized source_comparison dict + """ + ticker = ticker.upper() + logger.info(f"Getting all sources financials for {ticker}") + + # Fetch from both sources in parallel + sec_task = self._get_sec_data_safe(ticker) + yahoo_task = self._get_yahoo_data_safe(ticker) + + sec_result, yahoo_result = await asyncio.gather(sec_task, yahoo_task) + + # Build normalized source_comparison schema + sources = {} + sec_failed = "error" in sec_result or not sec_result.get("data") + + # Add SEC EDGAR data if available + if not sec_failed: + sources["sec_edgar"] = { + "source": sec_result.get("source"), + "data": sec_result.get("data"), + } + + # Add Yahoo Finance data + if "error" not in yahoo_result: + if sec_failed: + # FALLBACK: Yahoo provides core + supplementary when SEC fails + yahoo_data = await self._get_yahoo_fallback_data(ticker) + if yahoo_data.get("data"): + sources["yahoo_finance"] = yahoo_data + elif yahoo_result.get("data"): + # SUPPLEMENTARY: Only additional metrics + sources["yahoo_finance"] = { + "source": yahoo_result.get("source"), + "data": yahoo_result.get("data"), + } + + return { + "group": "source_comparison", + "ticker": ticker, + "sources": sources, + "source": "fundamentals-basket", + "as_of": datetime.now().strftime("%Y-%m-%d"), + } + + async def _get_sec_data_safe(self, ticker: str) -> Dict[str, Any]: + """Get SEC data with error handling. Returns 6 universal metrics only.""" + try: + cik = await self._get_cik_with_cache(ticker) + if not cik: + return {"error": "CIK not found", "source": "SEC EDGAR"} + + facts = await self._get_facts_with_cache(cik) + if not facts: + return {"error": "No facts available", "source": "SEC EDGAR"} + + financials = self.parser.parse_financials(facts, ticker) + + # Helper to convert TemporalMetric to dict + def to_metric_dict(tm): + if tm is None: + return None + return { + "value": tm.value, + "end_date": tm.end_date, + "data_type": tm.data_type, + } + + # Only 6 universal metrics (works across all industries) + return { + "source": "SEC EDGAR XBRL", + "as_of": datetime.now().strftime("%Y-%m-%d"), + "data": { + "revenue": to_metric_dict(financials.revenue), + "net_income": to_metric_dict(financials.net_income), + "net_margin_pct": to_metric_dict(financials.net_margin_pct), + "total_assets": to_metric_dict(financials.total_assets), + "total_liabilities": to_metric_dict(financials.total_liabilities), + "stockholders_equity": to_metric_dict(financials.stockholders_equity), + }, + } + + except Exception as e: + logger.error(f"SEC data fetch failed for {ticker}: {e}") + return {"error": str(e), "source": "SEC EDGAR"} + + async def _get_yahoo_data_safe(self, ticker: str) -> Dict[str, Any]: + """Get Yahoo data with error handling. Returns supplementary metrics only.""" + try: + data = await self.fetcher.fetch_yfinance(ticker) + + if "error" in data: + return {"error": data["error"], "source": "Yahoo Finance"} + + financials, debt, cash_flow = self.parser.parse_yfinance_data(data, ticker) + + # Helper to convert TemporalMetric to dict + def to_metric_dict(tm): + if tm is None: + return None + return { + "value": tm.value, + "end_date": tm.end_date, + "data_type": tm.data_type, + } + + # Only supplementary metrics not in SEC EDGAR (avoid duplicates) + return { + "source": "Yahoo Finance", + "as_of": datetime.now().strftime("%Y-%m-%d"), + "data": { + "operating_margin_pct": to_metric_dict(financials.operating_margin_pct), + "total_debt": to_metric_dict(debt.total_debt) if hasattr(debt, 'total_debt') else None, + "operating_cash_flow": to_metric_dict(cash_flow.operating_cash_flow) if hasattr(cash_flow, 'operating_cash_flow') else None, + "free_cash_flow": to_metric_dict(cash_flow.free_cash_flow) if hasattr(cash_flow, 'free_cash_flow') else None, + }, + } + + except Exception as e: + logger.error(f"Yahoo data fetch failed for {ticker}: {e}") + return {"error": str(e), "source": "Yahoo Finance"} + + async def _get_yahoo_fallback_data(self, ticker: str) -> Dict[str, Any]: + """Get Yahoo data as fallback when SEC fails. Returns core + supplementary metrics.""" + try: + data = await self.fetcher.fetch_yfinance(ticker) + + if "error" in data: + return {"error": data["error"], "source": "Yahoo Finance"} + + financials, debt, cash_flow = self.parser.parse_yfinance_data(data, ticker) + + def to_metric_dict(tm): + if tm is None: + return None + return { + "value": tm.value, + "end_date": tm.end_date, + "data_type": tm.data_type, + } + + # FALLBACK: Core metrics + supplementary metrics + return { + "source": "Yahoo Finance", + "as_of": datetime.now().strftime("%Y-%m-%d"), + "data": { + # Core metrics (normally from SEC) + "revenue": to_metric_dict(financials.revenue), + "net_income": to_metric_dict(financials.net_income), + "net_margin_pct": to_metric_dict(financials.net_margin_pct), + "total_assets": to_metric_dict(debt.total_assets) if hasattr(debt, 'total_assets') else None, + # Supplementary metrics + "operating_margin_pct": to_metric_dict(financials.operating_margin_pct), + "total_debt": to_metric_dict(debt.total_debt) if hasattr(debt, 'total_debt') else None, + "operating_cash_flow": to_metric_dict(cash_flow.operating_cash_flow) if hasattr(cash_flow, 'operating_cash_flow') else None, + "free_cash_flow": to_metric_dict(cash_flow.free_cash_flow) if hasattr(cash_flow, 'free_cash_flow') else None, + }, + } + + except Exception as e: + logger.error(f"Yahoo fallback fetch failed for {ticker}: {e}") + return {"error": str(e), "source": "Yahoo Finance"} + + # ========================================================================= + # HELPER METHODS + # ========================================================================= + + async def _get_cik_with_cache(self, ticker: str) -> Optional[str]: + """Get CIK with caching.""" + ticker = ticker.upper() + + # Check cache + cached_cik = await self.cache.get_cik(ticker) + if cached_cik: + return cached_cik + + # Fetch from SEC + cik = await self.fetcher.fetch_cik(ticker) + if cik: + await self.cache.set_cik(ticker, cik) + + return cik + + async def _get_facts_with_cache(self, cik: str) -> Optional[Dict[str, Any]]: + """Get company facts with caching.""" + # Check cache + cached_facts = await self.cache.get_company_facts(cik) + if cached_facts: + return cached_facts + + # Fetch from SEC + try: + facts = await self.fetcher.fetch_company_facts(cik) + await self.cache.set_company_facts(cik, facts) + return facts + except Exception as e: + logger.error(f"Failed to fetch company facts for CIK {cik}: {e}") + return None + + async def _get_yfinance_financials(self, ticker: str) -> Dict[str, Any]: + """Get financials from Yahoo Finance (fallback).""" + try: + data = await self.fetcher.fetch_yfinance(ticker) + + if "error" in data: + return { + "ticker": ticker.upper(), + "error": data["error"], + "fallback": True, + } + + financials, _, _ = self.parser.parse_yfinance_data(data, ticker) + result = financials.to_dict() + result["fallback"] = True + result["fallback_reason"] = "SEC EDGAR unavailable" + return result + + except Exception as e: + return { + "ticker": ticker.upper(), + "error": str(e), + "fallback": True, + } + + # ========================================================================= + # TOOL EXECUTION + # ========================================================================= + + async def execute_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + """ + Execute a tool by name. + + This is the main entry point for MCP tool calls. + + Args: + name: Tool name + arguments: Tool arguments + + Returns: + Tool result dict + """ + ticker = arguments.get("ticker", "").upper() + + tool_handlers = { + "get_company_info": lambda: self.get_company_info(ticker), + "get_financials": lambda: self.get_financials(ticker), + "get_debt_metrics": lambda: self.get_debt_metrics(ticker), + "get_cash_flow": lambda: self.get_cash_flow(ticker), + "get_sec_fundamentals": lambda: self.get_sec_fundamentals_basket(ticker), + "get_all_sources_fundamentals": lambda: self.get_all_sources_fundamentals(ticker), + } + + handler = tool_handlers.get(name) + if not handler: + return {"error": f"Unknown tool: {name}"} + + try: + return await asyncio.wait_for(handler(), timeout=TOOL_TIMEOUT) + except asyncio.TimeoutError: + logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s for {ticker}") + return { + "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds", + "ticker": ticker, + "tool": name, + "fallback": True, + } + except Exception as e: + logger.error(f"Tool {name} failed for {ticker}: {e}") + return { + "error": str(e), + "ticker": ticker, + "tool": name, + "fallback": True, + } + + def get_status(self) -> Dict[str, Any]: + """Get orchestrator and service status.""" + return { + "cache": self.cache.get_stats(), + "fetcher": self.fetcher.get_status(), + } + + +# Global orchestrator instance +_orchestrator_service: Optional[OrchestratorService] = None + + +def get_orchestrator_service() -> OrchestratorService: + """Get or create the global orchestrator service instance.""" + global _orchestrator_service + if _orchestrator_service is None: + _orchestrator_service = OrchestratorService() + return _orchestrator_service diff --git a/mcp-servers/fundamentals-basket/services/parser.py b/mcp-servers/fundamentals-basket/services/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..a8c07c9f4ea866a2e593b1c337b3f550d607705e --- /dev/null +++ b/mcp-servers/fundamentals-basket/services/parser.py @@ -0,0 +1,713 @@ +""" +Parser Service for Financials-Basket MCP Server + +Handles XBRL data parsing, ratio calculations, and SWOT analysis: +- Extracts metrics from SEC EDGAR company facts +- Calculates margins, growth rates, and ratios +- Preserves temporal metadata (end_date, fiscal_year, form) +- Generates SWOT summaries based on thresholds +""" + +import logging +from datetime import datetime +from typing import Optional, Dict, Any, List, Tuple + +from config import ( + REVENUE_GROWTH_STRONG, + REVENUE_GROWTH_POSITIVE, + REVENUE_GROWTH_DECLINING, + NET_MARGIN_HIGH, + NET_MARGIN_HEALTHY, + NET_MARGIN_THIN, + NET_MARGIN_UNPROFITABLE, + OPERATING_MARGIN_STRONG, + DEBT_TO_EQUITY_HIGH, + DEBT_TO_EQUITY_ELEVATED, + DEBT_TO_EQUITY_LOW, + RD_HIGH_INVESTMENT, +) +from models.schemas import ( + TemporalMetric, + ParsedFinancials, + DebtMetrics, + CashFlowMetrics, + SwotSummary, +) + +logger = logging.getLogger("fundamentals-basket.parser") + + +# ============================================================================= +# XBRL CONCEPT MAPPINGS +# ============================================================================= + +# Revenue concepts (in order of preference - newer ASC 606 concept first) +REVENUE_CONCEPTS = [ + "RevenueFromContractWithCustomerExcludingAssessedTax", # ASC 606 (post-2018) + "Revenues", # Legacy concept + "SalesRevenueNet", + "TotalRevenuesAndOtherIncome", +] + +# Net income concepts +NET_INCOME_CONCEPTS = [ + "NetIncomeLoss", + "ProfitLoss", + "NetIncomeLossAvailableToCommonStockholdersBasic", +] + +# Gross profit concepts +GROSS_PROFIT_CONCEPTS = [ + "GrossProfit", +] + +# Operating income concepts +OPERATING_INCOME_CONCEPTS = [ + "OperatingIncomeLoss", + "IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest", +] + +# Asset concepts +TOTAL_ASSETS_CONCEPTS = ["Assets"] +TOTAL_LIABILITIES_CONCEPTS = ["Liabilities", "LiabilitiesAndStockholdersEquity"] +STOCKHOLDERS_EQUITY_CONCEPTS = [ + "StockholdersEquity", + "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest", +] + +# Debt concepts - include comprehensive concepts that may have more recent data +LONG_TERM_DEBT_CONCEPTS = [ + "LongTermDebtAndCapitalLeaseObligations", # Most comprehensive, often most recent + "LongTermDebt", + "LongTermDebtNoncurrent", +] +SHORT_TERM_DEBT_CONCEPTS = ["ShortTermBorrowings", "DebtCurrent"] +TOTAL_DEBT_CONCEPTS = ["DebtAndCapitalLeaseObligations", "LongTermDebtAndCapitalLeaseObligations", "DebtLongtermAndShorttermCombinedAmount"] + +# Cash concepts +CASH_CONCEPTS = [ + "CashAndCashEquivalentsAtCarryingValue", + "CashCashEquivalentsAndShortTermInvestments", + "Cash", +] + +# Cash flow concepts +OPERATING_CF_CONCEPTS = ["NetCashProvidedByUsedInOperatingActivities"] +CAPEX_CONCEPTS = ["PaymentsToAcquirePropertyPlantAndEquipment", "CapitalExpendituresIncurredButNotYetPaid"] +RD_CONCEPTS = ["ResearchAndDevelopmentExpense"] + + +class ParserService: + """ + Parser service for XBRL data extraction and analysis. + + Features: + - Multi-concept fallback for XBRL extraction + - Temporal metadata preservation + - Ratio and margin calculations + - SWOT analysis generation + """ + + # ========================================================================= + # XBRL VALUE EXTRACTION + # ========================================================================= + + def get_latest_value( + self, + facts: Dict[str, Any], + concepts: List[str], + unit: str = "USD", + form_filter: Optional[str] = "10-K" + ) -> Optional[TemporalMetric]: + """ + Extract the latest value for a concept from XBRL facts. + + Args: + facts: Company facts dict from SEC EDGAR + concepts: List of concept names to try (in order of preference) + unit: Unit type (USD, shares, etc.) + form_filter: Optional form filter (10-K for annual) + + Returns: + TemporalMetric with value and temporal metadata, or None if not found + """ + us_gaap = facts.get("facts", {}).get("us-gaap", {}) + + for concept in concepts: + concept_data = us_gaap.get(concept, {}) + units = concept_data.get("units", {}) + values = units.get(unit, []) + + if not values: + continue + + # Filter by form if specified + if form_filter: + values = [v for v in values if v.get("form") == form_filter] + + if not values: + continue + + # Sort by end date (descending) to get latest + values = sorted(values, key=lambda x: x.get("end", ""), reverse=True) + + if values: + latest = values[0] + form = latest.get("form") + # Determine data_type from form: 10-K = FY, 10-Q = Q + data_type = "FY" if form == "10-K" else "Q" if form == "10-Q" else None + return TemporalMetric( + value=latest.get("val"), + data_type=data_type, + end_date=latest.get("end"), + filed=latest.get("filed"), + fiscal_year=latest.get("fy"), + form=form, + ) + + return None + + def get_most_recent_across_concepts( + self, + facts: Dict[str, Any], + concepts: List[str], + unit: str = "USD", + form_filter: Optional[str] = "10-K" + ) -> Optional[TemporalMetric]: + """ + Get the value with the most recent end_date across all concepts. + + Unlike get_latest_value which returns the first concept found, + this method compares end_dates across ALL concepts and returns + the one with the most recent data. + + Args: + facts: Company facts dict from SEC EDGAR + concepts: List of concept names to check + unit: Unit type (USD, shares, etc.) + form_filter: Optional form filter (10-K for annual) + + Returns: + TemporalMetric with the most recent end_date, or None if not found + """ + candidates = [] + + for concept in concepts: + result = self.get_latest_value(facts, [concept], unit, form_filter) + if result and result.end_date: + candidates.append(result) + + if not candidates: + return None + + # Sort by end_date descending and return most recent + candidates.sort(key=lambda x: x.end_date or "", reverse=True) + return candidates[0] + + def get_values_for_growth( + self, + facts: Dict[str, Any], + concepts: List[str], + years: int = 3, + unit: str = "USD" + ) -> List[Tuple[int, float]]: + """ + Get historical values for growth calculation. + + Args: + facts: Company facts dict + concepts: List of concept names to try + years: Number of years to fetch + unit: Unit type + + Returns: + List of (fiscal_year, value) tuples, sorted by year ascending + """ + us_gaap = facts.get("facts", {}).get("us-gaap", {}) + results = {} + + for concept in concepts: + concept_data = us_gaap.get(concept, {}) + units = concept_data.get("units", {}) + values = units.get(unit, []) + + # Only 10-K filings for annual data + annual_values = [v for v in values if v.get("form") == "10-K"] + + for v in annual_values: + fy = v.get("fy") + val = v.get("val") + if fy and val and fy not in results: + results[fy] = val + + if results: + break # Found values for first matching concept + + # Sort by year and return last N years + sorted_years = sorted(results.items(), key=lambda x: x[0]) + return sorted_years[-(years + 1):] # Include one extra year for growth calc + + def calculate_growth( + self, + facts: Dict[str, Any], + concepts: List[str], + years: int = 3 + ) -> Optional[float]: + """ + Calculate compound annual growth rate (CAGR). + + Args: + facts: Company facts dict + concepts: List of concept names to try + years: Number of years for CAGR + + Returns: + CAGR as percentage, or None if insufficient data + """ + values = self.get_values_for_growth(facts, concepts, years) + + if len(values) < 2: + return None + + start_year, start_val = values[0] + end_year, end_val = values[-1] + + if start_val <= 0 or end_val <= 0: + return None + + years_diff = end_year - start_year + if years_diff <= 0: + return None + + # CAGR = (end/start)^(1/years) - 1 + cagr = ((end_val / start_val) ** (1 / years_diff) - 1) * 100 + return round(cagr, 2) + + # ========================================================================= + # TEMPORAL METRIC HELPER + # ========================================================================= + + def create_temporal_metric( + self, + value: Optional[float], + source_metric: Optional[TemporalMetric] + ) -> TemporalMetric: + """ + Create a temporal metric inheriting temporal data from source. + + Used for calculated values (margins, ratios) to preserve audit context. + + Args: + value: The calculated value + source_metric: Source metric to inherit temporal data from + + Returns: + TemporalMetric with value and inherited temporal data + """ + if source_metric: + return TemporalMetric( + value=value, + data_type=source_metric.data_type, + end_date=source_metric.end_date, + filed=source_metric.filed, + fiscal_year=source_metric.fiscal_year, + form=source_metric.form, + ) + return TemporalMetric(value=value) + + # ========================================================================= + # FINANCIALS PARSING + # ========================================================================= + + def parse_financials( + self, + facts: Dict[str, Any], + ticker: str + ) -> ParsedFinancials: + """ + Parse financial metrics from XBRL facts. + + Args: + facts: Company facts dict from SEC EDGAR + ticker: Stock ticker symbol + + Returns: + ParsedFinancials with all metrics + """ + # Extract core metrics + revenue = self.get_latest_value(facts, REVENUE_CONCEPTS) + net_income = self.get_latest_value(facts, NET_INCOME_CONCEPTS) + gross_profit = self.get_latest_value(facts, GROSS_PROFIT_CONCEPTS) + operating_income = self.get_latest_value(facts, OPERATING_INCOME_CONCEPTS) + total_assets = self.get_latest_value(facts, TOTAL_ASSETS_CONCEPTS) + total_liabilities = self.get_latest_value(facts, TOTAL_LIABILITIES_CONCEPTS) + stockholders_equity = self.get_latest_value(facts, STOCKHOLDERS_EQUITY_CONCEPTS) + + # Calculate margins + gross_margin_pct = None + operating_margin_pct = None + net_margin_pct = None + + if revenue and revenue.value and revenue.value > 0: + if gross_profit and gross_profit.value is not None: + gross_margin_pct = self.create_temporal_metric( + round((gross_profit.value / revenue.value) * 100, 2), + revenue + ) + + if operating_income and operating_income.value is not None: + operating_margin_pct = self.create_temporal_metric( + round((operating_income.value / revenue.value) * 100, 2), + revenue + ) + + if net_income and net_income.value is not None: + net_margin_pct = self.create_temporal_metric( + round((net_income.value / revenue.value) * 100, 2), + revenue + ) + + # Calculate growth and wrap in TemporalMetric + revenue_growth_val = self.calculate_growth(facts, REVENUE_CONCEPTS) + revenue_growth_3yr = None + if revenue_growth_val is not None: + revenue_growth_3yr = self.create_temporal_metric(revenue_growth_val, revenue) + + return ParsedFinancials( + ticker=ticker.upper(), + revenue=revenue, + net_income=net_income, + gross_profit=gross_profit, + operating_income=operating_income, + gross_margin_pct=gross_margin_pct, + operating_margin_pct=operating_margin_pct, + net_margin_pct=net_margin_pct, + revenue_growth_3yr=revenue_growth_3yr, + total_assets=total_assets, + total_liabilities=total_liabilities, + stockholders_equity=stockholders_equity, + source="SEC EDGAR XBRL", + ) + + def parse_debt_metrics( + self, + facts: Dict[str, Any], + ticker: str + ) -> DebtMetrics: + """ + Parse debt and leverage metrics from XBRL facts. + + Args: + facts: Company facts dict from SEC EDGAR + ticker: Stock ticker symbol + + Returns: + DebtMetrics with all debt-related metrics + """ + # Use get_most_recent_across_concepts for debt to ensure freshest data + long_term_debt = self.get_most_recent_across_concepts(facts, LONG_TERM_DEBT_CONCEPTS) + short_term_debt = self.get_latest_value(facts, SHORT_TERM_DEBT_CONCEPTS) + cash = self.get_latest_value(facts, CASH_CONCEPTS) + stockholders_equity = self.get_latest_value(facts, STOCKHOLDERS_EQUITY_CONCEPTS) + + # Calculate total debt - use get_most_recent_across_concepts for freshest data + total_debt_val = None + total_debt = self.get_most_recent_across_concepts(facts, TOTAL_DEBT_CONCEPTS) + if not total_debt: + lt_val = long_term_debt.value if long_term_debt else 0 + st_val = short_term_debt.value if short_term_debt else 0 + if lt_val or st_val: + total_debt_val = (lt_val or 0) + (st_val or 0) + total_debt = self.create_temporal_metric( + total_debt_val, + long_term_debt or short_term_debt + ) + + # Calculate net debt + net_debt = None + if total_debt and total_debt.value is not None and cash and cash.value is not None: + net_debt = self.create_temporal_metric( + total_debt.value - cash.value, + total_debt + ) + + # Calculate debt to equity + debt_to_equity = None + if total_debt and total_debt.value and stockholders_equity and stockholders_equity.value: + if stockholders_equity.value > 0: + debt_to_equity = self.create_temporal_metric( + round(total_debt.value / stockholders_equity.value, 2), + total_debt + ) + + return DebtMetrics( + ticker=ticker.upper(), + long_term_debt=long_term_debt, + short_term_debt=short_term_debt, + total_debt=total_debt, + cash=cash, + net_debt=net_debt, + debt_to_equity=debt_to_equity, + source="SEC EDGAR XBRL", + ) + + def parse_cash_flow( + self, + facts: Dict[str, Any], + ticker: str + ) -> CashFlowMetrics: + """ + Parse cash flow metrics from XBRL facts. + + Args: + facts: Company facts dict from SEC EDGAR + ticker: Stock ticker symbol + + Returns: + CashFlowMetrics with all cash flow metrics + """ + operating_cf = self.get_latest_value(facts, OPERATING_CF_CONCEPTS) + capex = self.get_latest_value(facts, CAPEX_CONCEPTS) + rd_expense = self.get_latest_value(facts, RD_CONCEPTS) + + # Calculate free cash flow + free_cash_flow = None + if operating_cf and operating_cf.value is not None: + capex_val = capex.value if capex and capex.value else 0 + free_cash_flow = self.create_temporal_metric( + operating_cf.value - abs(capex_val), + operating_cf + ) + + return CashFlowMetrics( + ticker=ticker.upper(), + operating_cash_flow=operating_cf, + capital_expenditure=capex, + free_cash_flow=free_cash_flow, + rd_expense=rd_expense, + source="SEC EDGAR XBRL", + ) + + # ========================================================================= + # YAHOO FINANCE PARSING + # ========================================================================= + + def parse_yfinance_data( + self, + data: Dict[str, Any], + ticker: str + ) -> Tuple[ParsedFinancials, DebtMetrics, CashFlowMetrics]: + """ + Parse Yahoo Finance data into structured metrics. + + Args: + data: Raw yfinance data dict + ticker: Stock ticker symbol + + Returns: + Tuple of (ParsedFinancials, DebtMetrics, CashFlowMetrics) + """ + # Extract temporal fields from Yahoo Finance + most_recent_quarter = data.get("most_recent_quarter") # Period end for financials + regular_market_time = data.get("regular_market_time") # Last updated time + + # Income statement items - TTM (Trailing Twelve Months) + revenue = TemporalMetric( + value=data.get("revenue"), + data_type="TTM", + end_date=most_recent_quarter, + filed=regular_market_time + ) if data.get("revenue") else None + net_income = TemporalMetric( + value=data.get("net_income"), + data_type="TTM", + end_date=most_recent_quarter, + filed=regular_market_time + ) if data.get("net_income") else None + gross_profit = TemporalMetric( + value=data.get("gross_profit"), + data_type="TTM", + end_date=most_recent_quarter, + filed=regular_market_time + ) if data.get("gross_profit") else None + operating_income = TemporalMetric( + value=data.get("operating_income"), + data_type="TTM", + end_date=most_recent_quarter, + filed=regular_market_time + ) if data.get("operating_income") else None + + # Calculate margins - TTM + gross_margin_pct = None + operating_margin_pct = None + net_margin_pct = None + + if revenue and revenue.value and revenue.value > 0: + if gross_profit and gross_profit.value: + gross_margin_pct = TemporalMetric( + value=round((gross_profit.value / revenue.value) * 100, 2), + data_type="TTM", + end_date=most_recent_quarter, + filed=regular_market_time + ) + if operating_income and operating_income.value: + operating_margin_pct = TemporalMetric( + value=round((operating_income.value / revenue.value) * 100, 2), + data_type="TTM", + end_date=most_recent_quarter, + filed=regular_market_time + ) + if net_income and net_income.value: + net_margin_pct = TemporalMetric( + value=round((net_income.value / revenue.value) * 100, 2), + data_type="TTM", + end_date=most_recent_quarter, + filed=regular_market_time + ) + + # Balance sheet items - Point-in-time + financials = ParsedFinancials( + ticker=ticker.upper(), + revenue=revenue, + net_income=net_income, + gross_profit=gross_profit, + operating_income=operating_income, + gross_margin_pct=gross_margin_pct, + operating_margin_pct=operating_margin_pct, + net_margin_pct=net_margin_pct, + total_assets=TemporalMetric(value=data.get("total_assets"), data_type="Point-in-time", end_date=most_recent_quarter, filed=regular_market_time) if data.get("total_assets") else None, + total_liabilities=TemporalMetric(value=data.get("total_liabilities"), data_type="Point-in-time", end_date=most_recent_quarter, filed=regular_market_time) if data.get("total_liabilities") else None, + stockholders_equity=TemporalMetric(value=data.get("stockholders_equity"), data_type="Point-in-time", end_date=most_recent_quarter, filed=regular_market_time) if data.get("stockholders_equity") else None, + source="Yahoo Finance", + ) + + # Debt - Point-in-time (balance sheet) + total_debt = TemporalMetric(value=data.get("total_debt"), data_type="Point-in-time", end_date=most_recent_quarter, filed=regular_market_time) if data.get("total_debt") else None + cash = TemporalMetric(value=data.get("cash"), data_type="Point-in-time", end_date=most_recent_quarter, filed=regular_market_time) if data.get("cash") else None + + net_debt = None + if total_debt and total_debt.value and cash and cash.value: + net_debt = TemporalMetric(value=total_debt.value - cash.value, data_type="Point-in-time", end_date=most_recent_quarter, filed=regular_market_time) + + debt_to_equity = None + equity_val = data.get("stockholders_equity") + if total_debt and total_debt.value and equity_val and equity_val > 0: + debt_to_equity = TemporalMetric(value=round(total_debt.value / equity_val, 2), data_type="Point-in-time", end_date=most_recent_quarter, filed=regular_market_time) + + debt = DebtMetrics( + ticker=ticker.upper(), + total_debt=total_debt, + cash=cash, + net_debt=net_debt, + debt_to_equity=debt_to_equity, + source="Yahoo Finance", + ) + + # Cash flow - TTM + operating_cf = TemporalMetric(value=data.get("operating_cash_flow"), data_type="TTM", end_date=most_recent_quarter, filed=regular_market_time) if data.get("operating_cash_flow") else None + free_cf = TemporalMetric(value=data.get("free_cash_flow"), data_type="TTM", end_date=most_recent_quarter, filed=regular_market_time) if data.get("free_cash_flow") else None + + cash_flow = CashFlowMetrics( + ticker=ticker.upper(), + operating_cash_flow=operating_cf, + free_cash_flow=free_cf, + source="Yahoo Finance", + ) + + return financials, debt, cash_flow + + # ========================================================================= + # SWOT ANALYSIS + # ========================================================================= + + def build_swot_summary( + self, + financials: ParsedFinancials, + debt: DebtMetrics, + cash_flow: CashFlowMetrics + ) -> SwotSummary: + """ + Build SWOT summary from financial metrics. + + Args: + financials: Parsed financial metrics + debt: Debt metrics + cash_flow: Cash flow metrics + + Returns: + SwotSummary with categorized insights + """ + strengths = [] + weaknesses = [] + opportunities = [] + threats = [] + + # Revenue growth analysis + if financials.revenue_growth_3yr is not None: + growth = financials.revenue_growth_3yr + if growth > REVENUE_GROWTH_STRONG: + strengths.append(f"Strong revenue growth: {growth:.1f}% 3-year CAGR") + elif growth > REVENUE_GROWTH_POSITIVE: + strengths.append(f"Positive revenue growth: {growth:.1f}% 3-year CAGR") + elif growth < REVENUE_GROWTH_DECLINING: + weaknesses.append(f"Declining revenue: {growth:.1f}% 3-year CAGR") + + # Net margin analysis + if financials.net_margin_pct and financials.net_margin_pct.value is not None: + margin = financials.net_margin_pct.value + if margin > NET_MARGIN_HIGH: + strengths.append(f"High profitability: {margin:.1f}% net margin") + elif margin < NET_MARGIN_UNPROFITABLE: + weaknesses.append(f"Unprofitable: {margin:.1f}% net margin") + elif margin < NET_MARGIN_THIN: + weaknesses.append(f"Thin margins: {margin:.1f}% net margin") + + # Operating margin analysis + if financials.operating_margin_pct and financials.operating_margin_pct.value is not None: + op_margin = financials.operating_margin_pct.value + if op_margin > OPERATING_MARGIN_STRONG: + strengths.append(f"Strong operating efficiency: {op_margin:.1f}% operating margin") + + # Debt analysis + if debt.debt_to_equity and debt.debt_to_equity.value is not None: + de_ratio = debt.debt_to_equity.value + if de_ratio > DEBT_TO_EQUITY_HIGH: + threats.append(f"High leverage: {de_ratio:.2f}x debt-to-equity ratio") + elif de_ratio > DEBT_TO_EQUITY_ELEVATED: + weaknesses.append(f"Elevated debt: {de_ratio:.2f}x debt-to-equity ratio") + elif de_ratio < DEBT_TO_EQUITY_LOW: + strengths.append(f"Low leverage: {de_ratio:.2f}x debt-to-equity ratio") + + # Cash flow analysis + if cash_flow.free_cash_flow and cash_flow.free_cash_flow.value is not None: + fcf = cash_flow.free_cash_flow.value + if fcf > 0: + strengths.append(f"Positive free cash flow: ${fcf / 1e9:.1f}B") + else: + weaknesses.append(f"Negative free cash flow: ${fcf / 1e9:.1f}B") + + # R&D analysis (opportunity indicator) + if cash_flow.rd_expense and cash_flow.rd_expense.value: + if financials.revenue and financials.revenue.value and financials.revenue.value > 0: + rd_pct = (cash_flow.rd_expense.value / financials.revenue.value) * 100 + if rd_pct > RD_HIGH_INVESTMENT: + opportunities.append(f"High R&D investment: {rd_pct:.1f}% of revenue") + + return SwotSummary( + strengths=strengths, + weaknesses=weaknesses, + opportunities=opportunities, + threats=threats, + ) + + +# Global parser instance +_parser_service: Optional[ParserService] = None + + +def get_parser_service() -> ParserService: + """Get or create the global parser service instance.""" + global _parser_service + if _parser_service is None: + _parser_service = ParserService() + return _parser_service diff --git a/mcp-servers/fundamentals-basket/start_cluster.sh b/mcp-servers/fundamentals-basket/start_cluster.sh new file mode 100755 index 0000000000000000000000000000000000000000..79ef1d23a8601538ade0c2a17120bc0c64e7a734 --- /dev/null +++ b/mcp-servers/fundamentals-basket/start_cluster.sh @@ -0,0 +1,231 @@ +#!/bin/bash +# +# Financials Basket Cluster Startup Script +# +# Starts 3 instances of the HTTP server behind nginx load balancer. +# +# Usage: +# ./start_cluster.sh # Start cluster +# ./start_cluster.sh stop # Stop cluster +# ./start_cluster.sh status # Check status +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Configuration +PORTS=(8001 8002 8003) +NGINX_PORT=8080 +PID_DIR="/tmp/financials-cluster" +LOG_DIR="/tmp/financials-cluster/logs" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Create directories +mkdir -p "$PID_DIR" "$LOG_DIR" + +start_instance() { + local port=$1 + local instance_id="financials-$port" + local pid_file="$PID_DIR/$instance_id.pid" + local log_file="$LOG_DIR/$instance_id.log" + + if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then + echo -e "${YELLOW}Instance $instance_id already running (PID: $(cat "$pid_file"))${NC}" + return 0 + fi + + echo -e "${GREEN}Starting $instance_id on port $port...${NC}" + + INSTANCE_ID="$instance_id" \ + HTTP_PORT="$port" \ + nohup python3 -m uvicorn http_server:app \ + --host 0.0.0.0 \ + --port "$port" \ + --log-level info \ + > "$log_file" 2>&1 & + + echo $! > "$pid_file" + echo -e "${GREEN} Started (PID: $!)${NC}" +} + +stop_instance() { + local port=$1 + local instance_id="financials-$port" + local pid_file="$PID_DIR/$instance_id.pid" + + if [ -f "$pid_file" ]; then + local pid=$(cat "$pid_file") + if kill -0 "$pid" 2>/dev/null; then + echo -e "${YELLOW}Stopping $instance_id (PID: $pid)...${NC}" + kill "$pid" 2>/dev/null || true + rm -f "$pid_file" + else + echo -e "${RED}$instance_id not running (stale PID file)${NC}" + rm -f "$pid_file" + fi + else + echo -e "${RED}$instance_id not running (no PID file)${NC}" + fi +} + +start_nginx() { + local pid_file="$PID_DIR/nginx.pid" + + if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then + echo -e "${YELLOW}nginx already running (PID: $(cat "$pid_file"))${NC}" + return 0 + fi + + echo -e "${GREEN}Starting nginx load balancer on port $NGINX_PORT...${NC}" + + # Check if nginx is available + if ! command -v nginx &> /dev/null; then + echo -e "${RED}nginx not found. Please install nginx.${NC}" + echo -e "${YELLOW}You can still access instances directly on ports: ${PORTS[*]}${NC}" + return 1 + fi + + # Start nginx with our config + nginx -c "$SCRIPT_DIR/nginx.conf" -g "pid $pid_file;" + echo -e "${GREEN} Started nginx${NC}" +} + +stop_nginx() { + local pid_file="$PID_DIR/nginx.pid" + + if [ -f "$pid_file" ]; then + local pid=$(cat "$pid_file") + if kill -0 "$pid" 2>/dev/null; then + echo -e "${YELLOW}Stopping nginx (PID: $pid)...${NC}" + kill "$pid" 2>/dev/null || true + rm -f "$pid_file" + else + rm -f "$pid_file" + fi + fi +} + +check_status() { + echo -e "\n${GREEN}=== Financials Cluster Status ===${NC}\n" + + # Check instances + for port in "${PORTS[@]}"; do + local instance_id="financials-$port" + local pid_file="$PID_DIR/$instance_id.pid" + + if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then + # Check if responding + if curl -s "http://127.0.0.1:$port/health" > /dev/null 2>&1; then + echo -e "${GREEN}[OK]${NC} $instance_id (port $port) - responding" + else + echo -e "${YELLOW}[STARTING]${NC} $instance_id (port $port) - process running, not responding yet" + fi + else + echo -e "${RED}[DOWN]${NC} $instance_id (port $port)" + fi + done + + # Check nginx + local nginx_pid_file="$PID_DIR/nginx.pid" + if [ -f "$nginx_pid_file" ] && kill -0 "$(cat "$nginx_pid_file")" 2>/dev/null; then + if curl -s "http://127.0.0.1:$NGINX_PORT/health" > /dev/null 2>&1; then + echo -e "${GREEN}[OK]${NC} nginx (port $NGINX_PORT) - load balancer responding" + else + echo -e "${YELLOW}[STARTING]${NC} nginx (port $NGINX_PORT) - process running" + fi + else + echo -e "${RED}[DOWN]${NC} nginx (port $NGINX_PORT)" + fi + + echo "" +} + +wait_for_ready() { + echo -e "\n${YELLOW}Waiting for instances to be ready...${NC}" + + local max_wait=30 + local wait_time=0 + + while [ $wait_time -lt $max_wait ]; do + local ready=0 + for port in "${PORTS[@]}"; do + if curl -s "http://127.0.0.1:$port/health" > /dev/null 2>&1; then + ((ready++)) + fi + done + + if [ $ready -eq ${#PORTS[@]} ]; then + echo -e "${GREEN}All instances ready!${NC}" + return 0 + fi + + echo -e " $ready/${#PORTS[@]} instances ready..." + sleep 2 + ((wait_time+=2)) + done + + echo -e "${YELLOW}Timeout waiting for all instances (some may still be starting)${NC}" +} + +start_cluster() { + echo -e "\n${GREEN}=== Starting Financials Cluster ===${NC}\n" + + # Start instances + for port in "${PORTS[@]}"; do + start_instance "$port" + done + + # Wait for instances to be ready + wait_for_ready + + # Start nginx + start_nginx + + echo -e "\n${GREEN}=== Cluster Started ===${NC}" + echo -e "Load balancer: http://127.0.0.1:$NGINX_PORT" + echo -e "Instances: ${PORTS[*]}" + echo "" +} + +stop_cluster() { + echo -e "\n${YELLOW}=== Stopping Financials Cluster ===${NC}\n" + + # Stop nginx first + stop_nginx + + # Stop instances + for port in "${PORTS[@]}"; do + stop_instance "$port" + done + + echo -e "\n${GREEN}Cluster stopped${NC}\n" +} + +# Main +case "${1:-start}" in + start) + start_cluster + ;; + stop) + stop_cluster + ;; + restart) + stop_cluster + sleep 2 + start_cluster + ;; + status) + check_status + ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac diff --git a/mcp-servers/financials-basket/test_fetchers.py b/mcp-servers/fundamentals-basket/test_fetchers.py similarity index 100% rename from mcp-servers/financials-basket/test_fetchers.py rename to mcp-servers/fundamentals-basket/test_fetchers.py diff --git a/mcp-servers/macro-basket/fetchers.py b/mcp-servers/macro-basket/fetchers.py index a050217f1dbb7739478c1f74ec92dd33babc7a62..b74606dce9cd3f4dd716b0ba15b609be96798b2d 100644 --- a/mcp-servers/macro-basket/fetchers.py +++ b/mcp-servers/macro-basket/fetchers.py @@ -156,7 +156,7 @@ async def fetch_gdp_growth() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -210,7 +210,7 @@ async def fetch_interest_rates() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -298,7 +298,7 @@ async def fetch_cpi() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": "FRED (Federal Reserve)", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -352,7 +352,7 @@ async def fetch_unemployment() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -412,5 +412,5 @@ async def get_full_macro_basket() -> dict: }, "overall_assessment": overall, "swot_summary": swot_summary, - "generated_at": datetime.now().isoformat() + "generated_at": datetime.now().strftime("%Y-%m-%d") } diff --git a/mcp-servers/macro-basket/server.py b/mcp-servers/macro-basket/server.py index 029d993ecc1108b1e5fe341f6a9dd943a5b26bf1..03304c430d50f3f9daa0eef050f85018ff3176ce 100644 --- a/mcp-servers/macro-basket/server.py +++ b/mcp-servers/macro-basket/server.py @@ -59,6 +59,99 @@ FRED_SERIES = { "unemployment": "UNRATE", # Unemployment Rate } +# BEA API configuration (Primary for GDP) +# Get free key at: https://apps.bea.gov/api/signup/ +BEA_API_KEY = os.getenv("BEA_API_KEY") +BEA_BASE_URL = "https://apps.bea.gov/api/data" + +# BLS API configuration (Primary for CPI, Unemployment) +# v1 = no key needed (25 series/request), v2 = key for better limits +# Get free key at: https://data.bls.gov/registrationEngine/ +BLS_API_KEY = os.getenv("BLS_API_KEY") # Optional for v2 +BLS_BASE_URL = "https://api.bls.gov/publicAPI/v2/timeseries/data/" +BLS_SERIES = { + "cpi": "CUUR0000SA0", # CPI-U All items (same as FRED CPIAUCSL) + "unemployment": "LNS14000000" # Unemployment rate (same as FRED UNRATE) +} + + +# ============================================================ +# FALLBACK DEFAULTS (Historical Averages) +# ============================================================ + +def get_default_gdp_growth() -> dict: + """Return reasonable GDP growth default when FRED fails.""" + return { + "metric": "GDP Growth", + "value": 2.5, # US long-term average ~2.5% + "unit": "% change (quarterly, annualized)", + "date": None, + "previous_value": None, + "interpretation": "Moderate growth - Stable economic conditions (estimated)", + "swot_category": "NEUTRAL", + "source": "Historical Average (estimated)", + "fallback": True, + "fallback_reason": "FRED API unavailable", + "estimated": True, + "as_of": datetime.now().strftime("%Y-%m-%d") + } + + +def get_default_interest_rate() -> dict: + """Return reasonable interest rate default when FRED fails.""" + return { + "metric": "Federal Funds Rate", + "value": 5.0, # Recent elevated rates + "unit": "%", + "date": None, + "previous_value": None, + "trend": "stable", + "interpretation": "High interest rates - Tight monetary policy (estimated)", + "swot_category": "NEUTRAL", + "source": "Historical Average (estimated)", + "fallback": True, + "fallback_reason": "FRED API unavailable", + "estimated": True, + "as_of": datetime.now().strftime("%Y-%m-%d") + } + + +def get_default_cpi() -> dict: + """Return reasonable CPI/inflation default when FRED fails.""" + return { + "metric": "CPI / Inflation", + "value": 3.0, # Recent inflation rate + "unit": "% YoY", + "date": None, + "fed_target": 2.0, + "interpretation": "Moderate inflation - Near Fed target (estimated)", + "swot_category": "NEUTRAL", + "source": "Historical Average (estimated)", + "fallback": True, + "fallback_reason": "FRED API unavailable", + "estimated": True, + "as_of": datetime.now().strftime("%Y-%m-%d") + } + + +def get_default_unemployment() -> dict: + """Return reasonable unemployment default when FRED fails.""" + return { + "metric": "Unemployment Rate", + "value": 4.0, # Near historical average + "unit": "%", + "date": None, + "previous_value": None, + "trend": "stable", + "interpretation": "Low unemployment - Tight labor market (estimated)", + "swot_category": "OPPORTUNITY", + "source": "Historical Average (estimated)", + "fallback": True, + "fallback_reason": "FRED API unavailable", + "estimated": True, + "as_of": datetime.now().strftime("%Y-%m-%d") + } + # ============================================================ # FRED DATA FETCHERS @@ -142,11 +235,13 @@ async def fetch_gdp_growth() -> dict: """ Fetch GDP growth rate from FRED. Indicates economic expansion or contraction. + Uses fallback defaults if FRED unavailable. """ data = await fetch_fred_series(FRED_SERIES["gdp_growth"], limit=8) if "error" in data: - return {"metric": "GDP Growth", **data} + logger.info("FRED GDP fetch failed, using default values") + return get_default_gdp_growth() value = data["latest_value"] @@ -179,7 +274,7 @@ async def fetch_gdp_growth() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -187,11 +282,13 @@ async def fetch_interest_rates() -> dict: """ Fetch Federal Funds Rate from FRED. Indicates cost of borrowing and monetary policy stance. + Uses fallback defaults if FRED unavailable. """ data = await fetch_fred_series(FRED_SERIES["interest_rate"], limit=12) if "error" in data: - return {"metric": "Interest Rate", **data} + logger.info("FRED interest rate fetch failed, using default values") + return get_default_interest_rate() value = data["latest_value"] previous = data["previous_value"] @@ -233,23 +330,26 @@ async def fetch_interest_rates() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } async def fetch_cpi() -> dict: """ Fetch Consumer Price Index and calculate year-over-year inflation. + Uses fallback defaults if FRED unavailable. """ data = await fetch_fred_series(FRED_SERIES["cpi"], limit=13) # Need 13 months for YoY if "error" in data: - return {"metric": "CPI / Inflation", **data} + logger.info("FRED CPI fetch failed, using default values") + return get_default_cpi() # For CPI, we need to calculate YoY change # Fetch full series to calculate properly if not FRED_API_KEY: - return {"metric": "CPI / Inflation", "error": "FRED_API_KEY required"} + logger.info("FRED API key missing for CPI, using default values") + return get_default_cpi() try: async with httpx.AsyncClient() as client: @@ -289,8 +389,7 @@ async def fetch_cpi() -> dict: except Exception as e: logger.error(f"CPI calculation error: {e}") - yoy_inflation = None - current_date = None + return get_default_cpi() # Inflation interpretation if yoy_inflation is None: @@ -321,7 +420,7 @@ async def fetch_cpi() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": "FRED (Federal Reserve)", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -329,11 +428,13 @@ async def fetch_unemployment() -> dict: """ Fetch unemployment rate from FRED. Indicates labor market health. + Uses fallback defaults if FRED unavailable. """ data = await fetch_fred_series(FRED_SERIES["unemployment"], limit=12) if "error" in data: - return {"metric": "Unemployment", **data} + logger.info("FRED unemployment fetch failed, using default values") + return get_default_unemployment() value = data["latest_value"] previous = data["previous_value"] @@ -375,7 +476,378 @@ async def fetch_unemployment() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") + } + + +# ============================================================ +# BEA DATA FETCHERS (Primary for GDP) +# ============================================================ + +async def fetch_bea_gdp() -> dict: + """ + Fetch GDP growth rate from BEA NIPA dataset. + Primary source - publishes before FRED syncs. + + API: https://apps.bea.gov/api/data/ + Dataset: NIPA, TableName: T10101 (GDP Percent Change) + """ + if not BEA_API_KEY: + return { + "error": "BEA_API_KEY not configured", + "message": "Add BEA_API_KEY to ~/.env file. Get free key at https://apps.bea.gov/api/signup/" + } + + try: + async with httpx.AsyncClient() as client: + # Fetch GDP percent change from NIPA Table 1.1.1 + params = { + "UserID": BEA_API_KEY, + "method": "GetData", + "datasetname": "NIPA", + "TableName": "T10101", # Percent Change From Preceding Period in Real GDP + "Frequency": "Q", # Quarterly + "Year": "X", # All recent years + "ResultFormat": "JSON" + } + + response = await client.get(BEA_BASE_URL, params=params, timeout=15) + data = response.json() + + if "error" in str(data).lower(): + return {"error": f"BEA API error: {data}"} + + results = data.get("BEAAPI", {}).get("Results", {}) + data_rows = results.get("Data", []) + + if not data_rows: + return {"error": "No GDP data returned from BEA"} + + # Find the most recent GDP growth rate (Line 1 = Real GDP) + gdp_rows = [r for r in data_rows if r.get("LineNumber") == "1"] + + if not gdp_rows: + return {"error": "No Real GDP data found in BEA response"} + + # Sort by TimePeriod (e.g., "2025Q3") descending + gdp_rows.sort(key=lambda x: x.get("TimePeriod", ""), reverse=True) + + latest = gdp_rows[0] + value = float(latest.get("DataValue", 0)) + time_period = latest.get("TimePeriod", "") # e.g., "2025Q3" + + # Get previous quarter for comparison + previous_value = None + if len(gdp_rows) > 1: + previous_value = float(gdp_rows[1].get("DataValue", 0)) + + # GDP interpretation + if value > 3: + interpretation = "Strong economic growth - Favorable business environment" + swot_impact = "OPPORTUNITY" + elif value > 1: + interpretation = "Moderate growth - Stable economic conditions" + swot_impact = "NEUTRAL" + elif value > 0: + interpretation = "Slow growth - Cautious economic outlook" + swot_impact = "THREAT" + elif value > -2: + interpretation = "Economic contraction - Recessionary conditions" + swot_impact = "THREAT" + else: + interpretation = "Severe contraction - Deep recession" + swot_impact = "SEVERE_THREAT" + + return { + "metric": "GDP Growth", + "value": round(value, 2), + "unit": "% change (quarterly, annualized)", + "date": time_period, + "previous_value": round(previous_value, 2) if previous_value else None, + "interpretation": interpretation, + "swot_category": swot_impact, + "source": "BEA (Bureau of Economic Analysis)", + "as_of": datetime.now().strftime("%Y-%m-%d") + } + + except Exception as e: + logger.error(f"BEA GDP fetch error: {e}") + return {"error": str(e)} + + +# ============================================================ +# BLS DATA FETCHERS (Primary for CPI, Unemployment) +# ============================================================ + +async def fetch_bls_series(series_ids: list, start_year: int = None, end_year: int = None) -> dict: + """ + Fetch data from BLS API for given series. + + Args: + series_ids: List of BLS series IDs + start_year: Start year (default: current year - 2) + end_year: End year (default: current year) + """ + current_year = datetime.now().year + if not start_year: + start_year = current_year - 2 + if not end_year: + end_year = current_year + + try: + async with httpx.AsyncClient() as client: + # BLS API requires POST with JSON payload + payload = { + "seriesid": series_ids, + "startyear": str(start_year), + "endyear": str(end_year) + } + + # Add API key if available (for v2 with higher limits) + if BLS_API_KEY: + payload["registrationkey"] = BLS_API_KEY + + headers = {"Content-Type": "application/json"} + response = await client.post(BLS_BASE_URL, json=payload, headers=headers, timeout=15) + data = response.json() + + if data.get("status") != "REQUEST_SUCCEEDED": + return {"error": f"BLS API error: {data.get('message', 'Unknown error')}"} + + return data + + except Exception as e: + logger.error(f"BLS fetch error: {e}") + return {"error": str(e)} + + +async def fetch_bls_cpi() -> dict: + """ + Fetch CPI from BLS (primary source). + Series: CUUR0000SA0 (CPI-U All items) + """ + data = await fetch_bls_series([BLS_SERIES["cpi"]]) + + if "error" in data: + return data + + try: + series_data = data.get("Results", {}).get("series", []) + if not series_data: + return {"error": "No CPI series data from BLS"} + + cpi_data = series_data[0].get("data", []) + if not cpi_data: + return {"error": "No CPI observations from BLS"} + + # BLS data is sorted newest first + # Get current and year-ago values for YoY calculation + current = cpi_data[0] + current_value = float(current.get("value", 0)) + current_period = f"{current.get('year')}-{current.get('periodName', current.get('period', ''))}" + + # Find year-ago value (12 months back) + year_ago_value = None + for obs in cpi_data: + if obs.get("year") == str(int(current.get("year")) - 1) and obs.get("period") == current.get("period"): + year_ago_value = float(obs.get("value", 0)) + break + + # Calculate YoY inflation + if year_ago_value and year_ago_value > 0: + yoy_inflation = ((current_value - year_ago_value) / year_ago_value) * 100 + else: + # Fallback: use monthly change annualized + if len(cpi_data) > 1: + prev_value = float(cpi_data[1].get("value", 0)) + if prev_value > 0: + monthly_change = (current_value - prev_value) / prev_value + yoy_inflation = monthly_change * 12 * 100 + else: + yoy_inflation = None + else: + yoy_inflation = None + + # Inflation interpretation + if yoy_inflation is None: + interpretation = "Data unavailable" + swot_impact = "NEUTRAL" + elif yoy_inflation > 6: + interpretation = "High inflation - Eroding purchasing power, cost pressures" + swot_impact = "THREAT" + elif yoy_inflation > 4: + interpretation = "Elevated inflation - Above target, potential rate hikes" + swot_impact = "THREAT" + elif yoy_inflation > 2: + interpretation = "Moderate inflation - Near Fed target (2%)" + swot_impact = "NEUTRAL" + elif yoy_inflation > 0: + interpretation = "Low inflation - Subdued price pressures" + swot_impact = "OPPORTUNITY" + else: + interpretation = "Deflation - Falling prices, potential economic weakness" + swot_impact = "THREAT" + + return { + "metric": "CPI / Inflation", + "value": round(yoy_inflation, 2) if yoy_inflation else None, + "unit": "% YoY", + "date": current_period, + "fed_target": 2.0, + "interpretation": interpretation, + "swot_category": swot_impact, + "source": "BLS (Bureau of Labor Statistics)", + "as_of": datetime.now().strftime("%Y-%m-%d") + } + + except Exception as e: + logger.error(f"BLS CPI processing error: {e}") + return {"error": str(e)} + + +async def fetch_bls_unemployment() -> dict: + """ + Fetch unemployment rate from BLS (primary source). + Series: LNS14000000 (Unemployment rate) + """ + data = await fetch_bls_series([BLS_SERIES["unemployment"]]) + + if "error" in data: + return data + + try: + series_data = data.get("Results", {}).get("series", []) + if not series_data: + return {"error": "No unemployment series data from BLS"} + + unemp_data = series_data[0].get("data", []) + if not unemp_data: + return {"error": "No unemployment observations from BLS"} + + # BLS data is sorted newest first - filter out invalid values + valid_data = [d for d in unemp_data if d.get("value") and d.get("value") != "-" and d.get("value") != "."] + + if not valid_data: + return {"error": "No valid unemployment data from BLS"} + + current = valid_data[0] + value = float(current.get("value", 0)) + current_period = f"{current.get('year')}-{current.get('periodName', current.get('period', ''))}" + + # Get previous value for trend + previous_value = None + if len(valid_data) > 1: + previous_value = float(valid_data[1].get("value", 0)) + + # Determine trend + if previous_value: + if value > previous_value + 0.2: + trend = "rising" + elif value < previous_value - 0.2: + trend = "falling" + else: + trend = "stable" + else: + trend = "unknown" + + # Unemployment interpretation + if value < 4: + interpretation = f"Low unemployment ({trend}) - Tight labor market, wage pressures" + swot_impact = "OPPORTUNITY" if trend != "rising" else "NEUTRAL" + elif value < 5: + interpretation = f"Normal unemployment ({trend}) - Healthy labor market" + swot_impact = "NEUTRAL" + elif value < 7: + interpretation = f"Elevated unemployment ({trend}) - Labor market slack" + swot_impact = "THREAT" + else: + interpretation = f"High unemployment ({trend}) - Weak labor market, recessionary" + swot_impact = "SEVERE_THREAT" + + return { + "metric": "Unemployment Rate", + "value": round(value, 1), + "unit": "%", + "date": current_period, + "previous_value": round(previous_value, 1) if previous_value else None, + "trend": trend, + "interpretation": interpretation, + "swot_category": swot_impact, + "source": "BLS (Bureau of Labor Statistics)", + "as_of": datetime.now().strftime("%Y-%m-%d") + } + + except Exception as e: + logger.error(f"BLS unemployment processing error: {e}") + return {"error": str(e)} + + +# ============================================================ +# MULTI-SOURCE AGGREGATOR +# ============================================================ + +async def get_all_sources_macro() -> dict: + """ + Fetch macro from ALL sources (BEA/BLS primary + FRED fallback) in parallel. + Returns NORMALIZED schema for interpreted_metrics group. + """ + # Fetch from all sources in parallel + bea_gdp_task = fetch_bea_gdp() + bls_cpi_task = fetch_bls_cpi() + bls_unemp_task = fetch_bls_unemployment() + fred_gdp_task = fetch_gdp_growth() + fred_rates_task = fetch_interest_rates() + fred_cpi_task = fetch_cpi() + fred_unemp_task = fetch_unemployment() + + (bea_gdp, bls_cpi, bls_unemp, + fred_gdp, fred_rates, fred_cpi, fred_unemp) = await asyncio.gather( + bea_gdp_task, bls_cpi_task, bls_unemp_task, + fred_gdp_task, fred_rates_task, fred_cpi_task, fred_unemp_task + ) + + # Use primary source, fallback to secondary if primary failed + gdp = bea_gdp if "error" not in bea_gdp else fred_gdp + cpi = bls_cpi if "error" not in bls_cpi else fred_cpi + unemp = bls_unemp if "error" not in bls_unemp else fred_unemp + rates = fred_rates # FRED is primary for interest rates + + # Build normalized raw_metrics schema with temporal data + return { + "group": "raw_metrics", + "ticker": "MACRO", + "metrics": { + "gdp_growth": { + "value": gdp.get("value") if gdp else None, + "data_type": "Quarterly", + "as_of": gdp.get("date") if gdp else None, # e.g., "2025Q3" + "source": gdp.get("source") if gdp else None, + "fallback": gdp.get("fallback", False) if gdp else True + }, + "interest_rate": { + "value": rates.get("value") if rates else None, + "data_type": "Monthly", + "as_of": rates.get("date") if rates else None, + "source": rates.get("source") if rates else None, + "fallback": rates.get("fallback", False) if rates else True + }, + "cpi_inflation": { + "value": cpi.get("value") if cpi else None, + "data_type": "Monthly", + "as_of": cpi.get("date") if cpi else None, + "source": cpi.get("source") if cpi else None, + "fallback": cpi.get("fallback", False) if cpi else True + }, + "unemployment": { + "value": unemp.get("value") if unemp else None, + "data_type": "Monthly", + "as_of": unemp.get("date") if unemp else None, + "source": unemp.get("source") if unemp else None, + "fallback": unemp.get("fallback", False) if unemp else True + } + }, + "source": "macro-basket", + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -435,7 +907,7 @@ async def get_full_macro_basket() -> dict: }, "overall_assessment": overall, "swot_summary": swot_summary, - "generated_at": datetime.now().isoformat() + "generated_at": datetime.now().strftime("%Y-%m-%d") } @@ -491,32 +963,89 @@ async def list_tools(): "properties": {}, "required": [] } + ), + Tool( + name="get_all_sources_macro", + description="Get macro from ALL sources (BEA/BLS primary + FRED fallback) for side-by-side comparison.", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } ) ] +# Global timeout for all tool operations (seconds) +TOOL_TIMEOUT = 45.0 + + +async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict: + """Execute a tool with timeout. Returns result dict or error dict.""" + if name == "get_gdp": + return await fetch_gdp_growth() + elif name == "get_interest_rates": + return await fetch_interest_rates() + elif name == "get_cpi": + return await fetch_cpi() + elif name == "get_unemployment": + return await fetch_unemployment() + elif name == "get_macro_basket": + return await get_full_macro_basket() + elif name == "get_all_sources_macro": + return await get_all_sources_macro() + else: + return {"error": f"Unknown tool: {name}"} + + @server.call_tool() async def call_tool(name: str, arguments: dict): - """Handle tool invocations.""" + """ + Handle tool invocations with GUARANTEED JSON-RPC response. + + This function ALWAYS returns a valid TextContent response, even if: + - External APIs timeout + - Exceptions occur during processing + - Any unexpected error happens + + This ensures MCP protocol compliance and prevents client hangs. + """ try: - if name == "get_gdp": - result = await fetch_gdp_growth() - elif name == "get_interest_rates": - result = await fetch_interest_rates() - elif name == "get_cpi": - result = await fetch_cpi() - elif name == "get_unemployment": - result = await fetch_unemployment() - elif name == "get_macro_basket": - result = await get_full_macro_basket() - else: - return [TextContent(type="text", text=f"Unknown tool: {name}")] + # Execute tool with global timeout + try: + result = await asyncio.wait_for( + _execute_tool_with_timeout(name, arguments), + timeout=TOOL_TIMEOUT + ) + except asyncio.TimeoutError: + logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s") + result = { + "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds", + "tool": name, + "source": "macro-basket", + "fallback": True + } + + # Ensure result is JSON serializable + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] - return [TextContent(type="text", text=json.dumps(result, indent=2))] + except json.JSONDecodeError as e: + logger.error(f"JSON serialization error for {name}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"JSON serialization failed: {str(e)}", + "tool": name, + "source": "macro-basket" + }))] except Exception as e: - logger.error(f"Tool error {name}: {e}") - return [TextContent(type="text", text=f"Error: {str(e)}")] + # Catch-all: ALWAYS return valid JSON-RPC response + logger.error(f"Unexpected error in {name}: {type(e).__name__}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"{type(e).__name__}: {str(e)}", + "tool": name, + "source": "macro-basket", + "fallback": True + }))] # ============================================================ diff --git a/mcp-servers/news-basket/README.md b/mcp-servers/news-basket/README.md index fd3d6ea829c623b15083f8230e5fed01b6d30635..8c608f1048441faf4d08ae6270b52b3ee47c7248 100644 --- a/mcp-servers/news-basket/README.md +++ b/mcp-servers/news-basket/README.md @@ -27,7 +27,7 @@ TAVILY_API_KEY=tvly-xxxxxxxxxxxxx | Tool | Parameters | Returns | |------|------------|---------| | `tavily_search` | `query`, `search_depth`, `max_results` | General web search results | -| `search_company_news` | `ticker`, `company_name` | Recent company news + SWOT hints | +| `get_all_sources_news` | `ticker`, `company_name` | Recent company news + SWOT hints | | `search_going_concern_news` | `ticker`, `company_name` | Financial distress news + risk level | | `search_industry_trends` | `industry` | Industry outlook articles | | `search_competitor_news` | `ticker`, `competitors` | Competitor news coverage | diff --git a/mcp-servers/news-basket/config/__init__.py b/mcp-servers/news-basket/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..68e1c91b7c39ae926708157d5e149bd9d3c8049c --- /dev/null +++ b/mcp-servers/news-basket/config/__init__.py @@ -0,0 +1,2 @@ +from .company_name_filters import clean_company_name, COMPANY_SUFFIXES, COMPANY_PREFIXES +from .domain_filters import NEWS_DOMAINS, NYT_NEWS_DESKS, NEWSAPI_DOMAINS diff --git a/mcp-servers/news-basket/config/company_name_filters.py b/mcp-servers/news-basket/config/company_name_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..93ce9aee9c9acc12f2b77644ecbec2c0815f1c17 --- /dev/null +++ b/mcp-servers/news-basket/config/company_name_filters.py @@ -0,0 +1,56 @@ +""" +Company name normalization filters. +Add suffixes/prefixes here as new edge cases are discovered. +""" + +# Suffixes to strip from company names (order matters - longer first) +COMPANY_SUFFIXES = [ + " Corporation", + " Incorporated", + " Technologies", + " International", + " Platforms", + " Holdings", + " Company", + " Limited", + " Group", + " Inc.", + " Inc", + " Ltd.", + " Ltd", + " LLC", + " L.P.", + " Co.", + " PLC", + " N.V.", + " S.A.", + " AG", +] + +# Prefixes to strip (if any) +COMPANY_PREFIXES = [ + "The ", +] + + +def clean_company_name(name: str) -> str: + """ + Remove common corporate suffixes/prefixes for better search matching. + e.g., "NVIDIA Corporation" -> "NVIDIA" + """ + result = name + + # Strip prefixes + for prefix in COMPANY_PREFIXES: + if result.startswith(prefix): + result = result[len(prefix):] + + # Strip suffixes + for suffix in COMPANY_SUFFIXES: + if result.endswith(suffix): + result = result[:-len(suffix)] + + # Clean punctuation + result = result.replace(",", "").strip() + + return result diff --git a/mcp-servers/news-basket/config/domain_filters.py b/mcp-servers/news-basket/config/domain_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..51ed4ee40a604ae445136ee5e491be1f5f93ba7b --- /dev/null +++ b/mcp-servers/news-basket/config/domain_filters.py @@ -0,0 +1,39 @@ +""" +Domain whitelist for business/finance/tech news sources. +Helps disambiguate company names from common words (e.g., Visa vs travel visa). +""" + +# Finance/Business domains for Tavily +FINANCE_DOMAINS = [ + "bloomberg.com", + "reuters.com", + "wsj.com", + "cnbc.com", + "finance.yahoo.com", + "marketwatch.com", + "fool.com", + "seekingalpha.com", + "barrons.com", + "ft.com", + "businessinsider.com", + "forbes.com", + "investopedia.com", +] + +# Tech domains for Tavily +TECH_DOMAINS = [ + "techcrunch.com", + "wired.com", + "theverge.com", + "arstechnica.com", + "zdnet.com", +] + +# Combined whitelist for Tavily +NEWS_DOMAINS = FINANCE_DOMAINS + TECH_DOMAINS + +# NYT news desks to filter +NYT_NEWS_DESKS = ["Business", "Technology", "DealBook"] + +# NewsAPI domains (comma-separated string) +NEWSAPI_DOMAINS = ",".join(FINANCE_DOMAINS + TECH_DOMAINS) diff --git a/mcp-servers/news-basket/server.py b/mcp-servers/news-basket/server.py index 47024a5eca59a64da7c46bb9ab623ac8c1a7b415..47f52604795fdaf10b48a921f46b92ee84886e16 100644 --- a/mcp-servers/news-basket/server.py +++ b/mcp-servers/news-basket/server.py @@ -18,10 +18,27 @@ import asyncio import json import logging import os -from datetime import datetime +from datetime import datetime, timedelta, timezone + + +def normalize_date(date_str: str) -> str: + """Extract date-only (YYYY-MM-DD) from various datetime formats.""" + if not date_str: + return None + # Handle ISO format with/without timezone + if "T" in date_str: + return date_str.split("T")[0] + # Already date-only + if len(date_str) == 10: + return date_str + return date_str[:10] if len(date_str) >= 10 else date_str from pathlib import Path from typing import Optional +# Import company name normalization and domain filters from local config +from config.company_name_filters import clean_company_name +from config.domain_filters import NEWS_DOMAINS, NYT_NEWS_DESKS, NEWSAPI_DOMAINS + # Load environment variables from dotenv import load_dotenv env_paths = [ @@ -72,6 +89,7 @@ async def tavily_search( include_domains: list = None, exclude_domains: list = None, include_answer: bool = True, + days: int = None, ) -> dict: """ Execute Tavily search. @@ -83,6 +101,7 @@ async def tavily_search( include_domains: Limit to specific domains exclude_domains: Exclude specific domains include_answer: Include AI-generated answer + days: Limit results to last N days (optional) """ if not TAVILY_API_KEY: return { @@ -105,6 +124,8 @@ async def tavily_search( payload["include_domains"] = include_domains if exclude_domains: payload["exclude_domains"] = exclude_domains + if days: + payload["days"] = days response = await client.post( f"{TAVILY_BASE_URL}/search", @@ -138,7 +159,7 @@ async def tavily_search( "result_count": len(results), "search_depth": search_depth, "source": "Tavily", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: @@ -152,6 +173,7 @@ async def nyt_search( sort: str = "newest", begin_date: str = None, end_date: str = None, + news_desks: list[str] = None, ) -> dict: """ Search NYT Article Search API. @@ -162,6 +184,7 @@ async def nyt_search( sort: "newest", "oldest", or "relevance" begin_date: Filter start date (YYYYMMDD) end_date: Filter end date (YYYYMMDD) + news_desks: Filter by news desk (e.g., ["Business", "Technology"]) Returns: Dict with articles from New York Times @@ -185,6 +208,10 @@ async def nyt_search( params["begin_date"] = begin_date if end_date: params["end_date"] = end_date + if news_desks: + # Filter by news desk: fq=news_desk:("Business" "Technology") + desks_str = " ".join(f'"{desk}"' for desk in news_desks) + params["fq"] = f"news_desk:({desks_str})" response = await client.get( NYT_BASE_URL, @@ -205,7 +232,7 @@ async def nyt_search( } data = response.json() - docs = data.get("response", {}).get("docs", []) + docs = data.get("response", {}).get("docs") or [] # Format results results = [] @@ -226,7 +253,7 @@ async def nyt_search( "result_count": len(results), "total_hits": data.get("response", {}).get("meta", {}).get("hits", 0), "source": "NYT Article Search API", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: @@ -239,6 +266,7 @@ async def newsapi_search( max_results: int = 5, sort_by: str = "publishedAt", language: str = "en", + domains: str = None, ) -> dict: """ Search NewsAPI.org for articles. @@ -269,6 +297,8 @@ async def newsapi_search( "language": language, "pageSize": min(max_results, 100), } + if domains: + params["domains"] = domains response = await client.get( NEWSAPI_BASE_URL, @@ -313,7 +343,7 @@ async def newsapi_search( "result_count": len(results), "total_hits": data.get("totalResults", 0), "source": "NewsAPI", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: @@ -321,35 +351,51 @@ async def newsapi_search( return {"error": str(e)} -async def search_company_news(ticker: str, company_name: str = None) -> dict: +async def get_all_sources_news(ticker: str, company_name: str = None) -> dict: """ Search for recent news about a company using Tavily, NYT, and NewsAPI. Combines results from all sources for comprehensive coverage. """ - query = f"{ticker} stock news" + # Build specific queries for better relevance + base_query = f"{ticker} stock news" if company_name: - query = f"{company_name} ({ticker}) stock news" + base_query = f"{company_name} ({ticker}) stock news" + + # Tavily query - general search + tavily_query = base_query + + # NYT query - cleaned company name + "stock" for disambiguation (e.g., Apple vs apple fruit) + nyt_query = f"{clean_company_name(company_name or ticker)} stock" + + # NewsAPI query - ticker symbol helps filter + newsapi_query = f"{company_name or ticker} {ticker} stock" - # Fetch from all sources in parallel + # Calculate 7-day lookback + seven_days_ago = (datetime.now() - timedelta(days=7)).strftime("%Y%m%d") + + # Fetch from all sources in parallel (limited to business/finance/tech domains) tavily_task = tavily_search( - query=query, + query=tavily_query, search_depth="basic", max_results=4, + include_domains=NEWS_DOMAINS, exclude_domains=["reddit.com", "twitter.com", "x.com"], + days=7, ) - nyt_query = company_name or ticker nyt_task = nyt_search( query=nyt_query, - max_results=3, - sort="newest", + max_results=5, + sort="relevance", + begin_date=seven_days_ago, + news_desks=NYT_NEWS_DESKS, ) - newsapi_query = company_name or ticker newsapi_task = newsapi_search( query=newsapi_query, max_results=3, sort_by="publishedAt", + domains=NEWSAPI_DOMAINS, ) tavily_result, nyt_result, newsapi_result = await asyncio.gather( @@ -360,55 +406,52 @@ async def search_company_news(ticker: str, company_name: str = None) -> dict: all_results = [] sources_used = [] - # Add Tavily results + # Add Tavily results (inject source name into each article) if "results" in tavily_result and tavily_result["results"]: + for article in tavily_result["results"]: + article["source"] = article.get("source") or "Tavily" all_results.extend(tavily_result["results"]) sources_used.append("Tavily") - # Add NYT results + # Add NYT results (inject source name into each article) if "results" in nyt_result and nyt_result["results"]: + for article in nyt_result["results"]: + article["source"] = article.get("source") or "NYT" all_results.extend(nyt_result["results"]) sources_used.append("NYT") - # Add NewsAPI results + # Add NewsAPI results (inject source name into each article) if "results" in newsapi_result and newsapi_result["results"]: + for article in newsapi_result["results"]: + article["source"] = article.get("source") or "NewsAPI" all_results.extend(newsapi_result["results"]) sources_used.append("NewsAPI") - # Build combined result - result = { - "query": query, - "answer": tavily_result.get("answer"), - "results": all_results, - "result_count": len(all_results), - "sources": sources_used, - "source": " + ".join(sources_used) if sources_used else "None", - "as_of": datetime.now().isoformat() + # Sort by date (most recent first) - deduplication applied downstream + all_results.sort(key=lambda x: x.get("published_date", "") or "", reverse=True) + + # Build normalized content_analysis schema + items = [] + for article in all_results: + items.append({ + "title": article.get("title"), + "content": article.get("content") or article.get("snippet"), + "url": article.get("url"), + "datetime": normalize_date(article.get("published_date")), + "source": article.get("source"), + }) + + return { + "group": "content_analysis", + "ticker": ticker.upper(), + "query": base_query, + "items": items, + "item_count": len(items), + "sources_used": sources_used, + "source": "news-basket", + "as_of": datetime.now().strftime("%Y-%m-%d") } - # Add SWOT categorization - if all_results: - swot_hints = { - "opportunities": [], - "threats": [] - } - - for r in all_results: - content = (r.get("content") or "").lower() - title = (r.get("title") or "").lower() - - # Look for positive signals - if any(kw in content or kw in title for kw in ["upgrade", "beat", "growth", "strong", "positive"]): - swot_hints["opportunities"].append(r["title"][:80]) - - # Look for negative signals - if any(kw in content or kw in title for kw in ["downgrade", "miss", "decline", "weak", "concern", "warning"]): - swot_hints["threats"].append(r["title"][:80]) - - result["swot_hints"] = swot_hints - - return result - async def search_going_concern_news(ticker: str, company_name: str = None) -> dict: """ @@ -451,10 +494,6 @@ async def search_going_concern_news(ticker: str, company_name: str = None) -> di "signals": risk_signals[:5], } - result["swot_implications"] = { - "threats": [f"News coverage of financial distress ({len(risk_signals)} articles)"] if risk_signals else [] - } - return result @@ -548,7 +587,7 @@ async def list_tools(): } ), Tool( - name="search_company_news", + name="get_all_sources_news", description="Search for recent news about a company from Tavily + NYT. Returns news with SWOT hints.", inputSchema={ "type": "object", @@ -619,49 +658,93 @@ async def list_tools(): ] +# Global timeout for all tool operations (seconds) +TOOL_TIMEOUT = 45.0 + + +async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict: + """Execute a tool with timeout. Returns result dict or error dict.""" + if name == "tavily_search": + query = arguments.get("query", "") + search_depth = arguments.get("search_depth", "basic") + max_results = arguments.get("max_results", 5) + return await tavily_search(query, search_depth, max_results) + elif name == "nyt_search": + query = arguments.get("query", "") + max_results = arguments.get("max_results", 5) + sort = arguments.get("sort", "newest") + return await nyt_search(query, max_results, sort) + elif name == "get_all_sources_news": + ticker = arguments.get("ticker", "").upper() + company_name = arguments.get("company_name") + return await get_all_sources_news(ticker, company_name) + elif name == "search_going_concern_news": + ticker = arguments.get("ticker", "").upper() + company_name = arguments.get("company_name") + return await search_going_concern_news(ticker, company_name) + elif name == "search_industry_trends": + industry = arguments.get("industry", "") + return await search_industry_trends(industry) + elif name == "search_competitor_news": + ticker = arguments.get("ticker", "").upper() + competitors = arguments.get("competitors", []) + return await search_competitor_news(ticker, competitors) + else: + return {"error": f"Unknown tool: {name}"} + + @server.call_tool() async def call_tool(name: str, arguments: dict): - """Handle tool invocations.""" + """ + Handle tool invocations with GUARANTEED JSON-RPC response. + + This function ALWAYS returns a valid TextContent response, even if: + - External APIs timeout + - Exceptions occur during processing + - Any unexpected error happens + + This ensures MCP protocol compliance and prevents client hangs. + """ try: - if name == "tavily_search": - query = arguments.get("query", "") - search_depth = arguments.get("search_depth", "basic") - max_results = arguments.get("max_results", 5) - result = await tavily_search(query, search_depth, max_results) - - elif name == "nyt_search": - query = arguments.get("query", "") - max_results = arguments.get("max_results", 5) - sort = arguments.get("sort", "newest") - result = await nyt_search(query, max_results, sort) - - elif name == "search_company_news": - ticker = arguments.get("ticker", "").upper() - company_name = arguments.get("company_name") - result = await search_company_news(ticker, company_name) - - elif name == "search_going_concern_news": - ticker = arguments.get("ticker", "").upper() - company_name = arguments.get("company_name") - result = await search_going_concern_news(ticker, company_name) - - elif name == "search_industry_trends": - industry = arguments.get("industry", "") - result = await search_industry_trends(industry) - - elif name == "search_competitor_news": - ticker = arguments.get("ticker", "").upper() - competitors = arguments.get("competitors", []) - result = await search_competitor_news(ticker, competitors) - - else: - return [TextContent(type="text", text=f"Unknown tool: {name}")] - - return [TextContent(type="text", text=json.dumps(result, indent=2))] + # Execute tool with global timeout + try: + result = await asyncio.wait_for( + _execute_tool_with_timeout(name, arguments), + timeout=TOOL_TIMEOUT + ) + except asyncio.TimeoutError: + ticker = arguments.get("ticker", "") + logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s for {ticker}") + result = { + "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds", + "ticker": ticker, + "tool": name, + "source": "news-basket", + "fallback": True + } + + # Ensure result is JSON serializable + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + + except json.JSONDecodeError as e: + logger.error(f"JSON serialization error for {name}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"JSON serialization failed: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "news-basket" + }))] except Exception as e: - logger.error(f"Tool error {name}: {e}") - return [TextContent(type="text", text=f"Error: {str(e)}")] + # Catch-all: ALWAYS return valid JSON-RPC response + logger.error(f"Unexpected error in {name}: {type(e).__name__}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"{type(e).__name__}: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "news-basket", + "fallback": True + }))] # ============================================================ diff --git a/mcp-servers/sentiment-basket/server.py b/mcp-servers/sentiment-basket/server.py index d51ec53d744a57146ac2c23262d17d735d64e0f7..6d817eddcdc6bcacbf3f90d0faa0deb4c0a483f4 100644 --- a/mcp-servers/sentiment-basket/server.py +++ b/mcp-servers/sentiment-basket/server.py @@ -1,9 +1,11 @@ """ Sentiment Basket MCP Server -Aggregates sentiment metrics from multiple free sources for SWOT analysis: -- Finnhub News Sentiment → News articles analyzed with VADER -- Reddit → Retail investor sentiment from r/WallStreetBets, r/stocks +Aggregates raw content from multiple sources for downstream sentiment analysis: +- Finnhub News → Raw news articles with headlines +- Reddit → Retail investor posts from r/WallStreetBets, r/stocks + +Note: VADER sentiment scoring removed - apply sentiment analysis downstream. Usage: python server.py @@ -16,7 +18,7 @@ import asyncio import json import logging import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path # Load environment variables from .env @@ -41,13 +43,6 @@ from mcp.types import Tool, TextContent # Data fetching import httpx -# Sentiment analysis -try: - from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer - VADER_AVAILABLE = True -except ImportError: - VADER_AVAILABLE = False - logging.basicConfig(level=logging.INFO) logger = logging.getLogger("sentiment-basket") @@ -57,33 +52,23 @@ server = Server("sentiment-basket") # API Keys FINNHUB_API_KEY = os.getenv("FINNHUB_API_KEY") # Get free key: https://finnhub.io/register -# Initialize VADER if available -vader = SentimentIntensityAnalyzer() if VADER_AVAILABLE else None - # ============================================================ # DATA FETCHERS # ============================================================ -async def fetch_finnhub_sentiment(ticker: str) -> dict: +async def fetch_finnhub_news(ticker: str) -> dict: """ - Fetch company news from Finnhub and compute sentiment with VADER. - Uses free company-news endpoint + local NLP analysis. + Fetch company news from Finnhub. + Returns raw articles without sentiment scoring. """ if not FINNHUB_API_KEY: return { - "metric": "Finnhub News Sentiment", + "metric": "Finnhub News", "ticker": ticker, "error": "FINNHUB_API_KEY not configured. Get free key at https://finnhub.io/register" } - if not VADER_AVAILABLE: - return { - "metric": "Finnhub News Sentiment", - "ticker": ticker, - "error": "VADER sentiment analyzer not installed. Run: pip install vaderSentiment" - } - try: async with httpx.AsyncClient() as client: # Get company news (free tier) @@ -102,94 +87,64 @@ async def fetch_finnhub_sentiment(ticker: str) -> dict: if isinstance(data, dict) and "error" in data: return { - "metric": "Finnhub News Sentiment", + "metric": "Finnhub News", "ticker": ticker, "error": data.get("error", "Unknown error") } if not data or not isinstance(data, list): return { - "metric": "Finnhub News Sentiment", + "metric": "Finnhub News", "ticker": ticker.upper(), - "score": 50, - "articles_analyzed": 0, - "interpretation": "No recent news articles found", - "swot_category": "NEUTRAL", + "articles_count": 0, + "articles": [], "source": "Finnhub", - "as_of": datetime.now().isoformat() + "as_of": datetime.now().strftime("%Y-%m-%d") } - # Analyze sentiment of headlines with VADER - total_score = 0 + # Return raw articles without sentiment scoring + articles_list = [] for article in data[:50]: # Limit to 50 articles - headline = article.get("headline", "") - summary = article.get("summary", "") - text = f"{headline} {summary}" - scores = vader.polarity_scores(text) - total_score += scores["compound"] - - articles_count = min(len(data), 50) - avg_sentiment = total_score / articles_count if articles_count > 0 else 0 - score = (avg_sentiment + 1) * 50 # Convert -1..1 to 0..100 - - # Interpretation - if score >= 60: - interpretation = "Bullish sentiment - Positive news coverage" - swot_impact = "STRENGTH" - elif score >= 45: - interpretation = "Neutral sentiment - Mixed news coverage" - swot_impact = "NEUTRAL" - elif score >= 30: - interpretation = "Bearish sentiment - Negative news coverage" - swot_impact = "WEAKNESS" - else: - interpretation = "Very bearish sentiment - Predominantly negative coverage" - swot_impact = "THREAT" + articles_list.append({ + "headline": article.get("headline", ""), + "summary": article.get("summary", ""), + "url": article.get("url", ""), + "source": article.get("source", ""), + "datetime": datetime.fromtimestamp(article.get("datetime", 0), tz=timezone.utc).strftime("%Y-%m-%d") if article.get("datetime") else None, + }) return { - "metric": "Finnhub News Sentiment", + "metric": "Finnhub News", "ticker": ticker.upper(), - "score": round(score, 2), - "sentiment_raw": round(avg_sentiment, 3), - "articles_analyzed": articles_count, + "articles_count": len(articles_list), "total_articles": len(data), - "interpretation": interpretation, - "swot_category": swot_impact, - "source": "Finnhub + VADER", - "as_of": datetime.now().isoformat() + "source": "Finnhub", + "articles": articles_list, + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: - logger.error(f"Finnhub sentiment error for {ticker}: {e}") + logger.error(f"Finnhub news error for {ticker}: {e}") return { - "metric": "Finnhub News Sentiment", + "metric": "Finnhub News", "ticker": ticker, "error": str(e) } -async def fetch_reddit_sentiment(ticker: str, company_name: str = "") -> dict: +async def fetch_reddit_posts(ticker: str, company_name: str = "") -> dict: """ - Fetch Reddit sentiment using public JSON endpoints. + Fetch Reddit posts using public JSON endpoints. Searches r/WallStreetBets, r/stocks for mentions. - Uses VADER for sentiment scoring (100 req/min rate limit). + Returns raw posts without sentiment scoring. """ - if not VADER_AVAILABLE: - return { - "metric": "Reddit Sentiment", - "ticker": ticker, - "error": "VADER sentiment analyzer not installed" - } - try: async with httpx.AsyncClient() as client: headers = {"User-Agent": "SentimentBasket/1.0"} subreddits = ["wallstreetbets", "stocks"] - all_texts = [] - total_score = 0 + posts_list = [] total_upvotes = 0 - post_count = 0 search_query = ticker.upper() @@ -218,141 +173,95 @@ async def fetch_reddit_sentiment(ticker: str, company_name: str = "") -> dict: title = post_data.get("title", "") selftext = post_data.get("selftext", "")[:500] # Limit text length upvotes = post_data.get("ups", 1) + permalink = post_data.get("permalink", "") - text = f"{title} {selftext}" - - # VADER sentiment - scores = vader.polarity_scores(text) - total_score += scores["compound"] * upvotes total_upvotes += upvotes - post_count += 1 - - if post_count == 0: - return { - "metric": "Reddit Sentiment", - "ticker": ticker.upper(), - "score": 50, # Neutral default - "posts_analyzed": 0, - "interpretation": "No recent posts found - Insufficient data", - "swot_category": "NEUTRAL", - "source": "Reddit (Public)", - "as_of": datetime.now().isoformat() - } - avg_sentiment = (total_score / total_upvotes) if total_upvotes > 0 else 0 - score = (avg_sentiment + 1) * 50 - - if score >= 65: - interpretation = "Bullish retail sentiment" - swot_impact = "STRENGTH" - elif score >= 50: - interpretation = "Neutral retail sentiment" - swot_impact = "NEUTRAL" - elif score >= 35: - interpretation = "Bearish retail sentiment" - swot_impact = "WEAKNESS" - else: - interpretation = "Very bearish retail sentiment" - swot_impact = "THREAT" + # Capture post details with URL (no sentiment scoring) + posts_list.append({ + "title": title, + "selftext": selftext, + "url": f"https://reddit.com{permalink}" if permalink else "", + "subreddit": f"r/{subreddit}", + "upvotes": upvotes, + "created_utc": datetime.fromtimestamp(post_data.get("created_utc", 0), tz=timezone.utc).strftime("%Y-%m-%d") if post_data.get("created_utc") else None + }) return { - "metric": "Reddit Sentiment", + "metric": "Reddit Posts", "ticker": ticker.upper(), - "score": round(score, 2), - "sentiment_raw": round(avg_sentiment, 3), - "posts_analyzed": post_count, + "posts_count": len(posts_list), "total_upvotes": total_upvotes, - "interpretation": interpretation, - "swot_category": swot_impact, "source": "Reddit (Public)", - "as_of": datetime.now().isoformat() + "posts": posts_list, + "as_of": datetime.now().strftime("%Y-%m-%d") } except Exception as e: - logger.error(f"Reddit public sentiment error: {e}") + logger.error(f"Reddit posts error: {e}") return { - "metric": "Reddit Sentiment", + "metric": "Reddit Posts", "ticker": ticker, "error": str(e) } -async def get_full_sentiment_basket(ticker: str, company_name: str = "") -> dict: +async def get_all_sources_sentiment(ticker: str, company_name: str = "") -> dict: """ - Fetch all sentiment metrics for a given ticker/company. - Returns aggregated SWOT-ready data. + Fetch raw content from all sources for a given ticker/company. + Returns NORMALIZED schema for content_analysis group. + Sentiment analysis should be applied downstream. """ if not company_name: company_name = ticker # Use ticker as fallback - # Fetch all metrics concurrently - finnhub_task = fetch_finnhub_sentiment(ticker) - reddit_task = fetch_reddit_sentiment(ticker, company_name) + # Fetch from all sources concurrently + finnhub_task = fetch_finnhub_news(ticker) + reddit_task = fetch_reddit_posts(ticker, company_name) finnhub, reddit = await asyncio.gather(finnhub_task, reddit_task) - # Aggregate SWOT impacts - swot_summary = { - "strengths": [], - "weaknesses": [], - "opportunities": [], - "threats": [] - } - - # Calculate composite score (weighted average) - scores = [] - weights = [] - - for metric, weight in [(finnhub, 0.5), (reddit, 0.5)]: - if "error" not in metric and "score" in metric: - scores.append(metric["score"]) - weights.append(weight) - - impact = metric.get("swot_category", "NEUTRAL") - desc = f"{metric['metric']}: {metric.get('score', 'N/A')}/100 - {metric.get('interpretation', '')}" - - if impact == "STRENGTH": - swot_summary["strengths"].append(desc) - elif impact == "WEAKNESS": - swot_summary["weaknesses"].append(desc) - elif impact == "OPPORTUNITY": - swot_summary["opportunities"].append(desc) - elif impact in ["THREAT", "SEVERE_THREAT"]: - swot_summary["threats"].append(desc) - - # Calculate weighted composite score - if scores and weights: - total_weight = sum(weights) - composite_score = sum(s * w for s, w in zip(scores, weights)) / total_weight - else: - composite_score = 50 # Neutral default - - # Overall interpretation - if composite_score >= 65: - overall = "Overall Bullish Sentiment" - overall_swot = "STRENGTH" - elif composite_score >= 50: - overall = "Overall Neutral Sentiment" - overall_swot = "NEUTRAL" - elif composite_score >= 35: - overall = "Overall Bearish Sentiment" - overall_swot = "WEAKNESS" - else: - overall = "Overall Very Bearish Sentiment" - overall_swot = "THREAT" + # Build normalized content_analysis schema + items = [] + sources_used = [] + + # Add Finnhub articles + if "error" not in finnhub and finnhub.get("articles"): + sources_used.append("Finnhub") + for article in finnhub.get("articles", []): + items.append({ + "title": article.get("headline"), + "content": article.get("summary"), + "url": article.get("url"), + "datetime": article.get("datetime"), + "source": "Finnhub", + "subreddit": None, # Not applicable for Finnhub + }) + + # Add Reddit posts + if "error" not in reddit and reddit.get("posts"): + sources_used.append("Reddit") + for post in reddit.get("posts", []): + items.append({ + "title": post.get("title"), + "content": post.get("selftext"), + "url": post.get("url"), + "datetime": post.get("created_utc"), + "source": "Reddit", + "subreddit": post.get("subreddit"), # Separate subreddit field + }) + + # Sort by datetime (most recent first) + items.sort(key=lambda x: x.get("datetime") or "", reverse=True) return { + "group": "content_analysis", "ticker": ticker.upper(), - "company_name": company_name, - "composite_score": round(composite_score, 2), - "overall_interpretation": overall, - "overall_swot_category": overall_swot, - "metrics": { - "finnhub": finnhub, - "reddit": reddit - }, - "swot_summary": swot_summary, - "generated_at": datetime.now().isoformat() + "items": items, + "item_count": len(items), + "sources_used": sources_used, + "source": "sentiment-basket", + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -362,11 +271,11 @@ async def get_full_sentiment_basket(ticker: str, company_name: str = "") -> dict @server.list_tools() async def list_tools(): - """List available sentiment tools.""" + """List available content fetching tools (no sentiment scoring).""" return [ Tool( - name="get_finnhub_sentiment", - description="Get news sentiment from Finnhub company news analyzed with VADER. Returns sentiment score (0-100).", + name="get_finnhub_news", + description="Get news articles from Finnhub company news. Returns raw articles without sentiment scoring.", inputSchema={ "type": "object", "properties": { @@ -379,8 +288,8 @@ async def list_tools(): } ), Tool( - name="get_reddit_sentiment", - description="Get retail investor sentiment from Reddit (r/WallStreetBets, r/stocks). Uses VADER NLP for scoring.", + name="get_reddit_posts", + description="Get retail investor posts from Reddit (r/WallStreetBets, r/stocks). Returns raw posts without sentiment scoring.", inputSchema={ "type": "object", "properties": { @@ -398,7 +307,7 @@ async def list_tools(): ), Tool( name="get_sentiment_basket", - description="Get full sentiment basket (Finnhub + Reddit) with composite score and SWOT summary.", + description="Get full content basket (Finnhub + Reddit) with raw articles/posts. No sentiment scoring - apply VADER downstream.", inputSchema={ "type": "object", "properties": { @@ -417,38 +326,84 @@ async def list_tools(): ] +# Global timeout for all tool operations (seconds) +# Increased for completeness-first mode +TOOL_TIMEOUT = 60.0 + + +async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict: + """Execute a tool with timeout. Returns result dict or error dict.""" + ticker = arguments.get("ticker", "").upper() + company_name = arguments.get("company_name", "") + + if name == "get_finnhub_news": + if not ticker: + return {"error": "ticker is required"} + return await fetch_finnhub_news(ticker) + elif name == "get_reddit_posts": + if not ticker: + return {"error": "ticker is required"} + return await fetch_reddit_posts(ticker, company_name) + elif name == "get_sentiment_basket": + if not ticker: + return {"error": "ticker is required"} + return await get_all_sources_sentiment(ticker, company_name) + else: + return {"error": f"Unknown tool: {name}"} + + @server.call_tool() async def call_tool(name: str, arguments: dict): - """Handle tool invocations.""" + """ + Handle tool invocations with GUARANTEED JSON-RPC response. + + This function ALWAYS returns a valid TextContent response, even if: + - External APIs timeout + - Exceptions occur during processing + - Any unexpected error happens + + This ensures MCP protocol compliance and prevents client hangs. + """ try: - if name == "get_finnhub_sentiment": - ticker = arguments.get("ticker", "").upper() - if not ticker: - return [TextContent(type="text", text="Error: ticker is required")] - result = await fetch_finnhub_sentiment(ticker) - - elif name == "get_reddit_sentiment": - ticker = arguments.get("ticker", "").upper() - company_name = arguments.get("company_name", "") - if not ticker: - return [TextContent(type="text", text="Error: ticker is required")] - result = await fetch_reddit_sentiment(ticker, company_name) - - elif name == "get_sentiment_basket": - ticker = arguments.get("ticker", "").upper() - company_name = arguments.get("company_name", "") - if not ticker: - return [TextContent(type="text", text="Error: ticker is required")] - result = await get_full_sentiment_basket(ticker, company_name) - - else: - return [TextContent(type="text", text=f"Unknown tool: {name}")] - - return [TextContent(type="text", text=json.dumps(result, indent=2))] + # Execute tool with global timeout + try: + result = await asyncio.wait_for( + _execute_tool_with_timeout(name, arguments), + timeout=TOOL_TIMEOUT + ) + except asyncio.TimeoutError: + ticker = arguments.get("ticker", "") + logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s for {ticker}") + result = { + "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds", + "ticker": ticker, + "tool": name, + "source": "sentiment-basket", + "fallback": True + } + + # Ensure result is JSON serializable + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + + except json.JSONDecodeError as e: + logger.error(f"JSON serialization error for {name}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"JSON serialization failed: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "sentiment-basket" + }))] except Exception as e: - logger.error(f"Tool error {name}: {e}") - return [TextContent(type="text", text=f"Error: {str(e)}")] + # Catch-all: ALWAYS return valid JSON-RPC response + logger.error(f"Unexpected error in {name}: {type(e).__name__}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"{type(e).__name__}: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "sentiment-basket", + "fallback": True + }))] # ============================================================ diff --git a/mcp-servers/sentiment-basket/test_fetchers.py b/mcp-servers/sentiment-basket/test_fetchers.py index 0f1307bec4682779dd312255e9e5311f31a73eaa..02dd7151d6dfadfdf8bccf3309906d4b59d32faa 100644 --- a/mcp-servers/sentiment-basket/test_fetchers.py +++ b/mcp-servers/sentiment-basket/test_fetchers.py @@ -24,7 +24,7 @@ for env_path in env_paths: from server import ( fetch_finnhub_sentiment, fetch_reddit_sentiment, - get_full_sentiment_basket, + get_all_sources_sentiment, FINNHUB_API_KEY, VADER_AVAILABLE ) @@ -57,7 +57,7 @@ async def test_reddit(ticker: str = "AAPL"): async def test_full_basket(ticker: str = "AAPL", company_name: str = "Apple"): """Test full sentiment basket.""" print(f"\n[Full Basket] Testing {ticker} ({company_name})") - result = await get_full_sentiment_basket(ticker, company_name) + result = await get_all_sources_sentiment(ticker, company_name) print_result(f"Full Sentiment Basket - {ticker}", result) return result diff --git a/mcp-servers/valuation-basket/fetchers.py b/mcp-servers/valuation-basket/fetchers.py index b634fafbbbf3775f2c2dc8f3558bc34231951cd7..e90f9d3be87d0cf8eb7d6c4fd12a433a38f76acf 100644 --- a/mcp-servers/valuation-basket/fetchers.py +++ b/mcp-servers/valuation-basket/fetchers.py @@ -200,5 +200,5 @@ async def get_full_valuation_basket(ticker: str) -> dict: "overall_assessment": overall, "swot_summary": swot_summary, "source": "Yahoo Finance (yfinance)", - "generated_at": datetime.now().isoformat() + "generated_at": datetime.now().strftime("%Y-%m-%d") } diff --git a/mcp-servers/valuation-basket/server.py b/mcp-servers/valuation-basket/server.py index 478a98f255775a35b33c21db9f50152f3b1ea437..a3bb738fece79d820433dd922246640446af3602 100644 --- a/mcp-servers/valuation-basket/server.py +++ b/mcp-servers/valuation-basket/server.py @@ -78,6 +78,17 @@ def _fetch_yfinance_sync(ticker: str) -> dict: if forward_pe and earnings_growth and earnings_growth > 0: forward_peg = forward_pe / (earnings_growth * 100) + # Convert regularMarketTime (Unix timestamp) to date string (YYYY-MM-DD) + # Use UTC to get correct trading date (NYSE closes at 21:00 UTC) + from datetime import datetime as dt, timezone + regular_market_time = info.get("regularMarketTime") + market_date_str = None + if regular_market_time: + try: + market_date_str = dt.fromtimestamp(regular_market_time, tz=timezone.utc).strftime("%Y-%m-%d") + except (ValueError, OSError): + market_date_str = dt.now(tz=timezone.utc).strftime("%Y-%m-%d") + return { "ticker": ticker.upper(), "current_price": info.get("currentPrice") or info.get("regularMarketPrice"), @@ -92,6 +103,7 @@ def _fetch_yfinance_sync(ticker: str) -> dict: "forward_peg": forward_peg, "earnings_growth": earnings_growth, "revenue_growth": info.get("revenueGrowth"), + "regular_market_time": market_date_str, "source": "Yahoo Finance (yfinance)" } @@ -100,6 +112,132 @@ def _fetch_yfinance_sync(ticker: str) -> dict: return {"error": str(e)} +# Alpha Vantage API key for fallback +ALPHA_VANTAGE_KEY = os.getenv("ALPHA_VANTAGE_API_KEY", "") + + +def _safe_float(value, default=None): + """Safely convert Alpha Vantage value to float. Handles '-' and 'None' strings.""" + if value is None or value == "-" or value == "None" or value == "": + return default + try: + return float(value) + except (ValueError, TypeError): + return default + + +def _safe_int(value, default=None): + """Safely convert Alpha Vantage value to int. Handles '-' and 'None' strings.""" + if value is None or value == "-" or value == "None" or value == "": + return default + try: + return int(value) + except (ValueError, TypeError): + return default + + +def _fetch_alpha_vantage_sync(ticker: str) -> dict: + """ + Synchronous Alpha Vantage fetch (runs in thread pool). + Fallback source when Yahoo Finance fails. + """ + import requests + + if not ALPHA_VANTAGE_KEY: + return {"error": "Alpha Vantage API key not configured"} + + try: + url = f"https://www.alphavantage.co/query?function=OVERVIEW&symbol={ticker}&apikey={ALPHA_VANTAGE_KEY}" + response = requests.get(url, timeout=15) + data = response.json() + + if "Error Message" in data or not data.get("Symbol"): + return {"error": f"No data found for ticker {ticker}"} + + # Extract valuation metrics from Alpha Vantage OVERVIEW + trailing_pe = _safe_float(data.get("TrailingPE")) + forward_pe = _safe_float(data.get("ForwardPE")) + pb_ratio = _safe_float(data.get("PriceToBookRatio")) + ps_ratio = _safe_float(data.get("PriceToSalesRatioTTM")) + ev_ebitda = _safe_float(data.get("EVToEBITDA")) + peg_ratio = _safe_float(data.get("PEGRatio")) + + # Extract LatestQuarter as the "As Of" date + latest_quarter = data.get("LatestQuarter") # Format: YYYY-MM-DD + + # Calculate last trading day for "Filed/Updated" field + # Alpha Vantage data is updated on trading days, so use last weekday + from datetime import datetime as dt, timedelta + today = dt.now() + # If weekend, go back to Friday + days_since_friday = (today.weekday() - 4) % 7 + if days_since_friday > 0 and today.weekday() >= 5: # Saturday=5, Sunday=6 + last_trading_day = today - timedelta(days=days_since_friday) + else: + last_trading_day = today + fetch_time = last_trading_day.strftime("%Y-%m-%d") + + return { + "ticker": ticker.upper(), + "current_price": _safe_float(data.get("50DayMovingAverage")), + "market_cap": _safe_int(data.get("MarketCapitalization")), + "enterprise_value": None, # Not available in OVERVIEW + "trailing_pe": trailing_pe if trailing_pe and trailing_pe > 0 else None, + "forward_pe": forward_pe if forward_pe and forward_pe > 0 else None, + "ps_ratio": ps_ratio if ps_ratio and ps_ratio > 0 else None, + "pb_ratio": pb_ratio if pb_ratio and pb_ratio > 0 else None, + "ev_ebitda": ev_ebitda if ev_ebitda and ev_ebitda > 0 else None, + "trailing_peg": peg_ratio if peg_ratio and peg_ratio > 0 else None, + "forward_peg": None, + "earnings_growth": _safe_float(data.get("QuarterlyEarningsGrowthYOY")), + "revenue_growth": _safe_float(data.get("QuarterlyRevenueGrowthYOY")), + "latest_quarter": latest_quarter, # As Of date + "fetched_at": fetch_time, # Filed/Updated date + "source": "Alpha Vantage (fallback)", + "fallback": True + } + + except Exception as e: + logger.error(f"Alpha Vantage fetch error for {ticker}: {e}") + return {"error": str(e)} + + +async def fetch_alpha_vantage_quote(ticker: str) -> Optional[dict]: + """ + Fetch quote data from Alpha Vantage (fallback source). + Runs synchronous requests in thread pool. + """ + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(executor, _fetch_alpha_vantage_sync, ticker) + return result + + +def get_market_average_defaults(ticker: str) -> dict: + """ + Return market average valuation metrics as last-resort fallback. + Ensures 100% response rate even when all APIs fail. + """ + return { + "ticker": ticker.upper(), + "current_price": None, + "market_cap": None, + "enterprise_value": None, + "trailing_pe": 20.0, # S&P 500 historical average + "forward_pe": 18.0, + "ps_ratio": 2.5, + "pb_ratio": 3.0, + "ev_ebitda": 12.0, + "trailing_peg": 1.5, + "forward_peg": 1.3, + "earnings_growth": 0.08, # 8% average + "revenue_growth": 0.05, # 5% average + "source": "Market Averages (estimated)", + "fallback": True, + "fallback_reason": "All valuation data sources unavailable - using S&P 500 historical averages", + "estimated": True + } + + async def fetch_yahoo_quote(ticker: str) -> Optional[dict]: """ Fetch quote data from Yahoo Finance via yfinance library. @@ -172,7 +310,7 @@ async def fetch_pe_ratio(ticker: str) -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": data.get("regular_market_time") or datetime.now().strftime("%Y-%m-%d") } @@ -219,7 +357,7 @@ async def fetch_ps_ratio(ticker: str) -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": data.get("regular_market_time") or datetime.now().strftime("%Y-%m-%d") } @@ -263,7 +401,7 @@ async def fetch_pb_ratio(ticker: str) -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": data.get("regular_market_time") or datetime.now().strftime("%Y-%m-%d") } @@ -314,7 +452,7 @@ async def fetch_ev_ebitda(ticker: str) -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": data.get("regular_market_time") or datetime.now().strftime("%Y-%m-%d") } @@ -370,7 +508,7 @@ async def fetch_peg_ratio(ticker: str) -> dict: "note": "PEG < 1 often considered undervalued", "swot_category": swot_impact, "source": data["source"], - "as_of": datetime.now().isoformat() + "as_of": data.get("regular_market_time") or datetime.now().strftime("%Y-%m-%d") } @@ -378,15 +516,20 @@ async def get_full_valuation_basket(ticker: str) -> dict: """ Fetch all valuation metrics for a given ticker. Returns aggregated SWOT-ready data with trailing and forward PEG. + Uses fallback chain: Yahoo Finance → Alpha Vantage → Market Averages """ - # Fetch data once (to avoid multiple API calls) + # Try Yahoo Finance first data = await fetch_yahoo_quote(ticker) if "error" in data: - return { - "ticker": ticker.upper(), - "error": data["error"] - } + logger.info(f"Yahoo Finance failed for {ticker}, trying Alpha Vantage fallback") + # Fallback to Alpha Vantage + data = await fetch_alpha_vantage_quote(ticker) + + if "error" in data: + logger.info(f"Alpha Vantage failed for {ticker}, using market average defaults") + # Last resort: market average defaults + data = get_market_average_defaults(ticker) # Extract all metrics from yfinance data trailing_pe = safe_get(data, "trailing_pe") @@ -495,7 +638,65 @@ async def get_full_valuation_basket(ticker: str) -> dict: "overall_assessment": overall, "swot_summary": swot_summary, "source": "Yahoo Finance (yfinance)", - "generated_at": datetime.now().isoformat() + "generated_at": datetime.now().strftime("%Y-%m-%d") + } + + +async def get_all_sources_valuation(ticker: str) -> dict: + """ + Fetch valuation metrics from Yahoo Finance (primary) with Alpha Vantage fallback. + Returns NORMALIZED schema with 11 universal metrics. + """ + yahoo_result = await fetch_yahoo_quote(ticker) + + # Build normalized schema + sources = {} + + # Yahoo Finance as primary source (11 universal metrics, excludes ev_ebitda) + if "error" not in yahoo_result: + sources["yahoo_finance"] = { + "source": "Yahoo Finance", + "regular_market_time": yahoo_result.get("regular_market_time"), + "data": { + "current_price": safe_get(yahoo_result, "current_price"), + "market_cap": safe_get(yahoo_result, "market_cap"), + "enterprise_value": safe_get(yahoo_result, "enterprise_value"), + "trailing_pe": safe_get(yahoo_result, "trailing_pe"), + "forward_pe": safe_get(yahoo_result, "forward_pe"), + "ps_ratio": safe_get(yahoo_result, "ps_ratio"), + "pb_ratio": safe_get(yahoo_result, "pb_ratio"), + "trailing_peg": safe_get(yahoo_result, "trailing_peg"), + "forward_peg": safe_get(yahoo_result, "forward_peg"), + "earnings_growth": safe_get(yahoo_result, "earnings_growth"), + "revenue_growth": safe_get(yahoo_result, "revenue_growth"), + } + } + else: + # Fallback to Alpha Vantage if Yahoo Finance fails + alpha_result = await fetch_alpha_vantage_quote(ticker) + if alpha_result and "error" not in alpha_result: + sources["alpha_vantage"] = { + "source": "Alpha Vantage", + "latest_quarter": alpha_result.get("latest_quarter"), + "data": { + "current_price": safe_get(alpha_result, "current_price"), + "market_cap": safe_get(alpha_result, "market_cap"), + "trailing_pe": safe_get(alpha_result, "trailing_pe"), + "forward_pe": safe_get(alpha_result, "forward_pe"), + "ps_ratio": safe_get(alpha_result, "ps_ratio"), + "pb_ratio": safe_get(alpha_result, "pb_ratio"), + "trailing_peg": safe_get(alpha_result, "trailing_peg"), + "earnings_growth": safe_get(alpha_result, "earnings_growth"), + "revenue_growth": safe_get(alpha_result, "revenue_growth"), + } + } + + return { + "group": "source_comparison", + "ticker": ticker.upper(), + "sources": sources, + "source": "valuation-basket", + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -590,38 +791,107 @@ async def list_tools(): }, "required": ["ticker"] } + ), + Tool( + name="get_all_sources_valuation", + description="Get valuation from ALL sources (Yahoo Finance + Alpha Vantage) for side-by-side comparison.", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } ) ] +# Global timeout for all tool operations (seconds) +TOOL_TIMEOUT = 45.0 + + +async def _execute_tool_with_timeout(name: str, ticker: str, arguments: dict) -> dict: + """Execute a tool with timeout. Returns result dict or error dict.""" + if name == "get_pe_ratio": + return await fetch_pe_ratio(ticker) + elif name == "get_ps_ratio": + return await fetch_ps_ratio(ticker) + elif name == "get_pb_ratio": + return await fetch_pb_ratio(ticker) + elif name == "get_ev_ebitda": + return await fetch_ev_ebitda(ticker) + elif name == "get_peg_ratio": + return await fetch_peg_ratio(ticker) + elif name == "get_valuation_basket": + return await get_full_valuation_basket(ticker) + elif name == "get_all_sources_valuation": + return await get_all_sources_valuation(ticker) + else: + return {"error": f"Unknown tool: {name}"} + + @server.call_tool() async def call_tool(name: str, arguments: dict): - """Handle tool invocations.""" + """ + Handle tool invocations with GUARANTEED JSON-RPC response. + + This function ALWAYS returns a valid TextContent response, even if: + - External APIs timeout + - Exceptions occur during processing + - Any unexpected error happens + + This ensures MCP protocol compliance and prevents client hangs. + """ try: ticker = arguments.get("ticker", "").upper() if not ticker and name != "get_macro_basket": - return [TextContent(type="text", text="Error: ticker is required")] - - if name == "get_pe_ratio": - result = await fetch_pe_ratio(ticker) - elif name == "get_ps_ratio": - result = await fetch_ps_ratio(ticker) - elif name == "get_pb_ratio": - result = await fetch_pb_ratio(ticker) - elif name == "get_ev_ebitda": - result = await fetch_ev_ebitda(ticker) - elif name == "get_peg_ratio": - result = await fetch_peg_ratio(ticker) - elif name == "get_valuation_basket": - result = await get_full_valuation_basket(ticker) - else: - return [TextContent(type="text", text=f"Unknown tool: {name}")] + return [TextContent(type="text", text=json.dumps({ + "error": "ticker is required", + "ticker": None, + "source": "valuation-basket" + }))] + + # Execute tool with global timeout + try: + result = await asyncio.wait_for( + _execute_tool_with_timeout(name, ticker, arguments), + timeout=TOOL_TIMEOUT + ) + except asyncio.TimeoutError: + logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s for {ticker}") + result = { + "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds", + "ticker": ticker, + "tool": name, + "source": "valuation-basket", + "fallback": True + } + + # Ensure result is JSON serializable + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] - return [TextContent(type="text", text=json.dumps(result, indent=2))] + except json.JSONDecodeError as e: + logger.error(f"JSON serialization error for {name}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"JSON serialization failed: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "valuation-basket" + }))] except Exception as e: - logger.error(f"Tool error {name}: {e}") - return [TextContent(type="text", text=f"Error: {str(e)}")] + # Catch-all: ALWAYS return valid JSON-RPC response + logger.error(f"Unexpected error in {name}: {type(e).__name__}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"{type(e).__name__}: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "valuation-basket", + "fallback": True + }))] # ============================================================ diff --git a/mcp-servers/volatility-basket/server.py b/mcp-servers/volatility-basket/server.py index 0804a96c155f976a363b86d243b44b0dee6ae6db..163b3508145b264513df75c558c3f621f14a6da6 100644 --- a/mcp-servers/volatility-basket/server.py +++ b/mcp-servers/volatility-basket/server.py @@ -17,8 +17,9 @@ Or via MCP: import asyncio import json import logging +import math import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Optional import statistics @@ -54,6 +55,13 @@ server = Server("volatility-basket") # API Keys (optional - enables authoritative sources) FRED_API_KEY = os.getenv("FRED_API_KEY") or os.getenv("FRED_VIX_API_KEY") # Get free key: https://fred.stlouisfed.org/docs/api/api_key.html ALPHA_VANTAGE_KEY = os.getenv("ALPHA_VANTAGE_API_KEY") # Get free key: https://www.alphavantage.co/support/#api-key +TRADIER_API_KEY = os.getenv("TRADIER_API_KEY") # Get free key: https://developer.tradier.com/ + +# Alpha Vantage API configuration (Secondary for Beta, Historical Volatility) +ALPHA_VANTAGE_BASE_URL = "https://www.alphavantage.co/query" + +# Tradier API configuration (Primary for Implied Volatility) +TRADIER_BASE_URL = "https://api.tradier.com/v1" # Yahoo Finance requires browser-like headers YAHOO_HEADERS = { @@ -142,21 +150,153 @@ async def fetch_vix_from_yahoo() -> Optional[dict]: return None +def get_default_vix() -> dict: + """Return reasonable VIX default when all sources fail.""" + return { + "value": 20.0, # Historical average around 20 + "previous_close": 20.0, + "source": "Market Average (estimated)", + "fallback": True, + "fallback_reason": "All VIX sources unavailable", + "estimated": True + } + + +# ============================================================ +# VXN (NASDAQ-100 VOLATILITY INDEX) FETCHERS +# ============================================================ + +async def fetch_vxn_from_fred() -> Optional[dict]: + """ + Fetch VXN (Nasdaq-100 Volatility Index) from FRED. + VXN is to Nasdaq-100 what VIX is to S&P 500. + Series ID: VXNCLS + """ + if not FRED_API_KEY: + return None + + try: + async with httpx.AsyncClient() as client: + url = "https://api.stlouisfed.org/fred/series/observations" + params = { + "series_id": "VXNCLS", + "api_key": FRED_API_KEY, + "file_type": "json", + "sort_order": "desc", + "limit": 5 + } + response = await client.get(url, params=params, timeout=10) + data = response.json() + + observations = data.get("observations", []) + if not observations: + return None + + # Get latest non-null value + for obs in observations: + if obs.get("value") and obs["value"] != ".": + current_price = float(obs["value"]) + break + else: + return None + + # Get previous for change calculation + previous_close = current_price + if len(observations) > 1 and observations[1].get("value") != ".": + previous_close = float(observations[1]["value"]) + + return { + "value": current_price, + "previous_close": previous_close, + "source": "FRED (Federal Reserve)", + "date": observations[0].get("date") + } + except Exception as e: + logger.error(f"FRED VXN fetch error: {e}") + return None + + +def get_default_vxn() -> dict: + """Return reasonable VXN default when all sources fail.""" + return { + "value": 22.0, # VXN typically runs slightly higher than VIX + "previous_close": 22.0, + "source": "Market Average (estimated)", + "fallback": True, + "fallback_reason": "All VXN sources unavailable", + "estimated": True + } + + +async def fetch_vxn() -> dict: + """ + Fetch VXN (Nasdaq-100 Volatility Index) with fallback chain. + VXN measures expected volatility of Nasdaq-100 index. + Use for Nasdaq stocks as market context. + """ + vxn_data = await fetch_vxn_from_fred() + + if not vxn_data: + logger.info("FRED VXN failed, using default VXN value") + vxn_data = get_default_vxn() + + current_price = vxn_data["value"] + previous_close = vxn_data["previous_close"] + is_fallback = vxn_data.get("fallback", False) + + # VXN interpretation thresholds (similar to VIX but slightly higher typical values) + if current_price < 17: + interpretation = "Low volatility - Complacent tech market" + swot_impact = "OPPORTUNITY" + elif current_price < 22: + interpretation = "Normal volatility - Stable tech conditions" + swot_impact = "NEUTRAL" + elif current_price < 32: + interpretation = "Elevated volatility - Tech sector uncertainty" + swot_impact = "THREAT" + else: + interpretation = "High volatility - Tech fear/crisis mode" + swot_impact = "SEVERE_THREAT" + + # Use actual observation date from FRED, not query time + observation_date = vxn_data.get("date") # YYYY-MM-DD from FRED + + result = { + "metric": "VXN", + "description": "Nasdaq-100 Volatility Index", + "value": round(current_price, 2), + "previous_close": round(previous_close, 2), + "change_pct": round((current_price - previous_close) / previous_close * 100, 2) if previous_close else 0, + "interpretation": interpretation, + "swot_category": swot_impact, + "source": vxn_data["source"], + "as_of": observation_date or datetime.now().strftime("%Y-%m-%d") + } + if is_fallback: + result["fallback"] = True + result["fallback_reason"] = vxn_data.get("fallback_reason", "Primary source unavailable") + result["estimated"] = vxn_data.get("estimated", False) + return result + + async def fetch_vix() -> dict: """ - Fetch VIX index with fallback chain: FRED → Yahoo Finance. + Fetch VIX index with fallback chain: FRED → Yahoo Finance → Default. Returns current VIX level and interpretation. """ # Try FRED first (authoritative), fallback to Yahoo vix_data = await fetch_vix_from_fred() if not vix_data: + logger.info("FRED VIX failed, trying Yahoo fallback") vix_data = await fetch_vix_from_yahoo() if not vix_data: - return {"metric": "VIX", "error": "All sources failed"} + logger.info("Yahoo VIX failed, using default VIX value") + vix_data = get_default_vix() current_price = vix_data["value"] previous_close = vix_data["previous_close"] + is_fallback = vix_data.get("fallback", False) # VIX interpretation thresholds if current_price < 15: @@ -172,7 +312,10 @@ async def fetch_vix() -> dict: interpretation = "High volatility - Fear/crisis mode" swot_impact = "SEVERE_THREAT" - return { + # Use actual observation date from FRED, not query time + observation_date = vix_data.get("date") # YYYY-MM-DD from FRED + + result = { "metric": "VIX", "value": round(current_price, 2), "previous_close": round(previous_close, 2), @@ -180,7 +323,30 @@ async def fetch_vix() -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": vix_data["source"], - "as_of": datetime.now().isoformat() + "as_of": observation_date or datetime.now().strftime("%Y-%m-%d") + } + if is_fallback: + result["fallback"] = True + result["fallback_reason"] = vix_data.get("fallback_reason", "Primary source unavailable") + result["estimated"] = vix_data.get("estimated", False) + return result + + +def get_default_beta(ticker: str) -> dict: + """Return market average beta when calculation fails.""" + return { + "metric": "Beta", + "ticker": ticker.upper(), + "value": 1.0, # Market average beta + "benchmark": "S&P 500", + "period": "1 year", + "interpretation": "Market beta - Moves with the market (estimated)", + "swot_category": "NEUTRAL", + "source": "Market Average (estimated)", + "fallback": True, + "fallback_reason": "Unable to calculate beta from price data", + "estimated": True, + "as_of": datetime.now().strftime("%Y-%m-%d") } @@ -216,7 +382,8 @@ async def fetch_beta(ticker: str) -> dict: market_closes = market_closes[-min_len:] if len(stock_closes) < 30: - return {"metric": "Beta", "ticker": ticker, "error": "Insufficient data"} + logger.info(f"Insufficient data for beta calculation for {ticker}, using default") + return get_default_beta(ticker) # Calculate daily returns stock_returns = [(stock_closes[i] - stock_closes[i-1]) / stock_closes[i-1] @@ -250,6 +417,10 @@ async def fetch_beta(ticker: str) -> dict: interpretation = "Very high beta - Significantly more volatile" swot_impact = "WEAKNESS" + # Get actual data end date from timestamps (use UTC for correct trading date) + timestamps = stock_data.get("timestamp", []) + data_end_date = datetime.fromtimestamp(timestamps[-1], tz=timezone.utc).strftime("%Y-%m-%d") if timestamps else datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + return { "metric": "Beta", "ticker": ticker.upper(), @@ -259,11 +430,29 @@ async def fetch_beta(ticker: str) -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": "Calculated from Yahoo Finance data", - "as_of": datetime.now().isoformat() + "as_of": data_end_date } except Exception as e: logger.error(f"Beta fetch error for {ticker}: {e}") - return {"metric": "Beta", "ticker": ticker, "error": str(e)} + return get_default_beta(ticker) + + +def get_default_historical_volatility(ticker: str) -> dict: + """Return market average historical volatility when calculation fails.""" + return { + "metric": "Historical Volatility", + "ticker": ticker.upper(), + "value": 25.0, # Typical market average ~25% annualized + "unit": "% annualized", + "period_days": 30, + "interpretation": "Moderate volatility - Normal for equities (estimated)", + "swot_category": "NEUTRAL", + "source": "Market Average (estimated)", + "fallback": True, + "fallback_reason": "Unable to calculate historical volatility", + "estimated": True, + "as_of": datetime.now().strftime("%Y-%m-%d") + } async def fetch_historical_volatility(ticker: str, period_days: int = 30) -> dict: @@ -285,7 +474,8 @@ async def fetch_historical_volatility(ticker: str, period_days: int = 30) -> dic closes = [c for c in closes if c is not None][-period_days:] if len(closes) < 10: - return {"metric": "Historical Volatility", "ticker": ticker, "error": "Insufficient data"} + logger.info(f"Insufficient data for HV calculation for {ticker}, using default") + return get_default_historical_volatility(ticker) # Calculate daily returns returns = [(closes[i] - closes[i-1]) / closes[i-1] for i in range(1, len(closes))] @@ -308,6 +498,10 @@ async def fetch_historical_volatility(ticker: str, period_days: int = 30) -> dic interpretation = "Very high volatility - Extreme price movements" swot_impact = "WEAKNESS" + # Get actual data end date from timestamps (use UTC for correct trading date) + timestamps = result.get("timestamp", []) + data_end_date = datetime.fromtimestamp(timestamps[-1], tz=timezone.utc).strftime("%Y-%m-%d") if timestamps else datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + return { "metric": "Historical Volatility", "ticker": ticker.upper(), @@ -317,11 +511,30 @@ async def fetch_historical_volatility(ticker: str, period_days: int = 30) -> dic "interpretation": interpretation, "swot_category": swot_impact, "source": "Calculated from Yahoo Finance data", - "as_of": datetime.now().isoformat() + "as_of": data_end_date } except Exception as e: logger.error(f"Historical volatility error for {ticker}: {e}") - return {"metric": "Historical Volatility", "ticker": ticker, "error": str(e)} + return get_default_historical_volatility(ticker) + + +def get_default_implied_volatility(ticker: str) -> dict: + """Return estimated implied volatility when options data unavailable.""" + return { + "metric": "Implied Volatility", + "ticker": ticker.upper(), + "value": 30.0, # Typical IV for liquid stocks + "unit": "%", + "strike": None, + "expiration": None, + "interpretation": "Moderate IV - Normal expected movement (estimated)", + "swot_category": "NEUTRAL", + "source": "Market Average (estimated)", + "fallback": True, + "fallback_reason": "Options data unavailable", + "estimated": True, + "as_of": datetime.now().strftime("%Y-%m-%d") + } async def fetch_implied_volatility_proxy(ticker: str) -> dict: @@ -343,13 +556,15 @@ async def fetch_implied_volatility_proxy(ticker: str) -> dict: options_data = options_resp.json() if "optionChain" not in options_data or not options_data["optionChain"]["result"]: - return {"metric": "Implied Volatility", "ticker": ticker, "error": "No options data"} + logger.info(f"No options data for {ticker}, using default IV") + return get_default_implied_volatility(ticker) result = options_data["optionChain"]["result"][0] calls = result.get("options", [{}])[0].get("calls", []) if not calls: - return {"metric": "Implied Volatility", "ticker": ticker, "error": "No calls data"} + logger.info(f"No calls data for {ticker}, using default IV") + return get_default_implied_volatility(ticker) # Find ATM option (closest to current price) atm_call = min(calls, key=lambda x: abs(x.get("strike", 0) - current_price)) @@ -369,6 +584,10 @@ async def fetch_implied_volatility_proxy(ticker: str) -> dict: interpretation = "Very high IV - Extreme movement expected (earnings, event)" swot_impact = "THREAT" + # Get quote date from regularMarketTime (use UTC for correct trading date) + market_time = quote_data["chart"]["result"][0]["meta"].get("regularMarketTime", 0) + quote_date = datetime.fromtimestamp(market_time, tz=timezone.utc).strftime("%Y-%m-%d") if market_time else datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + return { "metric": "Implied Volatility", "ticker": ticker.upper(), @@ -379,11 +598,353 @@ async def fetch_implied_volatility_proxy(ticker: str) -> dict: "interpretation": interpretation, "swot_category": swot_impact, "source": "Yahoo Finance Options", - "as_of": datetime.now().isoformat() + "as_of": quote_date } except Exception as e: logger.error(f"IV fetch error for {ticker}: {e}") - return {"metric": "Implied Volatility", "ticker": ticker, "error": str(e)} + return get_default_implied_volatility(ticker) + + +# ============================================================ +# ALPHA VANTAGE FETCHERS (Secondary for Beta, Historical Vol) +# ============================================================ + +async def fetch_alpha_vantage_beta(ticker: str) -> Optional[dict]: + """ + Fetch Beta from Alpha Vantage OVERVIEW endpoint. + Secondary source for Beta validation. + """ + if not ALPHA_VANTAGE_KEY: + return None + + try: + async with httpx.AsyncClient() as client: + params = { + "function": "OVERVIEW", + "symbol": ticker.upper(), + "apikey": ALPHA_VANTAGE_KEY + } + response = await client.get(ALPHA_VANTAGE_BASE_URL, params=params, timeout=15) + data = response.json() + + if "Beta" not in data or not data.get("Beta"): + return None + + beta = float(data["Beta"]) + + # Beta interpretation + if beta < 0.8: + interpretation = "Low beta - Defensive stock, less volatile than market" + swot_impact = "STRENGTH" + elif beta < 1.2: + interpretation = "Market beta - Moves with the market" + swot_impact = "NEUTRAL" + elif beta < 1.5: + interpretation = "High beta - More volatile than market" + swot_impact = "WEAKNESS" + else: + interpretation = "Very high beta - Significantly more volatile" + swot_impact = "WEAKNESS" + + return { + "metric": "Beta", + "ticker": ticker.upper(), + "value": round(beta, 3), + "benchmark": "S&P 500", + "period": "5 year monthly", + "interpretation": interpretation, + "swot_category": swot_impact, + "source": "Alpha Vantage", + "as_of": data.get("LatestQuarter", datetime.now().strftime("%Y-%m-%d")) + } + + except Exception as e: + logger.error(f"Alpha Vantage Beta fetch error for {ticker}: {e}") + return None + + +async def fetch_alpha_vantage_historical_volatility(ticker: str, period_days: int = 30) -> Optional[dict]: + """ + Calculate historical volatility from Alpha Vantage daily prices. + Secondary source for Historical Volatility validation. + Formula: std(log returns) × sqrt(252) + """ + if not ALPHA_VANTAGE_KEY: + return None + + try: + async with httpx.AsyncClient() as client: + params = { + "function": "TIME_SERIES_DAILY", + "symbol": ticker.upper(), + "outputsize": "compact", # Last 100 data points + "apikey": ALPHA_VANTAGE_KEY + } + response = await client.get(ALPHA_VANTAGE_BASE_URL, params=params, timeout=15) + data = response.json() + + time_series = data.get("Time Series (Daily)", {}) + if not time_series: + return None + + # Get sorted dates (most recent first) + dates = sorted(time_series.keys(), reverse=True)[:period_days + 1] + + if len(dates) < 10: + return None + + # Get closing prices + closes = [float(time_series[d]["4. close"]) for d in dates] + + # Calculate log returns + log_returns = [math.log(closes[i] / closes[i + 1]) for i in range(len(closes) - 1)] + + # Calculate standard deviation and annualize + daily_vol = statistics.stdev(log_returns) + annual_vol = daily_vol * math.sqrt(252) * 100 # As percentage + + # Interpretation + if annual_vol < 20: + interpretation = "Low historical volatility - Stable price action" + swot_impact = "STRENGTH" + elif annual_vol < 35: + interpretation = "Moderate volatility - Normal for equities" + swot_impact = "NEUTRAL" + elif annual_vol < 50: + interpretation = "High volatility - Significant price swings" + swot_impact = "WEAKNESS" + else: + interpretation = "Very high volatility - Extreme price movements" + swot_impact = "WEAKNESS" + + # Use most recent date from time series + data_end_date = dates[0] if dates else datetime.now().strftime("%Y-%m-%d") + + return { + "metric": "Historical Volatility", + "ticker": ticker.upper(), + "value": round(annual_vol, 2), + "unit": "% annualized", + "period_days": period_days, + "interpretation": interpretation, + "swot_category": swot_impact, + "source": "Alpha Vantage (calculated)", + "as_of": data_end_date + } + + except Exception as e: + logger.error(f"Alpha Vantage HV fetch error for {ticker}: {e}") + return None + + +# ============================================================ +# TRADIER FETCHERS (Primary/Secondary for Implied Volatility) +# ============================================================ + +async def fetch_tradier_implied_volatility(ticker: str) -> Optional[dict]: + """ + Fetch implied volatility from Tradier options chain. + Provides stock-specific IV from ATM options. + + API: https://developer.tradier.com/ + Requires free account creation. + """ + if not TRADIER_API_KEY: + return None + + try: + async with httpx.AsyncClient() as client: + headers = { + "Authorization": f"Bearer {TRADIER_API_KEY}", + "Accept": "application/json" + } + + # First get current quote for ATM strike + quote_url = f"{TRADIER_BASE_URL}/markets/quotes" + quote_params = {"symbols": ticker.upper()} + quote_resp = await client.get(quote_url, params=quote_params, headers=headers, timeout=10) + quote_data = quote_resp.json() + + quotes = quote_data.get("quotes", {}).get("quote", {}) + if isinstance(quotes, list): + quotes = quotes[0] if quotes else {} + + current_price = quotes.get("last", 0) or quotes.get("close", 0) + if not current_price: + return None + + # Get options expirations + exp_url = f"{TRADIER_BASE_URL}/markets/options/expirations" + exp_params = {"symbol": ticker.upper()} + exp_resp = await client.get(exp_url, params=exp_params, headers=headers, timeout=10) + exp_data = exp_resp.json() + + expirations = exp_data.get("expirations", {}).get("date", []) + if not expirations: + return None + + # Use nearest expiration + nearest_exp = expirations[0] if isinstance(expirations, list) else expirations + + # Get options chain + chain_url = f"{TRADIER_BASE_URL}/markets/options/chains" + chain_params = { + "symbol": ticker.upper(), + "expiration": nearest_exp, + "greeks": "true" + } + chain_resp = await client.get(chain_url, params=chain_params, headers=headers, timeout=10) + chain_data = chain_resp.json() + + options = chain_data.get("options", {}).get("option", []) + if not options: + return None + + # Filter calls and find ATM + calls = [o for o in options if o.get("option_type") == "call"] + if not calls: + return None + + # Find ATM call (closest to current price) + atm_call = min(calls, key=lambda x: abs(x.get("strike", 0) - current_price)) + + # Get IV from greeks + greeks = atm_call.get("greeks", {}) + iv = greeks.get("mid_iv", 0) or greeks.get("ask_iv", 0) or greeks.get("bid_iv", 0) + + if not iv: + # Fallback to smv_vol if available + iv = greeks.get("smv_vol", 0) + + if not iv: + return None + + iv_pct = iv * 100 # Convert to percentage + + # Interpretation + if iv_pct < 25: + interpretation = "Low IV - Market expects limited price movement" + swot_impact = "OPPORTUNITY" + elif iv_pct < 40: + interpretation = "Moderate IV - Normal expected movement" + swot_impact = "NEUTRAL" + elif iv_pct < 60: + interpretation = "High IV - Market expects significant movement" + swot_impact = "THREAT" + else: + interpretation = "Very high IV - Extreme movement expected (earnings, event)" + swot_impact = "THREAT" + + # Use quote trade_date if available, else today + trade_date = quote.get("trade_date", datetime.now().strftime("%Y-%m-%d")) + if isinstance(trade_date, str) and "T" in trade_date: + trade_date = trade_date.split("T")[0] + + return { + "metric": "Implied Volatility", + "ticker": ticker.upper(), + "value": round(iv_pct, 2), + "unit": "%", + "strike": atm_call.get("strike"), + "expiration": nearest_exp, + "interpretation": interpretation, + "swot_category": swot_impact, + "source": "Tradier", + "as_of": trade_date + } + + except Exception as e: + logger.error(f"Tradier IV fetch error for {ticker}: {e}") + return None + + +# ============================================================ +# MULTI-SOURCE AGGREGATOR +# ============================================================ + +async def get_all_sources_volatility(ticker: str) -> dict: + """ + Fetch volatility from ALL sources in parallel. + Returns NORMALIZED schema for interpreted_metrics group. + + Source hierarchy: + - VIX: FRED (primary) - S&P 500 market volatility context + - VXN: FRED (primary) - Nasdaq-100 market volatility context + - Beta: Yahoo Finance (primary) → Alpha Vantage (secondary) + - Historical Vol: Yahoo Finance (primary) → Alpha Vantage (secondary) + - Implied Vol: Yahoo Finance Options (primary) + """ + # Fetch from all sources in parallel + vix_task = fetch_vix() + vxn_task = fetch_vxn() # Nasdaq-100 volatility index + + # Beta: Yahoo (primary) + Alpha Vantage (secondary) + yahoo_beta_task = fetch_beta(ticker) + av_beta_task = fetch_alpha_vantage_beta(ticker) + + # Historical Volatility: Yahoo (primary) + Alpha Vantage (secondary) + yahoo_hv_task = fetch_historical_volatility(ticker) + av_hv_task = fetch_alpha_vantage_historical_volatility(ticker) + + # Implied Volatility: Yahoo Options (primary) + yahoo_iv_task = fetch_implied_volatility_proxy(ticker) + + (vix, vxn, yahoo_beta, av_beta, yahoo_hv, av_hv, yahoo_iv) = await asyncio.gather( + vix_task, vxn_task, + yahoo_beta_task, av_beta_task, + yahoo_hv_task, av_hv_task, + yahoo_iv_task + ) + + # Use primary source, fallback to secondary if primary failed + beta = yahoo_beta if "error" not in yahoo_beta else (av_beta or yahoo_beta) + hv = yahoo_hv if "error" not in yahoo_hv else (av_hv or yahoo_hv) + iv = yahoo_iv + + # Build normalized raw_metrics schema with temporal data + return { + "group": "raw_metrics", + "ticker": ticker.upper(), + "metrics": { + "vix": { + "value": vix.get("value"), + "data_type": "Daily", + "as_of": vix.get("as_of"), # FRED observation date + "source": vix.get("source"), + "fallback": vix.get("fallback", False) + }, + "vxn": { + "value": vxn.get("value"), + "data_type": "Daily", + "as_of": vxn.get("as_of"), # FRED observation date + "source": vxn.get("source"), + "fallback": vxn.get("fallback", False) + }, + "beta": { + "value": beta.get("value") if beta else None, + "data_type": "1Y", # 1 year lookback + "as_of": beta.get("as_of") if beta else None, + "source": beta.get("source") if beta else None, + "fallback": beta.get("fallback", False) if beta else True + }, + "historical_volatility": { + "value": hv.get("value") if hv else None, + "data_type": "30D", # 30 day lookback + "as_of": hv.get("as_of") if hv else None, + "source": hv.get("source") if hv else None, + "fallback": hv.get("fallback", False) if hv else True + }, + "implied_volatility": { + "value": iv.get("value") if iv else None, + "data_type": "Forward", # Forward-looking from options + "as_of": iv.get("as_of") if iv else None, + "source": iv.get("source") if iv else None, + "fallback": iv.get("fallback", False) if iv else True + } + }, + "source": "volatility-basket", + "as_of": datetime.now().strftime("%Y-%m-%d") + } async def get_full_volatility_basket(ticker: str) -> dict: @@ -445,7 +1006,16 @@ async def list_tools(): return [ Tool( name="get_vix", - description="Get current VIX (CBOE Volatility Index) level with SWOT interpretation. Indicates market-wide fear/greed.", + description="Get current VIX (S&P 500 Volatility Index) level with SWOT interpretation. Indicates market-wide fear/greed.", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + Tool( + name="get_vxn", + description="Get current VXN (Nasdaq-100 Volatility Index) level with SWOT interpretation. Use for tech/Nasdaq stocks.", inputSchema={ "type": "object", "properties": {}, @@ -512,45 +1082,113 @@ async def list_tools(): }, "required": ["ticker"] } + ), + Tool( + name="get_all_sources_volatility", + description="Get volatility from ALL sources (Yahoo + Alpha Vantage) with VIX/VXN market context for side-by-side comparison.", + inputSchema={ + "type": "object", + "properties": { + "ticker": { + "type": "string", + "description": "Stock ticker symbol" + } + }, + "required": ["ticker"] + } ) ] +# Global timeout for all tool operations (seconds) +TOOL_TIMEOUT = 45.0 + + +async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict: + """Execute a tool with timeout. Returns result dict or error dict.""" + ticker = arguments.get("ticker", "").upper() + + if name == "get_vix": + return await fetch_vix() + elif name == "get_vxn": + return await fetch_vxn() + elif name == "get_beta": + if not ticker: + return {"error": "ticker is required"} + return await fetch_beta(ticker) + elif name == "get_historical_volatility": + if not ticker: + return {"error": "ticker is required"} + period = arguments.get("period_days", 30) + return await fetch_historical_volatility(ticker, period) + elif name == "get_implied_volatility": + if not ticker: + return {"error": "ticker is required"} + return await fetch_implied_volatility_proxy(ticker) + elif name == "get_volatility_basket": + if not ticker: + return {"error": "ticker is required"} + return await get_full_volatility_basket(ticker) + elif name == "get_all_sources_volatility": + if not ticker: + return {"error": "ticker is required"} + return await get_all_sources_volatility(ticker) + else: + return {"error": f"Unknown tool: {name}"} + + @server.call_tool() async def call_tool(name: str, arguments: dict): - """Handle tool invocations.""" + """ + Handle tool invocations with GUARANTEED JSON-RPC response. + + This function ALWAYS returns a valid TextContent response, even if: + - External APIs timeout + - Exceptions occur during processing + - Any unexpected error happens + + This ensures MCP protocol compliance and prevents client hangs. + """ try: - if name == "get_vix": - result = await fetch_vix() - elif name == "get_beta": - ticker = arguments.get("ticker", "").upper() - if not ticker: - return [TextContent(type="text", text="Error: ticker is required")] - result = await fetch_beta(ticker) - elif name == "get_historical_volatility": - ticker = arguments.get("ticker", "").upper() - period = arguments.get("period_days", 30) - if not ticker: - return [TextContent(type="text", text="Error: ticker is required")] - result = await fetch_historical_volatility(ticker, period) - elif name == "get_implied_volatility": - ticker = arguments.get("ticker", "").upper() - if not ticker: - return [TextContent(type="text", text="Error: ticker is required")] - result = await fetch_implied_volatility_proxy(ticker) - elif name == "get_volatility_basket": - ticker = arguments.get("ticker", "").upper() - if not ticker: - return [TextContent(type="text", text="Error: ticker is required")] - result = await get_full_volatility_basket(ticker) - else: - return [TextContent(type="text", text=f"Unknown tool: {name}")] - - return [TextContent(type="text", text=json.dumps(result, indent=2))] + # Execute tool with global timeout + try: + result = await asyncio.wait_for( + _execute_tool_with_timeout(name, arguments), + timeout=TOOL_TIMEOUT + ) + except asyncio.TimeoutError: + ticker = arguments.get("ticker", "") + logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s for {ticker}") + result = { + "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds", + "ticker": ticker, + "tool": name, + "source": "volatility-basket", + "fallback": True + } + + # Ensure result is JSON serializable + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + + except json.JSONDecodeError as e: + logger.error(f"JSON serialization error for {name}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"JSON serialization failed: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "volatility-basket" + }))] except Exception as e: - logger.error(f"Tool error {name}: {e}") - return [TextContent(type="text", text=f"Error: {str(e)}")] + # Catch-all: ALWAYS return valid JSON-RPC response + logger.error(f"Unexpected error in {name}: {type(e).__name__}: {e}") + return [TextContent(type="text", text=json.dumps({ + "error": f"{type(e).__name__}: {str(e)}", + "ticker": arguments.get("ticker", ""), + "tool": name, + "source": "volatility-basket", + "fallback": True + }))] # ============================================================ diff --git a/mcp_client.py b/mcp_client.py index 4c3806ccbad10d2469bdeba75d9eb18f7d121ab5..3dd759e7355e398a43e19fa9f58edec41298642d 100644 --- a/mcp_client.py +++ b/mcp_client.py @@ -7,6 +7,8 @@ Implements proper MCP handshake: 3. Send 'initialized' notification 4. Send 'tools/call' request 5. Parse response + +Also supports HTTP-based load-balanced calls for fundamentals-basket. """ import asyncio @@ -17,13 +19,90 @@ from pathlib import Path from datetime import datetime from typing import Optional, Callable, Any +import httpx + logger = logging.getLogger(__name__) # Base path for MCP servers MCP_SERVERS_PATH = Path(__file__).parent / "mcp-servers" # Configurable delay for granular progress events (ms) -METRIC_DELAY_MS = int(os.getenv("METRIC_DELAY_MS", "300")) +# Set to 0 for completeness-first mode (no artificial UI delays) +METRIC_DELAY_MS = int(os.getenv("METRIC_DELAY_MS", "0")) + +# ============================================================================= +# HTTP LOAD BALANCER CONFIGURATION +# ============================================================================= + +# Financials HTTP load balancer URL (nginx on port 8080) +FINANCIALS_HTTP_URL = os.getenv("FINANCIALS_HTTP_URL", "http://localhost:8080") + +# Toggle HTTP mode (set to "false" to use subprocess MCP) +USE_HTTP_FINANCIALS = os.getenv("USE_HTTP_FINANCIALS", "false").lower() == "true" + +# HTTP client timeout (increased for completeness-first mode) +HTTP_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "90.0")) + + +# ============================================================================= +# HTTP CLIENT FOR LOAD-BALANCED CALLS +# ============================================================================= + +async def call_fundamentals_http(tool_name: str, arguments: dict, timeout: float = None) -> dict: + """ + Call fundamentals-basket via HTTP load balancer (nginx). + + This bypasses MCP subprocess spawning for better performance. + Requires the HTTP cluster to be running (./start_cluster.sh). + + Args: + tool_name: Name of the tool (e.g., 'get_sec_fundamentals') + arguments: Tool arguments dict + timeout: Request timeout in seconds + + Returns: + Tool result dict or error dict + """ + timeout = timeout or HTTP_TIMEOUT + url = f"{FINANCIALS_HTTP_URL}/tools/{tool_name}" + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(url, json=arguments) + response.raise_for_status() + return response.json() + + except httpx.TimeoutException: + logger.error(f"HTTP timeout calling {tool_name}: {timeout}s") + return {"error": f"HTTP timeout after {timeout}s", "tool": tool_name} + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error calling {tool_name}: {e.response.status_code}") + return {"error": f"HTTP {e.response.status_code}", "tool": tool_name} + + except httpx.ConnectError: + logger.warning(f"HTTP connection failed for {tool_name}, falling back to subprocess") + # Fall back to subprocess MCP if HTTP cluster is not running + return await call_mcp_server("fundamentals-basket", tool_name, arguments, timeout) + + except Exception as e: + logger.error(f"HTTP error calling {tool_name}: {e}") + return {"error": str(e), "tool": tool_name} + + +async def check_fundamentals_http_health() -> bool: + """ + Check if the fundamentals HTTP cluster is healthy. + + Returns: + True if cluster is responding, False otherwise + """ + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{FINANCIALS_HTTP_URL}/health") + return response.status_code == 200 + except Exception: + return False async def emit_metric( @@ -54,16 +133,22 @@ async def call_mcp_server( server_name: str, tool_name: str, arguments: dict, - timeout: float = 30.0 + timeout: float = 90.0 ) -> dict: """ - Call an MCP server tool via subprocess stdio using TRUE MCP protocol. + Call an MCP server tool via subprocess stdio using proper MCP protocol sequencing. + + Protocol sequence: + 1. Send initialize request -> wait for response (id=1) + 2. Send initialized notification + 3. Send tools/call request -> wait for response (id=2) + 4. Clean up Args: - server_name: Name of the MCP server directory (e.g., 'financials-basket') + server_name: Name of the MCP server directory (e.g., 'fundamentals-basket') tool_name: Name of the tool to call (e.g., 'get_sec_fundamentals') arguments: Dict of arguments to pass to the tool - timeout: Timeout in seconds + timeout: Total timeout in seconds (default 60s for external API calls) Returns: Dict with tool result or error @@ -73,38 +158,7 @@ async def call_mcp_server( if not server_path.exists(): return {"error": f"MCP server not found: {server_name}"} - # MCP Protocol: Initialize request - init_request = { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": { - "name": "research-service", - "version": "1.0.0" - } - } - } - - # MCP Protocol: Initialized notification (no id = notification) - initialized_notification = { - "jsonrpc": "2.0", - "method": "notifications/initialized" - } - - # MCP Protocol: Tool call request - tool_request = { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments - } - } - + process = None try: # Start the MCP server process process = await asyncio.create_subprocess_exec( @@ -116,102 +170,182 @@ async def call_mcp_server( env={**os.environ} ) - # Build the full request sequence (newline-delimited JSON) - requests = ( - json.dumps(init_request) + "\n" + - json.dumps(initialized_notification) + "\n" + - json.dumps(tool_request) + "\n" - ) + async def send_message(msg: dict): + """Send a JSON-RPC message to the server.""" + data = json.dumps(msg) + "\n" + process.stdin.write(data.encode()) + await process.stdin.drain() - # Write requests to stdin WITHOUT closing it yet - process.stdin.write(requests.encode()) - await process.stdin.drain() + async def read_response(expected_id: int, phase_timeout: float) -> dict: + """Read and parse JSON-RPC response with expected id.""" + buffer = "" + start_time = asyncio.get_event_loop().time() - # Read stdout line by line until we get the tool response (id=2) - response_text = "" - tool_response = None + while True: + remaining = phase_timeout - (asyncio.get_event_loop().time() - start_time) + if remaining <= 0: + raise asyncio.TimeoutError(f"Timeout waiting for response id={expected_id}") - try: - async def read_responses(): - nonlocal response_text, tool_response - while True: + try: line = await asyncio.wait_for( process.stdout.readline(), - timeout=timeout + timeout=min(remaining, 5.0) # Check every 5s ) - if not line: - break - line_str = line.decode().strip() - response_text += line_str + "\n" - - if line_str.startswith("{"): - try: - response = json.loads(line_str) - # Look for the tool call response (id=2) - if response.get("id") == 2: - tool_response = response - return # Got what we need - except json.JSONDecodeError: - continue - - await read_responses() - - except asyncio.TimeoutError: - pass # Continue to process what we have - - # Now close stdin to signal EOF to the server - process.stdin.close() - await process.stdin.wait_closed() - - # Give the process a moment to exit gracefully - try: - await asyncio.wait_for(process.wait(), timeout=2.0) - except asyncio.TimeoutError: - process.kill() - await process.wait() - - # Read any remaining stderr - stderr_data = await process.stderr.read() - stderr_text = stderr_data.decode().strip() if stderr_data else "" - if stderr_text: - logger.debug(f"MCP server {server_name} stderr: {stderr_text[:500]}") - - # Process the tool response we captured - if tool_response: - if "result" in tool_response: - # Extract text content from MCP response - result = tool_response["result"] - if isinstance(result, dict) and "content" in result: - # MCP SDK format: {"content": [{"type": "text", "text": "..."}]} - content_list = result.get("content", []) - if content_list and isinstance(content_list, list): - for content in content_list: - if isinstance(content, dict) and content.get("type") == "text": - try: - return json.loads(content.get("text", "{}")) - except json.JSONDecodeError: - return {"raw_text": content.get("text", "")} - return result - elif "error" in tool_response: - return {"error": tool_response["error"]} - - # Fallback: no tool response captured - return {"error": f"No tool response received. stdout: {response_text[:200]}"} + except asyncio.TimeoutError: + continue # Keep trying until phase_timeout + + if not line: + # EOF - server closed stdout + raise EOFError(f"Server closed stdout before sending response id={expected_id}") + + line_str = line.decode().strip() + if not line_str: + continue + + # Try to parse as JSON + # Handle case where line might contain non-JSON prefix (logs) + json_start = line_str.find('{') + if json_start == -1: + continue + + try: + response = json.loads(line_str[json_start:]) + if isinstance(response, dict): + # Check if this is the response we're waiting for + if response.get("id") == expected_id: + return response + # Also check for error responses + if "error" in response and response.get("id") == expected_id: + return response + except json.JSONDecodeError: + # Might be partial JSON, accumulate in buffer + buffer += line_str + try: + response = json.loads(buffer) + if response.get("id") == expected_id: + return response + buffer = "" # Reset if we got valid JSON but wrong id + except json.JSONDecodeError: + pass # Keep accumulating + + # Phase 1: Initialize + init_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "research-service", "version": "1.0.0"} + } + } + await send_message(init_request) + init_response = await read_response(expected_id=1, phase_timeout=20.0) + + if "error" in init_response: + return {"error": f"Initialize failed: {init_response['error']}"} + # Phase 2: Send initialized notification (no response expected) + initialized_notification = { + "jsonrpc": "2.0", + "method": "notifications/initialized" + } + await send_message(initialized_notification) + await asyncio.sleep(0.05) # Brief pause for server to process + + # Phase 3: Tool call + tool_request = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments} + } + await send_message(tool_request) + tool_response = await read_response(expected_id=2, phase_timeout=timeout) + + # Process tool response + if "error" in tool_response: + return {"error": f"Tool call failed: {tool_response['error']}"} + + if "result" in tool_response: + result = tool_response["result"] + # MCP SDK format: {"content": [{"type": "text", "text": "..."}]} + if isinstance(result, dict) and "content" in result: + content_list = result.get("content", []) + if content_list and isinstance(content_list, list): + for content in content_list: + if isinstance(content, dict) and content.get("type") == "text": + try: + return json.loads(content.get("text", "{}")) + except json.JSONDecodeError: + return {"raw_text": content.get("text", "")} + return result + + return {"error": "No result in tool response"} + + except asyncio.TimeoutError as e: + logger.warning(f"MCP {server_name} timeout: {e}") + return {"error": f"Timeout: {e}"} + except EOFError as e: + logger.warning(f"MCP {server_name} EOF: {e}") + return {"error": f"Server closed: {e}"} except Exception as e: - logger.error(f"Error calling MCP server {server_name}: {e}") + logger.error(f"MCP {server_name} error: {e}") return {"error": str(e)} + finally: + # Clean up process + if process: + try: + process.stdin.close() + except: + pass + try: + # Give process 2s to exit gracefully + await asyncio.wait_for(process.wait(), timeout=2.0) + except asyncio.TimeoutError: + process.kill() + await process.wait() + # Log stderr if any + try: + stderr_data = await asyncio.wait_for(process.stderr.read(), timeout=1.0) + if stderr_data: + stderr_text = stderr_data.decode().strip() + if stderr_text: + logger.debug(f"MCP {server_name} stderr: {stderr_text[:500]}") + except: + pass + + +async def call_fundamentals_mcp(ticker: str) -> dict: + """ + Fetch SEC fundamentals for a ticker. - -async def call_financials_mcp(ticker: str) -> dict: - """Fetch SEC fundamentals for a ticker.""" + Uses HTTP load balancer if USE_HTTP_FINANCIALS=true, otherwise subprocess MCP. + """ + if USE_HTTP_FINANCIALS: + return await call_fundamentals_http("get_sec_fundamentals", {"ticker": ticker}) return await call_mcp_server( - "financials-basket", + "fundamentals-basket", "get_sec_fundamentals", {"ticker": ticker} ) +async def call_fundamentals_all_sources_mcp(ticker: str) -> dict: + """ + Fetch fundamentals from ALL sources (SEC EDGAR + Yahoo Finance). + + Uses HTTP load balancer if USE_HTTP_FINANCIALS=true, otherwise subprocess MCP. + """ + if USE_HTTP_FINANCIALS: + return await call_fundamentals_http("get_all_sources_fundamentals", {"ticker": ticker}) + return await call_mcp_server( + "fundamentals-basket", + "get_all_sources_fundamentals", + {"ticker": ticker} + ) + + async def call_volatility_mcp(ticker: str) -> dict: """Fetch volatility metrics for a ticker.""" return await call_mcp_server( @@ -221,6 +355,15 @@ async def call_volatility_mcp(ticker: str) -> dict: ) +async def call_volatility_all_sources_mcp(ticker: str) -> dict: + """Fetch volatility from ALL sources (Yahoo + Alpha Vantage + Tradier).""" + return await call_mcp_server( + "volatility-basket", + "get_all_sources_volatility", + {"ticker": ticker} + ) + + async def call_macro_mcp() -> dict: """Fetch macroeconomic indicators.""" return await call_mcp_server( @@ -230,6 +373,15 @@ async def call_macro_mcp() -> dict: ) +async def call_macro_all_sources_mcp() -> dict: + """Fetch macro from ALL sources (BEA/BLS primary + FRED fallback).""" + return await call_mcp_server( + "macro-basket", + "get_all_sources_macro", + {} + ) + + async def call_valuation_mcp(ticker: str) -> dict: """Fetch valuation ratios for a ticker.""" return await call_mcp_server( @@ -239,6 +391,15 @@ async def call_valuation_mcp(ticker: str) -> dict: ) +async def call_valuation_all_sources_mcp(ticker: str) -> dict: + """Fetch valuation from ALL sources (Yahoo Finance + Alpha Vantage).""" + return await call_mcp_server( + "valuation-basket", + "get_all_sources_valuation", + {"ticker": ticker} + ) + + async def call_news_mcp(ticker: str, company_name: str = "") -> dict: """Fetch news for a company.""" args = {"ticker": ticker} @@ -246,7 +407,7 @@ async def call_news_mcp(ticker: str, company_name: str = "") -> dict: args["company_name"] = company_name return await call_mcp_server( "news-basket", - "search_company_news", + "get_all_sources_news", args ) @@ -260,22 +421,173 @@ async def call_sentiment_mcp(ticker: str, company_name: str = "") -> dict: ) +# ============================================================================= +# SCHEMA NORMALIZERS +# Convert MCP-emitted schemas to analyzer-expected format +# ============================================================================= + +def _normalize_volatility(raw: dict) -> dict: + """ + Normalize volatility schema. + Input: {"metrics": {"vix": {...}, "beta": {...}, ...}} + Output: {"yahoo_finance": {"data": {...}}, "market_volatility_context": {...}} + """ + if not raw or "error" in raw: + return raw + + metrics = raw.get("metrics", {}) + + # Extract VIX/VXN for market context + vix = metrics.get("vix", {}) + vxn = metrics.get("vxn", {}) + + # Extract stock-specific metrics for yahoo_finance + beta = metrics.get("beta", {}) + hist_vol = metrics.get("historical_volatility", {}) + impl_vol = metrics.get("implied_volatility", {}) + + return { + "yahoo_finance": { + "source": "Yahoo Finance", + "data": { + "beta": beta, + "historical_volatility": hist_vol, + "implied_volatility": impl_vol, + } + }, + "market_volatility_context": { + "vix": {"value": vix.get("value"), "date": vix.get("as_of")}, + "vxn": {"value": vxn.get("value"), "date": vxn.get("as_of")}, + }, + "source": raw.get("source"), + "as_of": raw.get("as_of"), + } + + +def _normalize_macro(raw: dict) -> dict: + """ + Normalize macro schema. + Input: {"metrics": {"gdp_growth": {...}, "interest_rate": {...}, ...}} + Output: {"bea_bls": {"data": {...}}, "fred": {"data": {...}}} + """ + if not raw or "error" in raw: + return raw + + metrics = raw.get("metrics", {}) + + gdp = metrics.get("gdp_growth", {}) + cpi = metrics.get("cpi_inflation", {}) + unemp = metrics.get("unemployment", {}) + interest = metrics.get("interest_rate", {}) + + # BEA/BLS: GDP, CPI, unemployment (primary sources) + # FRED: interest_rate (and fallback for others) + return { + "bea_bls": { + "source": "BEA/BLS", + "data": { + "gdp_growth": {"value": gdp.get("value"), "date": gdp.get("as_of")}, + "cpi_inflation": {"value": cpi.get("value"), "date": cpi.get("as_of")}, + "unemployment": {"value": unemp.get("value"), "date": unemp.get("as_of")}, + } + }, + "fred": { + "source": "FRED", + "data": { + "interest_rate": {"value": interest.get("value"), "date": interest.get("as_of")}, + "gdp_growth": {"value": gdp.get("value"), "date": gdp.get("as_of")} if gdp.get("fallback") else None, + "cpi_inflation": {"value": cpi.get("value"), "date": cpi.get("as_of")} if cpi.get("fallback") else None, + "unemployment": {"value": unemp.get("value"), "date": unemp.get("as_of")} if unemp.get("fallback") else None, + } + }, + "source": raw.get("source"), + "as_of": raw.get("as_of"), + } + + +def _normalize_valuation(raw: dict) -> dict: + """ + Normalize valuation schema. + Input: {"sources": {"yahoo_finance": {...}, "alpha_vantage": {...}}} + Output: {"yahoo_finance": {"data": {...}}, "alpha_vantage": {"data": {...}}} + """ + if not raw or "error" in raw: + return raw + + sources = raw.get("sources", {}) + + result = { + "source": raw.get("source"), + "as_of": raw.get("as_of"), + } + + # Flatten sources to top level + if "yahoo_finance" in sources: + result["yahoo_finance"] = sources["yahoo_finance"] + if "alpha_vantage" in sources: + result["alpha_vantage"] = sources["alpha_vantage"] + + return result + + +def _normalize_fundamentals(raw: dict) -> dict: + """ + Normalize fundamentals schema. + Input: {"sources": {"sec_edgar": {...}, "yahoo_finance": {...}}} + Output: {"sec_edgar": {"data": {...}}, "yahoo_finance": {"data": {...}}} + """ + if not raw or "error" in raw: + return raw + + sources = raw.get("sources", {}) + + result = { + "source": raw.get("source"), + "as_of": raw.get("as_of"), + "ticker": raw.get("ticker"), + } + + # Flatten sources to top level + if "sec_edgar" in sources: + result["sec_edgar"] = sources["sec_edgar"] + if "yahoo_finance" in sources: + result["yahoo_finance"] = sources["yahoo_finance"] + + return result + + +def _get_nested_value(data: dict, *keys): + """Safely get nested value from dict, returns None if not found.""" + for key in keys: + if not isinstance(data, dict): + return None + data = data.get(key) + return data + + async def _extract_and_emit_metrics( source: str, result: dict, progress_callback: Optional[Callable] ) -> None: - """Extract metrics from MCP result and emit via callback.""" + """Extract metrics from MCP result and emit via callback. + + Handles multi-source structures from _all_sources endpoints: + - fundamentals: {"sec_edgar": {...}, "yahoo_finance": {...}} + - valuation: {"yahoo_finance": {...}, "alpha_vantage": {...}} + - volatility: {"yahoo_finance": {...}, "alpha_vantage": {...}, "market_volatility_context": {...}} + - macro: {"bea_bls": {...}, "fred": {...}} + """ if not progress_callback or not result or "error" in result: return - if source == "financials": - # Result structure: {"financials": {...}, "debt": {...}, ...} - financials = result.get("financials") or {} - debt = result.get("debt") or {} + if source == "fundamentals": + # Multi-source structure: {"sec_edgar": {"data": {...}}, "yahoo_finance": {"data": {...}}} + sec_data = _get_nested_value(result, "sec_edgar", "data") or {} + yf_data = _get_nested_value(result, "yahoo_finance", "data") or {} - # Revenue has temporal data from SEC EDGAR - revenue = financials.get("revenue") or {} + # Revenue - prefer SEC EDGAR (primary source) + revenue = sec_data.get("revenue") or yf_data.get("revenue") or {} if isinstance(revenue, dict) and revenue.get("value"): await emit_metric( progress_callback, source, "revenue", revenue["value"], @@ -283,9 +595,11 @@ async def _extract_and_emit_metrics( fiscal_year=revenue.get("fiscal_year"), form=revenue.get("form") ) + elif isinstance(revenue, (int, float)): + await emit_metric(progress_callback, source, "revenue", revenue) - # Net margin - now a dict with temporal data - net_margin = financials.get("net_margin_pct") or financials.get("net_margin") + # Net margin + net_margin = sec_data.get("net_margin_pct") or yf_data.get("net_margin_pct") or {} if isinstance(net_margin, dict) and net_margin.get("value") is not None: await emit_metric( progress_callback, source, "net_margin", net_margin["value"], @@ -294,11 +608,10 @@ async def _extract_and_emit_metrics( form=net_margin.get("form") ) elif isinstance(net_margin, (int, float)): - # Fallback for old format (plain number) await emit_metric(progress_callback, source, "net_margin", net_margin) - # EPS has temporal data - eps = financials.get("eps") or {} + # EPS + eps = sec_data.get("eps") or yf_data.get("eps") or {} if isinstance(eps, dict) and eps.get("value"): await emit_metric( progress_callback, source, "EPS", eps["value"], @@ -306,9 +619,11 @@ async def _extract_and_emit_metrics( fiscal_year=eps.get("fiscal_year"), form=eps.get("form") ) + elif isinstance(eps, (int, float)): + await emit_metric(progress_callback, source, "EPS", eps) - # Debt metrics from the debt sub-object - debt_to_equity = debt.get("debt_to_equity") + # Debt to Equity + debt_to_equity = sec_data.get("debt_to_equity") or yf_data.get("debt_to_equity") if isinstance(debt_to_equity, dict) and debt_to_equity.get("value") is not None: await emit_metric( progress_callback, source, "debt_to_equity", debt_to_equity["value"], @@ -320,73 +635,157 @@ async def _extract_and_emit_metrics( await emit_metric(progress_callback, source, "debt_to_equity", debt_to_equity) elif source == "volatility": - metrics = result.get("metrics") or {} - beta = metrics.get("beta") or {} - if isinstance(beta, dict) and beta.get("value") is not None: - await emit_metric(progress_callback, source, "beta", beta["value"]) - vix = metrics.get("vix") or {} + # Multi-source: {"yahoo_finance": {"data": {...}}, "alpha_vantage": {"data": {...}}, "market_volatility_context": {...}} + yf_data = _get_nested_value(result, "yahoo_finance", "data") or {} + av_data = _get_nested_value(result, "alpha_vantage", "data") or {} + market_ctx = result.get("market_volatility_context") or {} + + # VIX from market context + vix = market_ctx.get("vix") or {} if isinstance(vix, dict) and vix.get("value") is not None: await emit_metric(progress_callback, source, "VIX", vix["value"]) - hist_vol = metrics.get("historical_volatility") or {} + + # Beta - prefer Yahoo Finance + beta = yf_data.get("beta") or av_data.get("beta") + if isinstance(beta, dict) and beta.get("value") is not None: + await emit_metric(progress_callback, source, "beta", beta["value"]) + elif isinstance(beta, (int, float)): + await emit_metric(progress_callback, source, "beta", beta) + + # Historical Volatility + hist_vol = yf_data.get("historical_volatility") or av_data.get("historical_volatility") if isinstance(hist_vol, dict) and hist_vol.get("value") is not None: await emit_metric(progress_callback, source, "hist_vol", hist_vol["value"]) + elif isinstance(hist_vol, (int, float)): + await emit_metric(progress_callback, source, "hist_vol", hist_vol) elif source == "macro": - metrics = result.get("metrics") or {} - gdp = metrics.get("gdp_growth") or {} + # Multi-source: {"bea_bls": {"data": {...}}, "fred": {"data": {...}}} + bea_bls = _get_nested_value(result, "bea_bls", "data") or {} + fred = _get_nested_value(result, "fred", "data") or {} + + # GDP Growth - prefer BEA/BLS + gdp = bea_bls.get("gdp_growth") or fred.get("gdp_growth") or {} if isinstance(gdp, dict) and gdp.get("value") is not None: await emit_metric(progress_callback, source, "GDP_growth", gdp["value"]) - interest = metrics.get("interest_rate") or {} + elif isinstance(gdp, (int, float)): + await emit_metric(progress_callback, source, "GDP_growth", gdp) + + # Interest Rate - FRED only + interest = fred.get("interest_rate") or {} if isinstance(interest, dict) and interest.get("value") is not None: await emit_metric(progress_callback, source, "interest_rate", interest["value"]) - inflation = metrics.get("cpi_inflation") or {} + elif isinstance(interest, (int, float)): + await emit_metric(progress_callback, source, "interest_rate", interest) + + # Inflation (CPI) + inflation = bea_bls.get("cpi_inflation") or fred.get("cpi_inflation") or {} if isinstance(inflation, dict) and inflation.get("value") is not None: await emit_metric(progress_callback, source, "inflation", inflation["value"]) - unemployment = metrics.get("unemployment") or {} + elif isinstance(inflation, (int, float)): + await emit_metric(progress_callback, source, "inflation", inflation) + + # Unemployment + unemployment = bea_bls.get("unemployment") or fred.get("unemployment") or {} if isinstance(unemployment, dict) and unemployment.get("value") is not None: await emit_metric(progress_callback, source, "unemployment", unemployment["value"]) + elif isinstance(unemployment, (int, float)): + await emit_metric(progress_callback, source, "unemployment", unemployment) elif source == "valuation": - metrics = result.get("metrics") or {} - pe = metrics.get("pe_ratio") or {} - pe_val = None - if isinstance(pe, dict): - pe_val = pe.get("trailing") or pe.get("forward") - elif isinstance(pe, (int, float)): - pe_val = pe - if pe_val is not None: - await emit_metric(progress_callback, source, "P/E", pe_val) - pb_ratio = metrics.get("pb_ratio") + # Multi-source: {"yahoo_finance": {"data": {...}}, "alpha_vantage": {"data": {...}}} + yf_data = _get_nested_value(result, "yahoo_finance", "data") or {} + av_data = _get_nested_value(result, "alpha_vantage", "data") or {} + + # P/E Ratio - prefer Yahoo Finance + pe_trailing = yf_data.get("trailing_pe") or av_data.get("trailing_pe") + if pe_trailing is not None: + await emit_metric(progress_callback, source, "P/E", pe_trailing) + + # P/B Ratio + pb_ratio = yf_data.get("pb_ratio") or av_data.get("pb_ratio") if pb_ratio is not None: await emit_metric(progress_callback, source, "P/B", pb_ratio) - ps_ratio = metrics.get("ps_ratio") + + # P/S Ratio + ps_ratio = yf_data.get("ps_ratio") or av_data.get("ps_ratio") if ps_ratio is not None: await emit_metric(progress_callback, source, "P/S", ps_ratio) - ev_ebitda = metrics.get("ev_ebitda") + + # EV/EBITDA + ev_ebitda = yf_data.get("ev_ebitda") or av_data.get("ev_ebitda") if ev_ebitda is not None: await emit_metric(progress_callback, source, "EV/EBITDA", ev_ebitda) elif source == "news": - # News MCP server returns "results" not "articles" - articles = result.get("results") or result.get("articles") or [] - if articles and isinstance(articles, list) and len(articles) > 0: - await emit_metric(progress_callback, source, "articles_found", len(articles)) + # News-basket returns normalized "items" array + items = result.get("items") or [] + if items and isinstance(items, list) and len(items) > 0: + await emit_metric(progress_callback, source, "items_found", len(items)) else: await emit_metric(progress_callback, source, "status", "No recent news found") elif source == "sentiment": - has_data = False - composite = result.get("composite_score") - if composite is not None: - await emit_metric(progress_callback, source, "composite_score", composite) - has_data = True - metrics = result.get("metrics") or {} - finnhub = metrics.get("finnhub") or {} - if isinstance(finnhub, dict) and finnhub.get("sentiment_score") is not None: - await emit_metric(progress_callback, source, "finnhub_score", finnhub["sentiment_score"]) - has_data = True - if not has_data: - await emit_metric(progress_callback, source, "status", "No sentiment data available") + # Sentiment-basket returns raw content (items) without scoring + # Scoring is applied downstream by analyzer + items = result.get("items") or [] + if items and isinstance(items, list) and len(items) > 0: + await emit_metric(progress_callback, source, "items_found", len(items)) + else: + await emit_metric(progress_callback, source, "status", "No sentiment content found") + + +def _has_metric(data: dict, field: str) -> bool: + """Check if metric exists in possibly nested structure.""" + if not isinstance(data, dict): + return False + if field in data: + val = data[field] + if isinstance(val, dict): + return val.get("value") is not None + # Special case for items (list) - news and sentiment + if isinstance(val, list): + return len(val) > 0 + return val is not None + # Check common nested paths + for key in ["data", "metrics", "sec_edgar", "yahoo_finance"]: + if key in data and isinstance(data[key], dict): + if field in data[key]: + return True + return False + + +def _calculate_completeness(metrics: dict, sources_available: list) -> dict: + """Calculate completeness score and identify missing data.""" + required = { + "fundamentals": ["revenue", "net_income", "eps", "debt_to_equity"], + "valuation": ["trailing_pe", "pb_ratio", "ps_ratio"], + "volatility": ["beta", "vix"], + "macro": ["gdp_growth", "interest_rate", "cpi_inflation"], + "news": ["items"], + "sentiment": ["items"] + } + + total = 0 + found = 0 + missing = {} + + for source, fields in required.items(): + source_data = metrics.get(source, {}) + missing[source] = [] + for field in fields: + total += 1 + if _has_metric(source_data, field): + found += 1 + else: + missing[source].append(field) + + return { + "completeness_pct": round(found / total * 100, 1) if total > 0 else 0, + "metrics_found": found, + "metrics_total": total, + "missing": {k: v for k, v in missing.items() if v} + } def _aggregate_swot(metrics: dict, sources_available: list) -> dict: @@ -409,100 +808,253 @@ def _aggregate_swot(metrics: dict, sources_available: list) -> dict: return aggregated_swot +def _sort_and_limit_news(news_data: dict, limit: int = 10) -> dict: + """Sort news items by date (most recent first) and limit to top N.""" + if not news_data or "items" not in news_data: + return news_data + + items = news_data.get("items", []) + + # Sort by datetime descending (most recent first) + def get_date(item): + date_str = item.get("datetime") or "" + return date_str if date_str else "1970-01-01" + + sorted_items = sorted(items, key=get_date, reverse=True) + + # Limit to top N + news_data["items"] = sorted_items[:limit] + news_data["total_items"] = len(items) + news_data["showing"] = min(limit, len(items)) + + return news_data + + +def _sort_and_limit_sentiment(sentiment_data: dict, limit: int = 10) -> dict: + """Sort sentiment items by date (most recent first) and limit to top N.""" + if not sentiment_data or "items" not in sentiment_data: + return sentiment_data + + items = sentiment_data.get("items", []) + + # Sort by datetime descending (most recent first) + def get_date(item): + return item.get("datetime") or "1970-01-01" + + sorted_items = sorted(items, key=get_date, reverse=True) + + # Limit to top N + sentiment_data["items"] = sorted_items[:limit] + sentiment_data["total_items"] = len(items) + sentiment_data["showing"] = min(limit, len(items)) + + return sentiment_data + + +def _add_conflict_markers(fundamentals_all: dict, valuation_all: dict) -> dict: + """ + Add conflict resolution markers to multi-source data. + Primary sources: SEC EDGAR (fundamentals), Yahoo Finance (valuation) + """ + conflict_resolution = { + "fundamentals": { + "primary_source": "SEC EDGAR XBRL", + "secondary_source": "Yahoo Finance", + "conflicts": [] + }, + "valuation": { + "primary_source": "Yahoo Finance", + "secondary_source": "Alpha Vantage", + "conflicts": [] + } + } + + # Check fundamentals for conflicts + if fundamentals_all and "sec_edgar" in fundamentals_all and "yahoo_finance" in fundamentals_all: + sec_data = fundamentals_all.get("sec_edgar", {}).get("data", {}) + yf_data = fundamentals_all.get("yahoo_finance", {}).get("data", {}) + + for metric in ["revenue", "net_income", "free_cash_flow"]: + sec_val = sec_data.get(metric, {}) + yf_val = yf_data.get(metric, {}) + + if isinstance(sec_val, dict): + sec_val = sec_val.get("value") + if isinstance(yf_val, dict): + yf_val = yf_val.get("value") + if isinstance(yf_val, dict): + yf_val = yf_val.get("value") + + if sec_val and yf_val and sec_val != yf_val: + conflict_resolution["fundamentals"]["conflicts"].append({ + "metric": metric, + "primary_value": sec_val, + "secondary_value": yf_val, + "used": "primary" + }) + + # Check valuation for conflicts + if valuation_all and "yahoo_finance" in valuation_all and "alpha_vantage" in valuation_all: + yf_data = valuation_all.get("yahoo_finance", {}).get("data", {}) + av_data = valuation_all.get("alpha_vantage", {}).get("data", {}) + + for metric in ["trailing_pe", "forward_pe", "pb_ratio", "ps_ratio"]: + yf_val = yf_data.get(metric) + av_val = av_data.get(metric) + + if yf_val and av_val and abs(yf_val - av_val) > 0.5: + conflict_resolution["valuation"]["conflicts"].append({ + "metric": metric, + "primary_value": yf_val, + "secondary_value": av_val, + "used": "primary" + }) + + return conflict_resolution + + async def fetch_all_research_data( ticker: str, company_name: str, progress_callback: Optional[Callable] = None ) -> dict: """ - Fetch data from all 6 MCP servers in parallel using TRUE MCP protocol. + Fetch data from 6 MCP servers SEQUENTIALLY using TRUE MCP protocol. + Only calls multi-source (_all) versions to avoid duplicate API calls. + + Order: fundamentals -> valuation -> volatility -> macro -> news -> sentiment Args: ticker: Stock ticker symbol company_name: Company name - progress_callback: Optional callback for granular metric events (source, metric, value) + progress_callback: Optional callback for granular metric events Returns aggregated results with sources_available, sources_failed, and aggregated_swot. """ logger.info(f"Fetching from MCP servers for {ticker} ({company_name})...") - # MCP call functions mapped by name - mcp_functions = { - "financials": lambda: call_financials_mcp(ticker), - "volatility": lambda: call_volatility_mcp(ticker), - "macro": lambda: call_macro_mcp(), - "valuation": lambda: call_valuation_mcp(ticker), - "news": lambda: call_news_mcp(ticker, company_name), - "sentiment": lambda: call_sentiment_mcp(ticker, company_name), + # Sequential order: critical data first + mcp_sequence = [ + ("fundamentals", lambda: call_fundamentals_all_sources_mcp(ticker)), + ("valuation", lambda: call_valuation_all_sources_mcp(ticker)), + ("volatility", lambda: call_volatility_all_sources_mcp(ticker)), + ("macro", lambda: call_macro_all_sources_mcp()), + ("news", lambda: call_news_mcp(ticker, company_name)), + ("sentiment", lambda: call_sentiment_mcp(ticker, company_name)), + ] + + # Normalizers to convert MCP schemas to analyzer-expected format + normalizers = { + "fundamentals": _normalize_fundamentals, + "valuation": _normalize_valuation, + "volatility": _normalize_volatility, + "macro": _normalize_macro, } - mcp_names = list(mcp_functions.keys()) - - # Call all MCPs in parallel - results = await asyncio.gather( - *[mcp_functions[name]() for name in mcp_names], - return_exceptions=True - ) metrics = {} sources_available = [] sources_failed = [] - failed_for_retry = [] - - # First pass - identify successes and failures, emit metrics - for name, result in zip(mcp_names, results): - if isinstance(result, Exception): - failed_for_retry.append(name) - metrics[name] = {"error": str(result)} - logger.warning(f"MCP {name} failed: {result}") - elif isinstance(result, dict) and "error" in result: - failed_for_retry.append(name) - metrics[name] = result - logger.warning(f"MCP {name} error: {result.get('error', 'Unknown')[:50]}") - else: - sources_available.append(name) - metrics[name] = result - # Emit metrics for this source - await _extract_and_emit_metrics(name, result, progress_callback) - logger.info(f"MCP {name} fetched successfully") - - # Automatic retry once for failed MCPs - if failed_for_retry: - logger.info(f"Retrying {len(failed_for_retry)} failed MCP servers: {failed_for_retry}") - - retry_results = await asyncio.gather( - *[mcp_functions[name]() for name in failed_for_retry], - return_exceptions=True - ) - for name, result in zip(failed_for_retry, retry_results): - if isinstance(result, Exception): - sources_failed.append(name) - metrics[name] = {"error": str(result), "retried": True} - logger.warning(f"MCP {name} failed after retry: {result}") - elif isinstance(result, dict) and "error" in result: - sources_failed.append(name) - metrics[name] = {**result, "retried": True} - logger.warning(f"MCP {name} failed after retry: {result.get('error')}") + # Sequential execution - one at a time + for name, mcp_func in mcp_sequence: + logger.info(f"Fetching {name}...") + + try: + result = await mcp_func() + + if isinstance(result, dict) and "error" in result: + # First attempt failed, retry once + logger.warning(f"MCP {name} error, retrying: {result.get('error', 'Unknown')[:50]}") + result = await mcp_func() + + if isinstance(result, dict) and "error" in result: + sources_failed.append(name) + metrics[name] = {**result, "retried": True} + logger.warning(f"MCP {name} failed after retry: {result.get('error')}") + else: + # Apply normalizer if available + if name in normalizers: + result = normalizers[name](result) + sources_available.append(name) + metrics[name] = result + logger.info(f"MCP {name} succeeded on retry") + # Emit metrics for real-time streaming to frontend + await _extract_and_emit_metrics(name, result, progress_callback) else: + # Apply normalizer if available + if name in normalizers: + result = normalizers[name](result) sources_available.append(name) metrics[name] = result - # Emit metrics for this source + logger.info(f"MCP {name} fetched successfully") + # Emit metrics for real-time streaming to frontend await _extract_and_emit_metrics(name, result, progress_callback) - logger.info(f"MCP {name} succeeded on retry") - # Build aggregated SWOT + except Exception as e: + # First attempt exception, retry once + logger.warning(f"MCP {name} exception, retrying: {e}") + try: + result = await mcp_func() + if isinstance(result, dict) and "error" not in result: + # Apply normalizer if available + if name in normalizers: + result = normalizers[name](result) + sources_available.append(name) + metrics[name] = result + logger.info(f"MCP {name} succeeded on retry") + # Emit metrics for real-time streaming to frontend + await _extract_and_emit_metrics(name, result, progress_callback) + else: + sources_failed.append(name) + metrics[name] = {"error": str(result.get("error", e)), "retried": True} + logger.warning(f"MCP {name} failed after retry") + except Exception as e2: + sources_failed.append(name) + metrics[name] = {"error": str(e2), "retried": True} + logger.warning(f"MCP {name} failed after retry: {e2}") + + # Apply sorting and limiting to news (top 10, most recent first) + if "news" in metrics and "error" not in metrics.get("news", {}): + metrics["news"] = _sort_and_limit_news(metrics["news"], limit=10) + + # Apply sorting and limiting to sentiment (top 10 articles/posts, most recent first) + if "sentiment" in metrics and "error" not in metrics.get("sentiment", {}): + metrics["sentiment"] = _sort_and_limit_sentiment(metrics["sentiment"], limit=10) + + # Get multi-source data (now stored directly under source name) + fundamentals_data = metrics.get("fundamentals", {}) + valuation_data = metrics.get("valuation", {}) + macro_data = metrics.get("macro", {}) + volatility_data = metrics.get("volatility", {}) + + # Add conflict resolution markers + conflict_resolution = _add_conflict_markers(fundamentals_data, valuation_data) + + # Build aggregated SWOT from primary source data aggregated_swot = _aggregate_swot(metrics, sources_available) + # Calculate completeness score + completeness = _calculate_completeness(metrics, sources_available) + + # Final data package - shared with analyzer only after all collection complete data = { "ticker": ticker.upper(), "company_name": company_name, "sources_available": sources_available, "sources_failed": sources_failed, "metrics": metrics, + "multi_source": { + "fundamentals_all": fundamentals_data, + "valuation_all": valuation_data, + "macro_all": macro_data, + "volatility_all": volatility_data, + }, + "conflict_resolution": conflict_resolution, "aggregated_swot": aggregated_swot, + "completeness": completeness, "generated_at": datetime.now().isoformat() } - logger.info(f"Research complete: {len(sources_available)} sources, {len(sources_failed)} failed") + logger.info(f"Research complete: {len(sources_available)} sources, {len(sources_failed)} failed, {completeness['completeness_pct']}% complete") return data diff --git a/reports/mcp_test_smoke_20260108_085642.json b/reports/mcp_test_smoke_20260108_085642.json new file mode 100644 index 0000000000000000000000000000000000000000..99461cce5bdf368586bbab9596baf9a814a3104c --- /dev/null +++ b/reports/mcp_test_smoke_20260108_085642.json @@ -0,0 +1,210 @@ +{ + "total": 30, + "success_rate": 0.0, + "fallback_rate": 0.0, + "failure_rate": 1.0, + "by_category": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 30, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "by_server": { + "financials-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 5, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "valuation-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 5, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "volatility-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 5, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "macro-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 5, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "news-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 5, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "sentiment-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 5, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + } + }, + "latency_p50": 891.4120370172895, + "latency_p95": 1127.6071140018757, + "latency_p99": 1259.4495859812014, + "test_config": { + "batch_size": 5, + "sampling_strategy": "uniform", + "servers": [ + "financials-basket", + "valuation-basket", + "volatility-basket", + "macro-basket", + "news-basket", + "sentiment-basket" + ], + "seed": 42 + }, + "start_time": "2026-01-08T08:56:27.363356Z", + "end_time": "2026-01-08T08:56:42.017069Z", + "circuit_breaker_status": { + "financials-basket": { + "name": "financials-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 1.1030455979926046, + "last_failure": 253698.423568468 + }, + "valuation-basket": { + "name": "valuation-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 1.0236418429994956, + "last_failure": 253698.50298651 + }, + "volatility-basket": { + "name": "volatility-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 0.6598441220121458, + "last_failure": 253698.866787757 + }, + "macro-basket": { + "name": "macro-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 0.2782936120056547, + "last_failure": 253699.248343832 + }, + "news-basket": { + "name": "news-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 0.2414332780172117, + "last_failure": 253699.285207577 + }, + "sentiment-basket": { + "name": "sentiment-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 0.00047351798275485635, + "last_failure": 253699.526171608 + } + }, + "rate_limiter_status": { + "limiters": { + "sec_edgar": { + "type": "token_bucket", + "available": 10, + "capacity": 10 + }, + "yahoo_finance": { + "type": "token_bucket", + "available": 20, + "capacity": 20 + }, + "finnhub": { + "type": "token_bucket", + "available": 4.659811655001249, + "capacity": 5 + }, + "fred": { + "type": "sliding_window", + "used": 10, + "max": 120 + }, + "reddit": { + "type": "sliding_window", + "used": 0, + "max": 100 + } + }, + "quotas": { + "nyt": { + "remaining": 500, + "daily_limit": 500 + }, + "newsapi": { + "remaining": 100, + "daily_limit": 100 + }, + "tavily": { + "remaining": 28, + "daily_limit": 33 + } + } + } +} \ No newline at end of file diff --git a/reports/mcp_test_smoke_20260108_090457.json b/reports/mcp_test_smoke_20260108_090457.json new file mode 100644 index 0000000000000000000000000000000000000000..42277b98d65d28d1bd9709ab574d36a81e352c45 --- /dev/null +++ b/reports/mcp_test_smoke_20260108_090457.json @@ -0,0 +1,210 @@ +{ + "total": 30, + "success_rate": 0.3, + "fallback_rate": 0.1, + "failure_rate": 0.0, + "by_category": { + "success": 6, + "partial": 0, + "fallback": 3, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 21, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "by_server": { + "financials-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 5, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "valuation-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 5, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "volatility-basket": { + "success": 2, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 3, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "macro-basket": { + "success": 2, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 3, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "news-basket": { + "success": 2, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 3, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "sentiment-basket": { + "success": 0, + "partial": 0, + "fallback": 3, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 2, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + } + }, + "latency_p50": 30006.131328991614, + "latency_p95": 54585.708719008835, + "latency_p99": 56139.44560897653, + "test_config": { + "batch_size": 5, + "sampling_strategy": "uniform", + "servers": [ + "financials-basket", + "valuation-basket", + "volatility-basket", + "macro-basket", + "news-basket", + "sentiment-basket" + ], + "seed": 42 + }, + "start_time": "2026-01-08T08:59:53.381077Z", + "end_time": "2026-01-08T09:04:51.774112Z", + "circuit_breaker_status": { + "financials-basket": { + "name": "financials-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 58.52132911499939, + "last_failure": 254130.762781741 + }, + "valuation-basket": { + "name": "valuation-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 55.45289099801448, + "last_failure": 254133.83131143 + }, + "volatility-basket": { + "name": "volatility-basket", + "state": "closed", + "failure_count": 3, + "success_count": 0, + "time_in_state": 298.3937070299871, + "last_failure": 254133.907428539 + }, + "macro-basket": { + "name": "macro-basket", + "state": "closed", + "failure_count": 3, + "success_count": 0, + "time_in_state": 298.3937106039957, + "last_failure": 254186.973544599 + }, + "news-basket": { + "name": "news-basket", + "state": "closed", + "failure_count": 3, + "success_count": 0, + "time_in_state": 298.3937140539929, + "last_failure": 254188.540346412 + }, + "sentiment-basket": { + "name": "sentiment-basket", + "state": "closed", + "failure_count": 2, + "success_count": 0, + "time_in_state": 298.3937161710055, + "last_failure": 254188.615674078 + } + }, + "rate_limiter_status": { + "limiters": { + "sec_edgar": { + "type": "token_bucket", + "available": 10, + "capacity": 10 + }, + "yahoo_finance": { + "type": "token_bucket", + "available": 20, + "capacity": 20 + }, + "finnhub": { + "type": "token_bucket", + "available": 5, + "capacity": 5 + }, + "fred": { + "type": "sliding_window", + "used": 1, + "max": 120 + }, + "reddit": { + "type": "sliding_window", + "used": 0, + "max": 100 + } + }, + "quotas": { + "nyt": { + "remaining": 500, + "daily_limit": 500 + }, + "newsapi": { + "remaining": 100, + "daily_limit": 100 + }, + "tavily": { + "remaining": 28, + "daily_limit": 33 + } + } + } +} \ No newline at end of file diff --git a/reports/mcp_test_smoke_20260108_095022.json b/reports/mcp_test_smoke_20260108_095022.json new file mode 100644 index 0000000000000000000000000000000000000000..446cfe91eabe415101a303d42d70cf2758d0c026 --- /dev/null +++ b/reports/mcp_test_smoke_20260108_095022.json @@ -0,0 +1,210 @@ +{ + "total": 30, + "success_rate": 0.9, + "fallback_rate": 0.16666666666666666, + "failure_rate": 0.1, + "by_category": { + "success": 22, + "partial": 0, + "fallback": 5, + "transient": 0, + "persistent": 0, + "hard_failure": 3, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "by_server": { + "financials-basket": { + "success": 4, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 1, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "valuation-basket": { + "success": 4, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 1, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "volatility-basket": { + "success": 4, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 1, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "macro-basket": { + "success": 5, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "news-basket": { + "success": 5, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "sentiment-basket": { + "success": 0, + "partial": 0, + "fallback": 5, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + } + }, + "latency_p50": 13095.458244002657, + "latency_p95": 40498.139962001005, + "latency_p99": 41803.10635000933, + "test_config": { + "batch_size": 5, + "sampling_strategy": "uniform", + "servers": [ + "financials-basket", + "valuation-basket", + "volatility-basket", + "macro-basket", + "news-basket", + "sentiment-basket" + ], + "seed": 42 + }, + "start_time": "2026-01-08T09:47:24.629736Z", + "end_time": "2026-01-08T09:50:22.083041Z", + "circuit_breaker_status": { + "financials-basket": { + "name": "financials-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 177.45344851800473, + "last_failure": 256776.727346406 + }, + "valuation-basket": { + "name": "valuation-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 177.45345394100877, + "last_failure": 256773.135292402 + }, + "volatility-basket": { + "name": "volatility-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 177.4534553049889, + "last_failure": 256845.206840537 + }, + "macro-basket": { + "name": "macro-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 177.45345828298014, + "last_failure": null + }, + "news-basket": { + "name": "news-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 177.4534592970158, + "last_failure": null + }, + "sentiment-basket": { + "name": "sentiment-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 177.45345990499482, + "last_failure": null + } + }, + "rate_limiter_status": { + "limiters": { + "sec_edgar": { + "type": "token_bucket", + "available": 10, + "capacity": 10 + }, + "yahoo_finance": { + "type": "token_bucket", + "available": 20, + "capacity": 20 + }, + "finnhub": { + "type": "token_bucket", + "available": 5, + "capacity": 5 + }, + "fred": { + "type": "sliding_window", + "used": 4, + "max": 120 + }, + "reddit": { + "type": "sliding_window", + "used": 0, + "max": 100 + } + }, + "quotas": { + "nyt": { + "remaining": 500, + "daily_limit": 500 + }, + "newsapi": { + "remaining": 100, + "daily_limit": 100 + }, + "tavily": { + "remaining": 28, + "daily_limit": 33 + } + } + } +} \ No newline at end of file diff --git a/reports/mcp_test_standard_20260108_101517.json b/reports/mcp_test_standard_20260108_101517.json new file mode 100644 index 0000000000000000000000000000000000000000..db497596d477e456929489f15907c52d7941c752 --- /dev/null +++ b/reports/mcp_test_standard_20260108_101517.json @@ -0,0 +1,210 @@ +{ + "total": 60, + "success_rate": 0.18333333333333332, + "fallback_rate": 0.03333333333333333, + "failure_rate": 0.16666666666666666, + "by_category": { + "success": 9, + "partial": 0, + "fallback": 2, + "transient": 0, + "persistent": 0, + "hard_failure": 10, + "rate_limited": 0, + "timeout": 39, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "by_server": { + "financials-basket": { + "success": 0, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 2, + "rate_limited": 0, + "timeout": 8, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "valuation-basket": { + "success": 1, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 2, + "rate_limited": 0, + "timeout": 7, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "volatility-basket": { + "success": 3, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 1, + "rate_limited": 0, + "timeout": 6, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "macro-basket": { + "success": 3, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 1, + "rate_limited": 0, + "timeout": 6, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "news-basket": { + "success": 2, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 2, + "rate_limited": 0, + "timeout": 6, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "sentiment-basket": { + "success": 0, + "partial": 0, + "fallback": 2, + "transient": 0, + "persistent": 0, + "hard_failure": 2, + "rate_limited": 0, + "timeout": 6, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + } + }, + "latency_p50": 60150.188847997924, + "latency_p95": 337981.2546830217, + "latency_p99": 368786.9911470043, + "test_config": { + "batch_size": 10, + "sampling_strategy": "mixed", + "servers": [ + "financials-basket", + "valuation-basket", + "volatility-basket", + "macro-basket", + "news-basket", + "sentiment-basket" + ], + "seed": 1767865854 + }, + "start_time": "2026-01-08T09:50:54.133893Z", + "end_time": "2026-01-08T10:15:07.964324Z", + "circuit_breaker_status": { + "financials-basket": { + "name": "financials-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 353.31579070401494, + "last_failure": 258052.158882414 + }, + "valuation-basket": { + "name": "valuation-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 353.308007471991, + "last_failure": 258052.166274365 + }, + "volatility-basket": { + "name": "volatility-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 353.30786182099837, + "last_failure": 258052.166434367 + }, + "macro-basket": { + "name": "macro-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 353.30774669500533, + "last_failure": 258052.166565247 + }, + "news-basket": { + "name": "news-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 74.72261642399826, + "last_failure": 258330.751544651 + }, + "sentiment-basket": { + "name": "sentiment-basket", + "state": "open", + "failure_count": 5, + "success_count": 0, + "time_in_state": 69.12864734700997, + "last_failure": 258336.345675458 + } + }, + "rate_limiter_status": { + "limiters": { + "sec_edgar": { + "type": "token_bucket", + "available": 10, + "capacity": 10 + }, + "yahoo_finance": { + "type": "token_bucket", + "available": 20, + "capacity": 20 + }, + "finnhub": { + "type": "token_bucket", + "available": 5, + "capacity": 5 + }, + "fred": { + "type": "sliding_window", + "used": 0, + "max": 120 + }, + "reddit": { + "type": "sliding_window", + "used": 0, + "max": 100 + } + }, + "quotas": { + "nyt": { + "remaining": 500, + "daily_limit": 500 + }, + "newsapi": { + "remaining": 100, + "daily_limit": 100 + }, + "tavily": { + "remaining": 25, + "daily_limit": 33 + } + } + } +} \ No newline at end of file diff --git a/reports/mcp_test_standard_20260108_112154.json b/reports/mcp_test_standard_20260108_112154.json new file mode 100644 index 0000000000000000000000000000000000000000..8cec8e6a8174375ed429e35846a652948ae79298 --- /dev/null +++ b/reports/mcp_test_standard_20260108_112154.json @@ -0,0 +1,210 @@ +{ + "total": 120, + "success_rate": 0.9833333333333333, + "fallback_rate": 0.16666666666666666, + "failure_rate": 0.016666666666666666, + "by_category": { + "success": 98, + "partial": 0, + "fallback": 20, + "transient": 0, + "persistent": 0, + "hard_failure": 2, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "by_server": { + "financials-basket": { + "success": 19, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 1, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "valuation-basket": { + "success": 19, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 1, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "volatility-basket": { + "success": 20, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "macro-basket": { + "success": 20, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "news-basket": { + "success": 20, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "sentiment-basket": { + "success": 0, + "partial": 0, + "fallback": 20, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + } + }, + "latency_p50": 18704.227741000068, + "latency_p95": 39553.09576599984, + "latency_p99": 45975.18721300003, + "test_config": { + "batch_size": 20, + "sampling_strategy": "mixed", + "servers": [ + "financials-basket", + "valuation-basket", + "volatility-basket", + "macro-basket", + "news-basket", + "sentiment-basket" + ], + "seed": 1767870781 + }, + "start_time": "2026-01-08T11:13:01.296953Z", + "end_time": "2026-01-08T11:21:54.717224Z", + "circuit_breaker_status": { + "financials-basket": { + "name": "financials-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 533.4210687100001, + "last_failure": 2187.940096053 + }, + "valuation-basket": { + "name": "valuation-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 533.4210769880001, + "last_failure": 2188.940526928 + }, + "volatility-basket": { + "name": "volatility-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 533.421079531, + "last_failure": null + }, + "macro-basket": { + "name": "macro-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 533.4210825709999, + "last_failure": null + }, + "news-basket": { + "name": "news-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 533.4210848000002, + "last_failure": null + }, + "sentiment-basket": { + "name": "sentiment-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 533.4210867239999, + "last_failure": null + } + }, + "rate_limiter_status": { + "limiters": { + "sec_edgar": { + "type": "token_bucket", + "available": 10, + "capacity": 10 + }, + "yahoo_finance": { + "type": "token_bucket", + "available": 20, + "capacity": 20 + }, + "finnhub": { + "type": "token_bucket", + "available": 5, + "capacity": 5 + }, + "fred": { + "type": "sliding_window", + "used": 4, + "max": 120 + }, + "reddit": { + "type": "sliding_window", + "used": 0, + "max": 100 + } + }, + "quotas": { + "nyt": { + "remaining": 500, + "daily_limit": 500 + }, + "newsapi": { + "remaining": 100, + "daily_limit": 100 + }, + "tavily": { + "remaining": 13, + "daily_limit": 33 + } + } + } +} \ No newline at end of file diff --git a/reports/mcp_test_standard_20260108_135946.json b/reports/mcp_test_standard_20260108_135946.json new file mode 100644 index 0000000000000000000000000000000000000000..7d9f102dc33b20aaeb4d9889f6251844cfcd16b0 --- /dev/null +++ b/reports/mcp_test_standard_20260108_135946.json @@ -0,0 +1,210 @@ +{ + "total": 120, + "success_rate": 1.0, + "fallback_rate": 0.16666666666666666, + "failure_rate": 0.0, + "by_category": { + "success": 100, + "partial": 0, + "fallback": 20, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "by_server": { + "financials-basket": { + "success": 20, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "valuation-basket": { + "success": 20, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "volatility-basket": { + "success": 20, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "macro-basket": { + "success": 20, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "news-basket": { + "success": 20, + "partial": 0, + "fallback": 0, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + }, + "sentiment-basket": { + "success": 0, + "partial": 0, + "fallback": 20, + "transient": 0, + "persistent": 0, + "hard_failure": 0, + "rate_limited": 0, + "timeout": 0, + "hf_dependency": 0, + "cold_start": 0, + "unknown": 0 + } + }, + "latency_p50": 16580.103448000955, + "latency_p95": 34722.17255799842, + "latency_p99": 39185.02243799958, + "test_config": { + "batch_size": 20, + "sampling_strategy": "mixed", + "servers": [ + "financials-basket", + "valuation-basket", + "volatility-basket", + "macro-basket", + "news-basket", + "sentiment-basket" + ], + "seed": 1767880308 + }, + "start_time": "2026-01-08T13:51:48.501643Z", + "end_time": "2026-01-08T13:59:46.562352Z", + "circuit_breaker_status": { + "financials-basket": { + "name": "financials-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 478.06103119399995, + "last_failure": null + }, + "valuation-basket": { + "name": "valuation-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 478.0610348329992, + "last_failure": null + }, + "volatility-basket": { + "name": "volatility-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 478.06103692500074, + "last_failure": null + }, + "macro-basket": { + "name": "macro-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 478.06103845800135, + "last_failure": null + }, + "news-basket": { + "name": "news-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 478.0610400780006, + "last_failure": null + }, + "sentiment-basket": { + "name": "sentiment-basket", + "state": "closed", + "failure_count": 0, + "success_count": 0, + "time_in_state": 478.06109777500023, + "last_failure": null + } + }, + "rate_limiter_status": { + "limiters": { + "sec_edgar": { + "type": "token_bucket", + "available": 10, + "capacity": 10 + }, + "yahoo_finance": { + "type": "token_bucket", + "available": 20, + "capacity": 20 + }, + "finnhub": { + "type": "token_bucket", + "available": 5, + "capacity": 5 + }, + "fred": { + "type": "sliding_window", + "used": 4, + "max": 120 + }, + "reddit": { + "type": "sliding_window", + "used": 0, + "max": 100 + } + }, + "quotas": { + "nyt": { + "remaining": 500, + "daily_limit": 500 + }, + "newsapi": { + "remaining": 100, + "daily_limit": 100 + }, + "tavily": { + "remaining": 13, + "daily_limit": 33 + } + } + } +} \ No newline at end of file diff --git a/scripts/fetch_alphavantage_schema.py b/scripts/fetch_alphavantage_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..c198520c5aa6b60b935d1b2320dd0356fd6049d2 --- /dev/null +++ b/scripts/fetch_alphavantage_schema.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +Fetch raw Alpha Vantage data and output the schema. +Shows raw API response structure and field descriptions. +""" + +import asyncio +import json +import os +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv +import httpx + +# Load environment variables +env_paths = [ + Path.home() / ".env", + Path(__file__).parent.parent / ".env", +] +for env_path in env_paths: + if env_path.exists(): + load_dotenv(env_path) + break + +ALPHA_VANTAGE_KEY = os.getenv("ALPHA_VANTAGE_API_KEY", "") + + +def print_table(title: str, rows: list, col_widths: list = None): + """Print ASCII table.""" + if not rows: + return + + # Calculate column widths + if col_widths is None: + col_widths = [] + for col in range(len(rows[0])): + width = max(len(str(row[col])) for row in rows) + col_widths.append(width) + + # Print header + print(f"\n{title}") + + # Top border + line = "┌" + "┬".join("─" * (w + 2) for w in col_widths) + "┐" + print(line) + + # Header row + header = rows[0] + row_str = "│" + "│".join(f" {str(header[i]).ljust(col_widths[i])} " for i in range(len(header))) + "│" + print(row_str) + + # Separator + line = "├" + "┼".join("─" * (w + 2) for w in col_widths) + "┤" + print(line) + + # Data rows + for row in rows[1:]: + row_str = "│" + "│".join(f" {str(row[i]).ljust(col_widths[i])} " for i in range(len(row))) + "│" + print(row_str) + + # Bottom border + line = "└" + "┴".join("─" * (w + 2) for w in col_widths) + "┘" + print(line) + + +async def fetch_overview(ticker: str) -> dict: + """Fetch company overview from Alpha Vantage.""" + if not ALPHA_VANTAGE_KEY: + return {"error": "ALPHA_VANTAGE_API_KEY not configured"} + + try: + async with httpx.AsyncClient() as client: + url = f"https://www.alphavantage.co/query" + params = { + "function": "OVERVIEW", + "symbol": ticker, + "apikey": ALPHA_VANTAGE_KEY + } + response = await client.get(url, params=params, timeout=15) + return response.json() + except Exception as e: + return {"error": str(e)} + + +async def main(): + print("Alpha Vantage Data Schema") + print("=" * 60) + print() + print("Endpoint: https://www.alphavantage.co/query?function=OVERVIEW") + print() + + if not ALPHA_VANTAGE_KEY: + print("ERROR: ALPHA_VANTAGE_API_KEY not configured") + print("Add ALPHA_VANTAGE_API_KEY to ~/.env file") + print("Get free key at: https://www.alphavantage.co/support/#api-key") + return + + print("Fetching AAPL overview...") + data = await fetch_overview("AAPL") + + if "error" in data or "Error Message" in data: + print(f"ERROR: {data}") + return + + print() + print("=" * 60) + print() + + # Print raw API response structure + print("Raw API Response Structure") + print("-" * 40) + + # Company Info + rows = [["field", "value"]] + rows.append(["Symbol", data.get("Symbol", "")]) + rows.append(["Name", data.get("Name", "")]) + rows.append(["Exchange", data.get("Exchange", "")]) + rows.append(["Currency", data.get("Currency", "")]) + rows.append(["Country", data.get("Country", "")]) + rows.append(["Sector", data.get("Sector", "")]) + rows.append(["Industry", data.get("Industry", "")]) + print_table("Company Info", rows) + + # Valuation Metrics + rows = [["field", "value"]] + rows.append(["MarketCapitalization", data.get("MarketCapitalization", "")]) + rows.append(["TrailingPE", data.get("TrailingPE", "")]) + rows.append(["ForwardPE", data.get("ForwardPE", "")]) + rows.append(["PEGRatio", data.get("PEGRatio", "")]) + rows.append(["PriceToBookRatio", data.get("PriceToBookRatio", "")]) + rows.append(["PriceToSalesRatioTTM", data.get("PriceToSalesRatioTTM", "")]) + rows.append(["EVToEBITDA", data.get("EVToEBITDA", "")]) + rows.append(["EVToRevenue", data.get("EVToRevenue", "")]) + print_table("Valuation Metrics", rows) + + # Growth Metrics + rows = [["field", "value"]] + rows.append(["QuarterlyEarningsGrowthYOY", data.get("QuarterlyEarningsGrowthYOY", "")]) + rows.append(["QuarterlyRevenueGrowthYOY", data.get("QuarterlyRevenueGrowthYOY", "")]) + rows.append(["AnalystTargetPrice", data.get("AnalystTargetPrice", "")]) + print_table("Growth Metrics", rows) + + # Financial Metrics + rows = [["field", "value"]] + rows.append(["EBITDA", data.get("EBITDA", "")]) + rows.append(["RevenueTTM", data.get("RevenueTTM", "")]) + rows.append(["GrossProfitTTM", data.get("GrossProfitTTM", "")]) + rows.append(["DilutedEPSTTM", data.get("DilutedEPSTTM", "")]) + rows.append(["ProfitMargin", data.get("ProfitMargin", "")]) + rows.append(["OperatingMarginTTM", data.get("OperatingMarginTTM", "")]) + rows.append(["ReturnOnAssetsTTM", data.get("ReturnOnAssetsTTM", "")]) + rows.append(["ReturnOnEquityTTM", data.get("ReturnOnEquityTTM", "")]) + print_table("Financial Metrics", rows) + + # Dividend & Book Value + rows = [["field", "value"]] + rows.append(["DividendPerShare", data.get("DividendPerShare", "")]) + rows.append(["DividendYield", data.get("DividendYield", "")]) + rows.append(["ExDividendDate", data.get("ExDividendDate", "")]) + rows.append(["BookValue", data.get("BookValue", "")]) + print_table("Dividend & Book Value", rows) + + # Moving Averages + rows = [["field", "value"]] + rows.append(["50DayMovingAverage", data.get("50DayMovingAverage", "")]) + rows.append(["200DayMovingAverage", data.get("200DayMovingAverage", "")]) + rows.append(["52WeekHigh", data.get("52WeekHigh", "")]) + rows.append(["52WeekLow", data.get("52WeekLow", "")]) + rows.append(["Beta", data.get("Beta", "")]) + print_table("Moving Averages & Risk", rows) + + # Save raw JSON + output_path = Path(__file__).parent.parent / "docs" / "alphavantage_raw.json" + with open(output_path, 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: {output_path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/fetch_bea_schema.py b/scripts/fetch_bea_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..c3e04939de09ba51d5a899876ef34d365919f242 --- /dev/null +++ b/scripts/fetch_bea_schema.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Fetch raw BEA (Bureau of Economic Analysis) data and output the schema. +Shows raw API response structure for NIPA GDP data. +""" + +import asyncio +import json +import os +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv +import httpx + +# Load environment variables +env_paths = [ + Path.home() / ".env", + Path(__file__).parent.parent / ".env", +] +for env_path in env_paths: + if env_path.exists(): + load_dotenv(env_path) + break + +BEA_API_KEY = os.getenv("BEA_API_KEY") +BEA_BASE_URL = "https://apps.bea.gov/api/data" + + +def print_table(title: str, rows: list, col_widths: list = None): + """Print ASCII table.""" + if not rows: + return + + # Calculate column widths + if col_widths is None: + col_widths = [] + for col in range(len(rows[0])): + width = max(len(str(row[col])) for row in rows) + col_widths.append(width) + + # Print header + print(f"\n{title}") + + # Top border + line = "┌" + "┬".join("─" * (w + 2) for w in col_widths) + "┐" + print(line) + + # Header row + header = rows[0] + row_str = "│" + "│".join(f" {str(header[i]).ljust(col_widths[i])} " for i in range(len(header))) + "│" + print(row_str) + + # Separator + line = "├" + "┼".join("─" * (w + 2) for w in col_widths) + "┤" + print(line) + + # Data rows + for row in rows[1:]: + row_str = "│" + "│".join(f" {str(row[i]).ljust(col_widths[i])} " for i in range(len(row))) + "│" + print(row_str) + + # Bottom border + line = "└" + "┴".join("─" * (w + 2) for w in col_widths) + "┘" + print(line) + + +async def fetch_gdp_data() -> dict: + """Fetch GDP data from BEA NIPA dataset.""" + if not BEA_API_KEY: + return {"error": "BEA_API_KEY not configured"} + + try: + async with httpx.AsyncClient() as client: + params = { + "UserID": BEA_API_KEY, + "method": "GetData", + "datasetname": "NIPA", + "TableName": "T10101", # Percent Change From Preceding Period in Real GDP + "Frequency": "Q", # Quarterly + "Year": "X", # All recent years + "ResultFormat": "JSON" + } + response = await client.get(BEA_BASE_URL, params=params, timeout=15) + return response.json() + except Exception as e: + return {"error": str(e)} + + +async def main(): + print("BEA Data Schema") + print("=" * 60) + print() + print("Endpoint: https://apps.bea.gov/api/data") + print("Dataset: NIPA (National Income and Product Accounts)") + print("Table: T10101 (Percent Change From Preceding Period in Real GDP)") + print() + + if not BEA_API_KEY: + print("ERROR: BEA_API_KEY not configured") + print("Add BEA_API_KEY to ~/.env file") + print("Get free key at: https://apps.bea.gov/api/signup/") + return + + print("Fetching GDP data...") + data = await fetch_gdp_data() + + if "error" in data: + print(f"ERROR: {data}") + return + + beaapi = data.get("BEAAPI", {}) + results = beaapi.get("Results", {}) + + if not results: + print("ERROR: No results returned") + return + + print() + print("=" * 60) + print() + + # Print raw API response structure + print("Raw API Response Structure") + print("-" * 40) + + # Request metadata + request = beaapi.get("Request", {}) + rows = [["field", "value"]] + rows.append(["RequestParam.DataSetName", request.get("RequestParam", [{}])[0].get("ParameterValue", "") if request.get("RequestParam") else ""]) + rows.append(["RequestParam.TableName", "T10101"]) + rows.append(["RequestParam.Frequency", "Q"]) + print_table("BEAAPI.Request", rows) + + # Results metadata + rows = [["field", "description"]] + rows.append(["Statistic", results.get("Statistic", "")]) + rows.append(["UTCProductionTime", results.get("UTCProductionTime", "")]) + rows.append(["Notes[]", "Array of data notes/descriptions"]) + rows.append(["Data[]", "Array of data observations"]) + print_table("BEAAPI.Results", rows) + + # Data row structure + data_rows = results.get("Data", []) + if data_rows: + # Get a recent GDP row (LineNumber = 1 is Real GDP) + gdp_rows = [r for r in data_rows if r.get("LineNumber") == "1"] + gdp_rows.sort(key=lambda x: x.get("TimePeriod", ""), reverse=True) + + if gdp_rows: + sample = gdp_rows[0] + rows = [["field", "value"]] + rows.append(["TableName", sample.get("TableName", "")]) + rows.append(["SeriesCode", sample.get("SeriesCode", "")]) + rows.append(["LineNumber", sample.get("LineNumber", "")]) + rows.append(["LineDescription", sample.get("LineDescription", "")]) + rows.append(["TimePeriod", sample.get("TimePeriod", "")]) + rows.append(["METRIC_NAME", sample.get("METRIC_NAME", "")]) + rows.append(["CL_UNIT", sample.get("CL_UNIT", "")]) + rows.append(["UNIT_MULT", sample.get("UNIT_MULT", "")]) + rows.append(["DataValue", sample.get("DataValue", "")]) + rows.append(["NoteRef", sample.get("NoteRef", "")]) + print_table("Data[0] (Row Structure)", rows) + + # Field descriptions + print() + print() + print("Field Descriptions") + print("-" * 40) + + rows = [["field", "description"]] + rows.append(["TableName", "NIPA table identifier (T10101)"]) + rows.append(["SeriesCode", "BEA series code for the metric"]) + rows.append(["LineNumber", "Row number in the table (1 = Real GDP)"]) + rows.append(["LineDescription", "Human-readable metric name"]) + rows.append(["TimePeriod", "Time period (YYYYQN format, e.g., 2025Q3)"]) + rows.append(["METRIC_NAME", "Metric type (e.g., Percent Change)"]) + rows.append(["CL_UNIT", "Classification unit"]) + rows.append(["UNIT_MULT", "Unit multiplier"]) + rows.append(["DataValue", "The actual data value"]) + rows.append(["NoteRef", "Reference to notes array"]) + print_table("Field Descriptions", rows) + + # Recent GDP values + if gdp_rows: + print() + print() + print("Recent GDP Growth Data") + print("-" * 40) + + rows = [["TimePeriod", "DataValue", "LineDescription"]] + for row in gdp_rows[:6]: + rows.append([ + row.get("TimePeriod", ""), + row.get("DataValue", ""), + row.get("LineDescription", "")[:40] + ]) + print_table("Real GDP % Change (Latest)", rows) + + # Save raw JSON + output_path = Path(__file__).parent.parent / "docs" / "bea_raw.json" + with open(output_path, 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: {output_path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/fetch_bls_schema.py b/scripts/fetch_bls_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..c23b2bfc52173cb5c17ac5537ea3b2c3827f4cf2 --- /dev/null +++ b/scripts/fetch_bls_schema.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Fetch raw BLS (Bureau of Labor Statistics) data and output the schema. +Shows raw API response structure for CPI and Unemployment data. +""" + +import asyncio +import json +import os +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv +import httpx + +# Load environment variables +env_paths = [ + Path.home() / ".env", + Path(__file__).parent.parent / ".env", +] +for env_path in env_paths: + if env_path.exists(): + load_dotenv(env_path) + break + +BLS_API_KEY = os.getenv("BLS_API_KEY") +BLS_BASE_URL = "https://api.bls.gov/publicAPI/v2/timeseries/data/" + +# BLS Series IDs +SERIES = { + "cpi": "CUUR0000SA0", # CPI-U All items + "unemployment": "LNS14000000" # Unemployment rate +} + + +def print_table(title: str, rows: list, col_widths: list = None): + """Print ASCII table.""" + if not rows: + return + + # Calculate column widths + if col_widths is None: + col_widths = [] + for col in range(len(rows[0])): + width = max(len(str(row[col])) for row in rows) + col_widths.append(width) + + # Print header + print(f"\n{title}") + + # Top border + line = "┌" + "┬".join("─" * (w + 2) for w in col_widths) + "┐" + print(line) + + # Header row + header = rows[0] + row_str = "│" + "│".join(f" {str(header[i]).ljust(col_widths[i])} " for i in range(len(header))) + "│" + print(row_str) + + # Separator + line = "├" + "┼".join("─" * (w + 2) for w in col_widths) + "┤" + print(line) + + # Data rows + for row in rows[1:]: + row_str = "│" + "│".join(f" {str(row[i]).ljust(col_widths[i])} " for i in range(len(row))) + "│" + print(row_str) + + # Bottom border + line = "└" + "┴".join("─" * (w + 2) for w in col_widths) + "┘" + print(line) + + +async def fetch_bls_data(series_ids: list) -> dict: + """Fetch data from BLS API.""" + current_year = datetime.now().year + + try: + async with httpx.AsyncClient() as client: + payload = { + "seriesid": series_ids, + "startyear": str(current_year - 2), + "endyear": str(current_year) + } + + # Add API key if available (for v2 with higher limits) + if BLS_API_KEY: + payload["registrationkey"] = BLS_API_KEY + + headers = {"Content-Type": "application/json"} + response = await client.post(BLS_BASE_URL, json=payload, headers=headers, timeout=15) + return response.json() + except Exception as e: + return {"error": str(e)} + + +async def main(): + print("BLS Data Schema") + print("=" * 60) + print() + print("Endpoint: https://api.bls.gov/publicAPI/v2/timeseries/data/") + print("Method: POST with JSON payload") + print() + print("Series IDs:") + print(" - CUUR0000SA0: CPI-U All items (Consumer Price Index)") + print(" - LNS14000000: Unemployment Rate") + print() + + print("Fetching CPI and Unemployment data...") + data = await fetch_bls_data(list(SERIES.values())) + + if "error" in data: + print(f"ERROR: {data}") + return + + if data.get("status") != "REQUEST_SUCCEEDED": + print(f"ERROR: {data.get('message', 'Unknown error')}") + return + + print() + print("=" * 60) + print() + + # Print raw API response structure + print("Raw API Response Structure") + print("-" * 40) + + # Request payload + rows = [["field", "description"]] + rows.append(["seriesid[]", "Array of BLS series IDs to fetch"]) + rows.append(["startyear", "Start year for data range"]) + rows.append(["endyear", "End year for data range"]) + rows.append(["registrationkey", "Optional API key for higher limits"]) + print_table("Request Payload", rows) + + # Response metadata + rows = [["field", "value"]] + rows.append(["status", data.get("status", "")]) + rows.append(["responseTime", str(data.get("responseTime", ""))]) + rows.append(["message[]", "Array of status messages"]) + print_table("Response Metadata", rows) + + # Results structure + results = data.get("Results", {}) + series_list = results.get("series", []) + + rows = [["field", "description"]] + rows.append(["Results.series[]", f"Array of series data (count: {len(series_list)})"]) + print_table("Results Structure", rows) + + # Series data structure + if series_list: + sample_series = series_list[0] + rows = [["field", "value"]] + rows.append(["seriesID", sample_series.get("seriesID", "")]) + rows.append(["data[]", f"Array of observations (count: {len(sample_series.get('data', []))})"]) + print_table("series[0] (Series Structure)", rows) + + # Data observation structure + data_obs = sample_series.get("data", []) + if data_obs: + sample_obs = data_obs[0] + rows = [["field", "value"]] + rows.append(["year", sample_obs.get("year", "")]) + rows.append(["period", sample_obs.get("period", "")]) + rows.append(["periodName", sample_obs.get("periodName", "")]) + rows.append(["value", sample_obs.get("value", "")]) + rows.append(["footnotes[]", str(sample_obs.get("footnotes", []))]) + print_table("data[0] (Observation Structure)", rows) + + # Field descriptions + print() + print() + print("Field Descriptions") + print("-" * 40) + + rows = [["field", "description"]] + rows.append(["seriesID", "BLS series identifier"]) + rows.append(["year", "4-digit year (e.g., 2025)"]) + rows.append(["period", "Period code (M01-M12 for monthly, A01 for annual)"]) + rows.append(["periodName", "Human-readable period (January, February, etc.)"]) + rows.append(["value", "Data value as string"]) + rows.append(["footnotes", "Array of footnote codes"]) + print_table("Field Descriptions", rows) + + # Series data + print() + print() + print("Series Data") + print("-" * 40) + + for series in series_list: + series_id = series.get("seriesID", "") + series_name = "CPI-U All Items" if series_id == "CUUR0000SA0" else "Unemployment Rate" + data_obs = series.get("data", []) + + rows = [["field", "value"]] + rows.append(["series_id", series_id]) + rows.append(["name", series_name]) + + if data_obs: + latest = data_obs[0] + rows.append(["period", f"{latest.get('year')}-{latest.get('periodName', latest.get('period'))}"]) + rows.append(["value", latest.get("value", "")]) + print_table(series_name, rows) + + # Save raw JSON + output_path = Path(__file__).parent.parent / "docs" / "bls_raw.json" + with open(output_path, 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: {output_path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/fetch_financials_schema.py b/scripts/fetch_financials_schema.py new file mode 100755 index 0000000000000000000000000000000000000000..589677945414c6954626db4c4e2a4abe99221ce5 --- /dev/null +++ b/scripts/fetch_financials_schema.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Fetch raw financials data from SEC EDGAR and Yahoo Finance. +Outputs the data schema with actual values. +""" + +import asyncio +import json +import sys +sys.path.insert(0, '/home/vn6295337/Researcher-Agent') + +from mcp_client import call_mcp_server + + +async def fetch_financials(ticker: str = 'AAPL'): + """Fetch financials from SEC EDGAR and Yahoo Finance.""" + result = await call_mcp_server( + 'fundamentals-basket', + 'get_all_sources_fundamentals', + {'ticker': ticker}, + timeout=90 + ) + return result + + +def print_schema(data: dict, ticker: str): + """Print data schema in plain text format.""" + print("SEC EDGAR & Yahoo Finance Data Schema") + print("=" * 40) + print(f"\nExample Ticker: {ticker}") + print() + + # SEC EDGAR + if 'sec_edgar' in data: + sec = data['sec_edgar'] + print("\nSEC EDGAR") + print("-" * 40) + print(f"source: {sec.get('source')}") + print(f"as_of: {sec.get('as_of')}") + + sec_data = sec.get('data', {}) + for category in ['financials', 'debt', 'cash_flow']: + if category in sec_data: + print(f"\n{category.upper()}") + cat_data = sec_data[category] + for key, val in cat_data.items(): + if key in ['ticker', 'source', 'as_of']: + continue + print(f"\n {key}") + if isinstance(val, dict): + for k, v in val.items(): + print(f" {k}: {v}") + else: + print(f" value: {val}") + + # Yahoo Finance + if 'yahoo_finance' in data: + yf = data['yahoo_finance'] + print("\n\nYahoo Finance") + print("-" * 40) + print(f"source: {yf.get('source')}") + print(f"as_of: {yf.get('as_of')}") + + yf_data = yf.get('data', {}) + for category in ['financials', 'debt', 'cash_flow']: + if category in yf_data: + print(f"\n{category.upper()}") + cat_data = yf_data[category] + for key, val in cat_data.items(): + if key in ['ticker', 'source', 'as_of']: + continue + print(f"\n {key}") + if isinstance(val, dict): + for k, v in val.items(): + print(f" {k}: {v}") + else: + print(f" value: {val}") + + +async def main(): + ticker = sys.argv[1] if len(sys.argv) > 1 else 'AAPL' + + print(f"Fetching financials for {ticker}...") + data = await fetch_financials(ticker) + + if data: + print_schema(data, ticker) + + # Also save raw JSON + with open(f'/home/vn6295337/Researcher-Agent/docs/{ticker}_financials_raw.json', 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: docs/{ticker}_financials_raw.json") + else: + print("Failed to fetch data") + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/scripts/fetch_fred_schema.py b/scripts/fetch_fred_schema.py new file mode 100755 index 0000000000000000000000000000000000000000..8ae594326864ce8b3a945b01c5fcd03b2308fba6 --- /dev/null +++ b/scripts/fetch_fred_schema.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Fetch raw FRED data and output the schema. +Shows both raw API response and enriched format. +""" + +import asyncio +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv +import httpx + +# Load environment variables +env_paths = [ + Path.home() / ".env", + Path(__file__).parent.parent / ".env", +] +for env_path in env_paths: + if env_path.exists(): + load_dotenv(env_path) + break + +FRED_API_KEY = os.getenv("FRED_API_KEY") or os.getenv("FRED_VIX_API_KEY") +FRED_BASE_URL = "https://api.stlouisfed.org/fred" + +# Series to fetch +SERIES = { + "gdp_growth": "A191RL1Q225SBEA", + "interest_rate": "FEDFUNDS", + "cpi": "CPIAUCSL", + "unemployment": "UNRATE", + "vix": "VIXCLS", + "vxn": "VXNCLS", +} + + +async def fetch_series_raw(series_id: str, limit: int = 5) -> dict: + """Fetch raw FRED data for a series.""" + if not FRED_API_KEY: + return {"error": "FRED_API_KEY not configured"} + + async with httpx.AsyncClient() as client: + # Get series info + info_url = f"{FRED_BASE_URL}/series" + info_params = { + "series_id": series_id, + "api_key": FRED_API_KEY, + "file_type": "json" + } + info_resp = await client.get(info_url, params=info_params, timeout=10) + info_data = info_resp.json() + + # Get observations + obs_url = f"{FRED_BASE_URL}/series/observations" + obs_params = { + "series_id": series_id, + "api_key": FRED_API_KEY, + "file_type": "json", + "sort_order": "desc", + "limit": limit + } + obs_resp = await client.get(obs_url, params=obs_params, timeout=10) + obs_data = obs_resp.json() + + return { + "series_info": info_data, + "observations": obs_data + } + + +def print_table(title: str, rows: list, col_widths: list = None): + """Print ASCII table.""" + if not rows: + return + + # Calculate column widths + if col_widths is None: + col_widths = [] + for col in range(len(rows[0])): + width = max(len(str(row[col])) for row in rows) + col_widths.append(width) + + # Print header + print(f"\n{title}") + + # Top border + line = "┌" + "┬".join("─" * (w + 2) for w in col_widths) + "┐" + print(line) + + # Header row + header = rows[0] + row_str = "│" + "│".join(f" {str(header[i]).ljust(col_widths[i])} " for i in range(len(header))) + "│" + print(row_str) + + # Separator + line = "├" + "┼".join("─" * (w + 2) for w in col_widths) + "┤" + print(line) + + # Data rows + for row in rows[1:]: + row_str = "│" + "│".join(f" {str(row[i]).ljust(col_widths[i])} " for i in range(len(row))) + "│" + print(row_str) + + # Bottom border + line = "└" + "┴".join("─" * (w + 2) for w in col_widths) + "┘" + print(line) + + +async def main(): + print("FRED Data Schema") + print("=" * 60) + print() + print("Endpoint: https://api.stlouisfed.org/fred/series/observations") + print() + + if not FRED_API_KEY: + print("ERROR: FRED_API_KEY not configured") + print("Add FRED_API_KEY to ~/.env file") + return + + all_data = {} + + # Fetch each series + for name, series_id in SERIES.items(): + print(f"Fetching {name} ({series_id})...") + data = await fetch_series_raw(series_id, limit=3) + all_data[name] = data + + print() + print("=" * 60) + print() + + # Print raw API response structure + print("Raw API Response Structure") + print("-" * 40) + print() + + # Series info fields + sample = all_data.get("gdp_growth", {}) + series_info = sample.get("series_info", {}).get("seriess", [{}])[0] + + rows = [["field", "description", "example"]] + rows.append(["id", "Series identifier", series_info.get("id", "")]) + rows.append(["title", "Series title", series_info.get("title", "")[:40]]) + rows.append(["units", "Data units", series_info.get("units", "")]) + rows.append(["frequency", "Update frequency", series_info.get("frequency", "")]) + rows.append(["seasonal_adjustment", "Adjustment type", series_info.get("seasonal_adjustment", "")]) + rows.append(["last_updated", "Last update time", series_info.get("last_updated", "")]) + print_table("Series Info (seriess[0])", rows) + + # Observation fields + obs = sample.get("observations", {}).get("observations", [{}])[0] + rows = [["field", "description", "example"]] + rows.append(["realtime_start", "Real-time period start", obs.get("realtime_start", "")]) + rows.append(["realtime_end", "Real-time period end", obs.get("realtime_end", "")]) + rows.append(["date", "Observation date", obs.get("date", "")]) + rows.append(["value", "Data value", obs.get("value", "")]) + print_table("Observation (observations[])", rows) + + print() + print() + print("Series Data") + print("-" * 40) + + # Print each series + for name, data in all_data.items(): + series_info = data.get("series_info", {}).get("seriess", [{}])[0] + observations = data.get("observations", {}).get("observations", []) + + # Get latest observation + latest = None + for obs in observations: + if obs.get("value") and obs["value"] != ".": + latest = obs + break + + rows = [["field", "value"]] + rows.append(["series_id", SERIES[name]]) + rows.append(["title", series_info.get("title", "")[:50]]) + rows.append(["units", series_info.get("units", "")]) + rows.append(["frequency", series_info.get("frequency", "")]) + rows.append(["date", latest.get("date", "") if latest else ""]) + rows.append(["value", latest.get("value", "") if latest else ""]) + rows.append(["last_updated", series_info.get("last_updated", "")[:19]]) + + print_table(name, rows) + + # Save raw JSON + output_path = Path(__file__).parent.parent / "docs" / "fred_raw.json" + with open(output_path, 'w') as f: + json.dump(all_data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: {output_path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/fetch_macro_schema.py b/scripts/fetch_macro_schema.py new file mode 100755 index 0000000000000000000000000000000000000000..121c733eed36905dee88cfafadb7e3b0e8f0da26 --- /dev/null +++ b/scripts/fetch_macro_schema.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Fetch raw macro economic data from BEA, BLS, and FRED. +Outputs the data schema with actual values. +""" + +import asyncio +import json +import sys +sys.path.insert(0, '/home/vn6295337/Researcher-Agent') + +from mcp_client import call_mcp_server + + +async def fetch_macro(): + """Fetch macro data from multiple sources.""" + result = await call_mcp_server( + 'macro-basket', + 'get_all_sources_macro', + {}, + timeout=90 + ) + return result + + +def print_metric(key, val, indent=2): + """Print a metric with proper indentation.""" + prefix = " " * indent + print(f"\n{prefix}{key}") + if isinstance(val, dict): + for k, v in val.items(): + print(f"{prefix} {k}: {v}") + elif val is None: + print(f"{prefix} value: null") + else: + print(f"{prefix} value: {val}") + + +def print_schema(data: dict): + """Print data schema in plain text format.""" + print("Macro Economic Data Schema") + print("=" * 50) + + # BEA + BLS + if 'bea_bls' in data: + src = data['bea_bls'] + print(f"\n\nBEA + BLS (Primary)") + print("-" * 40) + print(f"source: {src.get('source')}") + print(f"as_of: {src.get('as_of')}") + for key, val in src.get('data', {}).items(): + print_metric(key, val) + + # FRED + if 'fred' in data: + src = data['fred'] + print(f"\n\nFRED (Secondary)") + print("-" * 40) + print(f"source: {src.get('source')}") + print(f"as_of: {src.get('as_of')}") + for key, val in src.get('data', {}).items(): + print_metric(key, val) + + # Source hierarchy + if 'primary_source_hierarchy' in data: + print(f"\n\nPrimary Source Hierarchy") + print("-" * 40) + for key, val in data['primary_source_hierarchy'].items(): + print(f"{key}: {val}") + + +async def main(): + print("Fetching macro economic data...") + data = await fetch_macro() + + if data: + print_schema(data) + + with open('/home/vn6295337/Researcher-Agent/docs/macro_raw.json', 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: docs/macro_raw.json") + else: + print("Failed to fetch data") + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/scripts/fetch_valuation_schema.py b/scripts/fetch_valuation_schema.py new file mode 100755 index 0000000000000000000000000000000000000000..2f7ecd5c503a7d5c7a195ed72f79da3274bcb679 --- /dev/null +++ b/scripts/fetch_valuation_schema.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Fetch raw valuation data from Yahoo Finance and Alpha Vantage. +Outputs the data schema with actual values. +""" + +import asyncio +import json +import sys +sys.path.insert(0, '/home/vn6295337/Researcher-Agent') + +from mcp_client import call_mcp_server + + +async def fetch_valuation(ticker: str = 'AAPL'): + """Fetch valuation from Yahoo Finance and Alpha Vantage.""" + result = await call_mcp_server( + 'valuation-basket', + 'get_all_sources_valuation', + {'ticker': ticker}, + timeout=90 + ) + return result + + +def print_schema(data: dict, ticker: str): + """Print data schema in plain text format.""" + print("Yahoo Finance & Alpha Vantage Valuation Data Schema") + print("=" * 50) + print(f"\nExample Ticker: {ticker}") + print() + + for source_key in ['yahoo_finance', 'alpha_vantage']: + if source_key in data: + source_data = data[source_key] + source_name = source_data.get('source', source_key) + + print(f"\n{source_name}") + print("-" * 40) + print(f"source: {source_name}") + print(f"as_of: {source_data.get('as_of')}") + + metrics = source_data.get('data', {}) + for key, val in metrics.items(): + print(f"\n {key}") + if isinstance(val, dict): + for k, v in val.items(): + print(f" {k}: {v}") + else: + print(f" value: {val}") + + +async def main(): + ticker = sys.argv[1] if len(sys.argv) > 1 else 'AAPL' + + print(f"Fetching valuation for {ticker}...") + data = await fetch_valuation(ticker) + + if data: + print_schema(data, ticker) + + # Also save raw JSON + with open(f'/home/vn6295337/Researcher-Agent/docs/{ticker}_valuation_raw.json', 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: docs/{ticker}_valuation_raw.json") + else: + print("Failed to fetch data") + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/scripts/fetch_volatility_schema.py b/scripts/fetch_volatility_schema.py new file mode 100755 index 0000000000000000000000000000000000000000..8518d00b8c57e7b3ba33aa9956938fd7dc294212 --- /dev/null +++ b/scripts/fetch_volatility_schema.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Fetch raw volatility data from Yahoo Finance, Alpha Vantage, and FRED. +Outputs the data schema with actual values. +""" + +import asyncio +import json +import sys +sys.path.insert(0, '/home/vn6295337/Researcher-Agent') + +from mcp_client import call_mcp_server + + +async def fetch_volatility(ticker: str = 'AAPL'): + """Fetch volatility from multiple sources.""" + result = await call_mcp_server( + 'volatility-basket', + 'get_all_sources_volatility', + {'ticker': ticker}, + timeout=90 + ) + return result + + +def print_metric(key, val, indent=2): + """Print a metric with proper indentation.""" + prefix = " " * indent + print(f"\n{prefix}{key}") + if isinstance(val, dict): + for k, v in val.items(): + print(f"{prefix} {k}: {v}") + elif val is None: + print(f"{prefix} value: null") + else: + print(f"{prefix} value: {val}") + + +def print_schema(data: dict, ticker: str): + """Print data schema in plain text format.""" + print("Volatility Data Schema") + print("=" * 50) + print(f"\nExample Ticker: {ticker}") + + # Market volatility context + if 'market_volatility_context' in data: + ctx = data['market_volatility_context'] + print(f"\n\nMarket Volatility Context") + print("-" * 40) + print(f"description: {ctx.get('description')}") + print(f"note: {ctx.get('note')}") + for key in ['vix', 'vxn']: + if key in ctx: + print_metric(key, ctx[key]) + + # Yahoo Finance + if 'yahoo_finance' in data: + yf = data['yahoo_finance'] + print(f"\n\nYahoo Finance (Primary)") + print("-" * 40) + print(f"source: {yf.get('source')}") + print(f"as_of: {yf.get('as_of')}") + for key, val in yf.get('data', {}).items(): + print_metric(key, val) + + # Alpha Vantage + if 'alpha_vantage' in data: + av = data['alpha_vantage'] + print(f"\n\nAlpha Vantage (Secondary)") + print("-" * 40) + print(f"source: {av.get('source')}") + print(f"as_of: {av.get('as_of')}") + for key, val in av.get('data', {}).items(): + print_metric(key, val) + + # Source hierarchy + if 'primary_source_hierarchy' in data: + print(f"\n\nPrimary Source Hierarchy") + print("-" * 40) + for key, val in data['primary_source_hierarchy'].items(): + print(f"{key}: {val}") + + +async def main(): + ticker = sys.argv[1] if len(sys.argv) > 1 else 'AAPL' + + print(f"Fetching volatility for {ticker}...") + data = await fetch_volatility(ticker) + + if data: + print_schema(data, ticker) + + with open(f'/home/vn6295337/Researcher-Agent/docs/{ticker}_volatility_raw.json', 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: docs/{ticker}_volatility_raw.json") + else: + print("Failed to fetch data") + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/scripts/fetch_yahoo_options_schema.py b/scripts/fetch_yahoo_options_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..714fe643240529a60b278a4414594ee1c033c33e --- /dev/null +++ b/scripts/fetch_yahoo_options_schema.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Fetch raw Yahoo Finance Options data and output the schema. +Shows raw API response structure for options chain data. +""" + +import asyncio +import json +from datetime import datetime +from pathlib import Path + +import httpx + +YAHOO_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", +} + + +def print_table(title: str, rows: list, col_widths: list = None): + """Print ASCII table.""" + if not rows: + return + + # Calculate column widths + if col_widths is None: + col_widths = [] + for col in range(len(rows[0])): + width = max(len(str(row[col])) for row in rows) + col_widths.append(width) + + # Print header + print(f"\n{title}") + + # Top border + line = "┌" + "┬".join("─" * (w + 2) for w in col_widths) + "┐" + print(line) + + # Header row + header = rows[0] + row_str = "│" + "│".join(f" {str(header[i]).ljust(col_widths[i])} " for i in range(len(header))) + "│" + print(row_str) + + # Separator + line = "├" + "┼".join("─" * (w + 2) for w in col_widths) + "┤" + print(line) + + # Data rows + for row in rows[1:]: + row_str = "│" + "│".join(f" {str(row[i]).ljust(col_widths[i])} " for i in range(len(row))) + "│" + print(row_str) + + # Bottom border + line = "└" + "┴".join("─" * (w + 2) for w in col_widths) + "┘" + print(line) + + +async def fetch_options(ticker: str) -> dict: + """Fetch options chain from Yahoo Finance.""" + try: + async with httpx.AsyncClient() as client: + url = f"https://query1.finance.yahoo.com/v7/finance/options/{ticker}" + response = await client.get(url, headers=YAHOO_HEADERS, timeout=15) + return response.json() + except Exception as e: + return {"error": str(e)} + + +async def main(): + print("Yahoo Finance Options Data Schema") + print("=" * 60) + print() + print("Endpoint: https://query1.finance.yahoo.com/v7/finance/options/{ticker}") + print() + + print("Fetching AAPL options chain...") + data = await fetch_options("AAPL") + + if "error" in data: + print(f"ERROR: {data}") + return + + option_chain = data.get("optionChain", {}) + result = option_chain.get("result", [{}])[0] if option_chain.get("result") else {} + + if not result: + print("ERROR: No options data returned") + return + + print() + print("=" * 60) + print() + + # Print raw API response structure + print("Raw API Response Structure") + print("-" * 40) + + # Underlying quote + quote = result.get("quote", {}) + rows = [["field", "value"]] + rows.append(["symbol", quote.get("symbol", "")]) + rows.append(["regularMarketPrice", quote.get("regularMarketPrice", "")]) + rows.append(["regularMarketTime", quote.get("regularMarketTime", "")]) + rows.append(["regularMarketChange", quote.get("regularMarketChange", "")]) + rows.append(["regularMarketChangePercent", quote.get("regularMarketChangePercent", "")]) + print_table("quote (Underlying)", rows) + + # Expiration dates + expirations = result.get("expirationDates", []) + rows = [["field", "description"]] + rows.append(["expirationDates[]", "Unix timestamps of available expiration dates"]) + rows.append(["count", str(len(expirations))]) + if expirations: + rows.append(["first", str(expirations[0])]) + rows.append(["last", str(expirations[-1])]) + print_table("Expiration Dates", rows) + + # Strikes + strikes = result.get("strikes", []) + rows = [["field", "description"]] + rows.append(["strikes[]", "Available strike prices"]) + rows.append(["count", str(len(strikes))]) + if strikes: + rows.append(["min", str(min(strikes))]) + rows.append(["max", str(max(strikes))]) + print_table("Strike Prices", rows) + + # Options data structure + options = result.get("options", [{}])[0] if result.get("options") else {} + calls = options.get("calls", []) + puts = options.get("puts", []) + + rows = [["field", "description"]] + rows.append(["expirationDate", "Expiration date (Unix timestamp)"]) + rows.append(["calls[]", f"Call options array (count: {len(calls)})"]) + rows.append(["puts[]", f"Put options array (count: {len(puts)})"]) + print_table("options[0] (First Expiration)", rows) + + # Call/Put contract fields + if calls: + sample_call = calls[0] + rows = [["field", "value"]] + rows.append(["contractSymbol", sample_call.get("contractSymbol", "")]) + rows.append(["strike", sample_call.get("strike", "")]) + rows.append(["currency", sample_call.get("currency", "")]) + rows.append(["lastPrice", sample_call.get("lastPrice", "")]) + rows.append(["change", sample_call.get("change", "")]) + rows.append(["percentChange", sample_call.get("percentChange", "")]) + rows.append(["volume", sample_call.get("volume", "")]) + rows.append(["openInterest", sample_call.get("openInterest", "")]) + rows.append(["bid", sample_call.get("bid", "")]) + rows.append(["ask", sample_call.get("ask", "")]) + rows.append(["impliedVolatility", sample_call.get("impliedVolatility", "")]) + rows.append(["inTheMoney", sample_call.get("inTheMoney", "")]) + rows.append(["expiration", sample_call.get("expiration", "")]) + rows.append(["lastTradeDate", sample_call.get("lastTradeDate", "")]) + print_table("calls[0] / puts[0] (Contract Fields)", rows) + + # ATM implied volatility example + print() + print() + print("Implied Volatility Extraction") + print("-" * 40) + + current_price = quote.get("regularMarketPrice", 0) + if calls and current_price: + atm_call = min(calls, key=lambda x: abs(x.get("strike", 0) - current_price)) + iv = atm_call.get("impliedVolatility", 0) * 100 + + rows = [["field", "value"]] + rows.append(["currentPrice", f"{current_price:.2f}"]) + rows.append(["atmStrike", atm_call.get("strike", "")]) + rows.append(["impliedVolatility (raw)", atm_call.get("impliedVolatility", "")]) + rows.append(["impliedVolatility (%)", f"{iv:.2f}%"]) + print_table("ATM Call Option", rows) + + # Save raw JSON + output_path = Path(__file__).parent.parent / "docs" / "yahoo_options_raw.json" + with open(output_path, 'w') as f: + json.dump(data, f, indent=2, default=str) + print(f"\nRaw JSON saved to: {output_path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/mcp_stress_test.py b/scripts/mcp_stress_test.py new file mode 100644 index 0000000000000000000000000000000000000000..25e98390c7f5af0151ca065ae11fbcaff82df6da --- /dev/null +++ b/scripts/mcp_stress_test.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +MCP Server Stress Test CLI + +Usage: + python scripts/mcp_stress_test.py --mode smoke + python scripts/mcp_stress_test.py --mode standard --output reports/ + python scripts/mcp_stress_test.py --mode stress --batch-size 100 +""" + +import asyncio +import argparse +import json +import sys +import time +from pathlib import Path +from datetime import datetime + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from tests.mcp_reliability.company_sampler import CompanySampler, SamplingStrategy +from tests.mcp_reliability.rate_limiter import get_rate_limiter_registry +from tests.mcp_reliability.circuit_breaker import get_circuit_breaker_registry +from tests.mcp_reliability.test_stress import MCPTestRunner, TestConfig + + +# Test mode configurations +TEST_MODES = { + "smoke": { + "batch_size": 5, + "sampling_strategy": "uniform", + "max_concurrent": 3, + "description": "Quick smoke test for basic functionality" + }, + "standard": { + "batch_size": 50, + "sampling_strategy": "mixed", + "max_concurrent": 5, + "description": "Standard reliability test" + }, + "stress": { + "batch_size": 100, + "sampling_strategy": "uniform", + "max_concurrent": 10, + "request_interval_ms": 50, + "description": "High-load stress test" + }, + "soak": { + "batch_size": 200, + "sampling_strategy": "stratified", + "max_concurrent": 5, + "description": "Long-running soak test" + } +} + + +def print_banner(): + """Print CLI banner.""" + print(""" +╔═══════════════════════════════════════════════════════════╗ +║ MCP Server Stress Test Framework ║ +║ High-frequency reliability testing for MCP servers ║ +╚═══════════════════════════════════════════════════════════╝ +""") + + +def print_summary(summary: dict): + """Print formatted test summary.""" + print("\n" + "="*60) + print(" TEST SUMMARY") + print("="*60) + + print(f"\nTotal Requests: {summary['total']}") + print(f"Success Rate: {summary['success_rate']*100:.1f}%") + print(f"Fallback Rate: {summary['fallback_rate']*100:.1f}%") + print(f"Failure Rate: {summary['failure_rate']*100:.1f}%") + + print(f"\nLatency Percentiles:") + print(f" P50: {summary['latency_p50']:.0f}ms") + print(f" P95: {summary['latency_p95']:.0f}ms") + print(f" P99: {summary['latency_p99']:.0f}ms") + + print(f"\nResults by Category:") + for category, count in summary['by_category'].items(): + if count > 0: + pct = count / summary['total'] * 100 + print(f" {category:15s}: {count:4d} ({pct:5.1f}%)") + + print(f"\nResults by Server:") + for server, cats in summary['by_server'].items(): + total = sum(cats.values()) + success = cats.get('success', 0) + cats.get('partial', 0) + cats.get('fallback', 0) + rate = success / total * 100 if total > 0 else 0 + print(f" {server:20s}: {rate:5.1f}% success ({total} requests)") + + # Circuit breaker status + cb_status = summary.get('circuit_breaker_status', {}) + open_breakers = [name for name, s in cb_status.items() if s.get('state') == 'open'] + if open_breakers: + print(f"\n⚠️ Open Circuit Breakers: {', '.join(open_breakers)}") + + print("\n" + "="*60) + + +def check_exit_criteria(summary: dict, mode: str) -> bool: + """Check if test results meet exit criteria.""" + criteria = { + "smoke": {"success_rate": 0.95, "p99_latency": 5000, "failure_rate": 0.0}, + "standard": {"success_rate": 0.90, "p99_latency": 10000, "failure_rate": 0.05}, + "stress": {"success_rate": 0.85, "p99_latency": 15000, "failure_rate": 0.10}, + "soak": {"success_rate": 0.85, "p99_latency": 15000, "failure_rate": 0.10} + } + + c = criteria.get(mode, criteria["standard"]) + + passed = True + print("\nExit Criteria Check:") + + effective_success = summary['success_rate'] + summary['fallback_rate'] + if effective_success >= c["success_rate"]: + print(f" ✓ Success rate {effective_success*100:.1f}% >= {c['success_rate']*100:.0f}%") + else: + print(f" ✗ Success rate {effective_success*100:.1f}% < {c['success_rate']*100:.0f}%") + passed = False + + if summary['latency_p99'] <= c["p99_latency"]: + print(f" ✓ P99 latency {summary['latency_p99']:.0f}ms <= {c['p99_latency']}ms") + else: + print(f" ✗ P99 latency {summary['latency_p99']:.0f}ms > {c['p99_latency']}ms") + passed = False + + if summary['failure_rate'] <= c["failure_rate"]: + print(f" ✓ Failure rate {summary['failure_rate']*100:.1f}% <= {c['failure_rate']*100:.0f}%") + else: + print(f" ✗ Failure rate {summary['failure_rate']*100:.1f}% > {c['failure_rate']*100:.0f}%") + passed = False + + return passed + + +async def run_test(config: TestConfig) -> dict: + """Run the stress test with given configuration.""" + runner = MCPTestRunner(config) + return await runner.run() + + +def main(): + parser = argparse.ArgumentParser( + description="MCP Server Stress Test CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python scripts/mcp_stress_test.py --mode smoke + python scripts/mcp_stress_test.py --mode standard --output reports/ + python scripts/mcp_stress_test.py --batch-size 100 --strategy stratified + """ + ) + + parser.add_argument( + "--mode", "-m", + choices=list(TEST_MODES.keys()), + default="standard", + help="Test mode (default: standard)" + ) + parser.add_argument( + "--batch-size", "-b", + type=int, + help="Override batch size" + ) + parser.add_argument( + "--strategy", "-s", + choices=["uniform", "stratified", "edge_case", "mixed"], + help="Override sampling strategy" + ) + parser.add_argument( + "--max-concurrent", "-c", + type=int, + help="Override max concurrent requests" + ) + parser.add_argument( + "--seed", + type=int, + help="Random seed for reproducibility" + ) + parser.add_argument( + "--output", "-o", + type=Path, + help="Output directory for results" + ) + parser.add_argument( + "--json", + action="store_true", + help="Output results as JSON only" + ) + parser.add_argument( + "--servers", + nargs="+", + help="Specific servers to test" + ) + + args = parser.parse_args() + + if not args.json: + print_banner() + + # Build configuration + mode_config = TEST_MODES[args.mode] + + config = TestConfig( + batch_size=args.batch_size or mode_config["batch_size"], + sampling_strategy=args.strategy or mode_config["sampling_strategy"], + max_concurrent=args.max_concurrent or mode_config.get("max_concurrent", 5), + request_interval_ms=mode_config.get("request_interval_ms", 200), + seed=args.seed or int(time.time()), + servers=args.servers + ) + + if not args.json: + print(f"Mode: {args.mode} - {mode_config['description']}") + print(f"Batch size: {config.batch_size}") + print(f"Strategy: {config.sampling_strategy}") + print(f"Max concurrent: {config.max_concurrent}") + print(f"Seed: {config.seed}") + print("-"*60) + + # Run test + summary = asyncio.run(run_test(config)) + + # Output results + if args.json: + print(json.dumps(summary, indent=2)) + else: + print_summary(summary) + passed = check_exit_criteria(summary, args.mode) + + if passed: + print("\n✓ TEST PASSED") + else: + print("\n✗ TEST FAILED") + + # Save results if output specified + if args.output: + args.output.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + summary_path = args.output / f"mcp_test_{args.mode}_{timestamp}.json" + + with open(summary_path, "w") as f: + json.dump(summary, f, indent=2) + + if not args.json: + print(f"\nResults saved to: {summary_path}") + + # Exit with appropriate code + if not args.json: + passed = check_exit_criteria(summary, args.mode) + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e3d73a59225d6d1b7d1b66c057e3fa63b76aff92 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for Researcher-Agent.""" diff --git a/tests/fixtures/test_tickers.json b/tests/fixtures/test_tickers.json new file mode 100644 index 0000000000000000000000000000000000000000..f52b98edd5090b1524253e6147e1ee3507888cb7 --- /dev/null +++ b/tests/fixtures/test_tickers.json @@ -0,0 +1,63 @@ +{ + "sp500_sample": [ + {"ticker": "AAPL", "name": "Apple Inc.", "sector": "Technology"}, + {"ticker": "MSFT", "name": "Microsoft Corporation", "sector": "Technology"}, + {"ticker": "GOOGL", "name": "Alphabet Inc.", "sector": "Technology"}, + {"ticker": "AMZN", "name": "Amazon.com Inc.", "sector": "Consumer Discretionary"}, + {"ticker": "NVDA", "name": "NVIDIA Corporation", "sector": "Technology"}, + {"ticker": "META", "name": "Meta Platforms Inc.", "sector": "Technology"}, + {"ticker": "TSLA", "name": "Tesla Inc.", "sector": "Consumer Discretionary"}, + {"ticker": "BRK.B", "name": "Berkshire Hathaway Inc.", "sector": "Financials"}, + {"ticker": "JPM", "name": "JPMorgan Chase & Co.", "sector": "Financials"}, + {"ticker": "V", "name": "Visa Inc.", "sector": "Financials"}, + {"ticker": "JNJ", "name": "Johnson & Johnson", "sector": "Healthcare"}, + {"ticker": "UNH", "name": "UnitedHealth Group Inc.", "sector": "Healthcare"}, + {"ticker": "PG", "name": "Procter & Gamble Co.", "sector": "Consumer Staples"}, + {"ticker": "HD", "name": "Home Depot Inc.", "sector": "Consumer Discretionary"}, + {"ticker": "MA", "name": "Mastercard Inc.", "sector": "Financials"}, + {"ticker": "XOM", "name": "Exxon Mobil Corporation", "sector": "Energy"}, + {"ticker": "CVX", "name": "Chevron Corporation", "sector": "Energy"}, + {"ticker": "LLY", "name": "Eli Lilly and Company", "sector": "Healthcare"}, + {"ticker": "ABBV", "name": "AbbVie Inc.", "sector": "Healthcare"}, + {"ticker": "PFE", "name": "Pfizer Inc.", "sector": "Healthcare"}, + {"ticker": "KO", "name": "Coca-Cola Company", "sector": "Consumer Staples"}, + {"ticker": "PEP", "name": "PepsiCo Inc.", "sector": "Consumer Staples"}, + {"ticker": "MRK", "name": "Merck & Co. Inc.", "sector": "Healthcare"}, + {"ticker": "COST", "name": "Costco Wholesale Corporation", "sector": "Consumer Staples"}, + {"ticker": "WMT", "name": "Walmart Inc.", "sector": "Consumer Staples"}, + {"ticker": "DIS", "name": "Walt Disney Company", "sector": "Communication Services"}, + {"ticker": "CSCO", "name": "Cisco Systems Inc.", "sector": "Technology"}, + {"ticker": "VZ", "name": "Verizon Communications Inc.", "sector": "Communication Services"}, + {"ticker": "INTC", "name": "Intel Corporation", "sector": "Technology"}, + {"ticker": "IBM", "name": "IBM Corporation", "sector": "Technology"}, + {"ticker": "BA", "name": "Boeing Company", "sector": "Industrials"}, + {"ticker": "CAT", "name": "Caterpillar Inc.", "sector": "Industrials"}, + {"ticker": "GS", "name": "Goldman Sachs Group Inc.", "sector": "Financials"}, + {"ticker": "MS", "name": "Morgan Stanley", "sector": "Financials"}, + {"ticker": "AXP", "name": "American Express Company", "sector": "Financials"}, + {"ticker": "T", "name": "AT&T Inc.", "sector": "Communication Services"}, + {"ticker": "NEE", "name": "NextEra Energy Inc.", "sector": "Utilities"}, + {"ticker": "LIN", "name": "Linde plc", "sector": "Materials"}, + {"ticker": "RTX", "name": "RTX Corporation", "sector": "Industrials"}, + {"ticker": "HON", "name": "Honeywell International Inc.", "sector": "Industrials"} + ], + "edge_cases": [ + {"ticker": "BRK.A", "name": "Berkshire Hathaway Class A", "sector": "Financials", "note": "High price stock"}, + {"ticker": "GOOG", "name": "Alphabet Inc. Class C", "sector": "Technology", "note": "Dual class"}, + {"ticker": "NVR", "name": "NVR Inc.", "sector": "Consumer Discretionary", "note": "High price"}, + {"ticker": "AZO", "name": "AutoZone Inc.", "sector": "Consumer Discretionary", "note": "High price"} + ], + "sectors": [ + "Technology", + "Financials", + "Healthcare", + "Consumer Discretionary", + "Consumer Staples", + "Communication Services", + "Industrials", + "Energy", + "Utilities", + "Materials", + "Real Estate" + ] +} diff --git a/tests/mcp_reliability/__init__.py b/tests/mcp_reliability/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5ba4abda0b0da3f461780d0747535070941cb1ff --- /dev/null +++ b/tests/mcp_reliability/__init__.py @@ -0,0 +1,27 @@ +""" +MCP Server Reliability Testing Framework + +Provides high-frequency, randomized stress testing for MCP servers with: +- Company sampling strategies +- Rate limiting protection +- Circuit breaker patterns +- Result classification and aggregation +""" + +from .company_sampler import CompanySampler, SamplingStrategy, create_test_batch +from .rate_limiter import get_rate_limiter_registry, RateLimiterRegistry +from .circuit_breaker import get_circuit_breaker_registry, CircuitBreakerRegistry +from .result_classifier import ResultClassifier, ResultAggregator, ResultCategory + +__all__ = [ + "CompanySampler", + "SamplingStrategy", + "create_test_batch", + "get_rate_limiter_registry", + "RateLimiterRegistry", + "get_circuit_breaker_registry", + "CircuitBreakerRegistry", + "ResultClassifier", + "ResultAggregator", + "ResultCategory", +] diff --git a/tests/mcp_reliability/circuit_breaker.py b/tests/mcp_reliability/circuit_breaker.py new file mode 100644 index 0000000000000000000000000000000000000000..ce6143b208279af59a24fc4870daff5058957be6 --- /dev/null +++ b/tests/mcp_reliability/circuit_breaker.py @@ -0,0 +1,279 @@ +""" +Circuit Breaker - Prevents cascading failures during MCP stress testing. + +Implements the circuit breaker pattern: +- CLOSED: Normal operation, requests pass through +- OPEN: Failures exceeded threshold, requests fail fast +- HALF_OPEN: Testing if service recovered +""" + +import time +import threading +from typing import Dict, Optional, Callable, Any +from dataclasses import dataclass, field +from enum import Enum + + +class CircuitState(Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +@dataclass +class CircuitBreakerConfig: + """Configuration for circuit breaker behavior.""" + failure_threshold: int = 5 # Failures before opening + success_threshold: int = 3 # Successes in half-open before closing + half_open_timeout: float = 30.0 # Seconds before transitioning to half-open + reset_timeout: float = 60.0 # Full reset timeout + + +@dataclass +class CircuitBreaker: + """Circuit breaker for a single service/endpoint. + + Thread-safe implementation with state transitions: + CLOSED -> OPEN (on failure_threshold failures) + OPEN -> HALF_OPEN (after half_open_timeout) + HALF_OPEN -> CLOSED (on success_threshold successes) + HALF_OPEN -> OPEN (on any failure) + """ + name: str + config: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig) + state: CircuitState = field(default=CircuitState.CLOSED) + failure_count: int = 0 + success_count: int = 0 + last_failure_time: Optional[float] = None + last_state_change: float = field(default_factory=time.monotonic) + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def _transition(self, new_state: CircuitState): + """Transition to a new state.""" + if self.state != new_state: + old_state = self.state + self.state = new_state + self.last_state_change = time.monotonic() + # Reset counters on state change + if new_state == CircuitState.CLOSED: + self.failure_count = 0 + self.success_count = 0 + elif new_state == CircuitState.HALF_OPEN: + self.success_count = 0 + + def allow_request(self) -> bool: + """Check if a request should be allowed through. + + Returns True if request can proceed, False if circuit is open. + """ + with self._lock: + now = time.monotonic() + + if self.state == CircuitState.CLOSED: + return True + + elif self.state == CircuitState.OPEN: + # Check if we should transition to half-open + if self.last_failure_time: + elapsed = now - self.last_failure_time + if elapsed >= self.config.half_open_timeout: + self._transition(CircuitState.HALF_OPEN) + return True # Allow test request + return False + + elif self.state == CircuitState.HALF_OPEN: + # Allow limited requests in half-open state + return True + + return False + + def record_success(self): + """Record a successful request.""" + with self._lock: + if self.state == CircuitState.HALF_OPEN: + self.success_count += 1 + if self.success_count >= self.config.success_threshold: + self._transition(CircuitState.CLOSED) + elif self.state == CircuitState.CLOSED: + # Optionally reset failure count on success + self.failure_count = max(0, self.failure_count - 1) + + def record_failure(self, error: Optional[str] = None): + """Record a failed request.""" + with self._lock: + self.last_failure_time = time.monotonic() + + if self.state == CircuitState.CLOSED: + self.failure_count += 1 + if self.failure_count >= self.config.failure_threshold: + self._transition(CircuitState.OPEN) + + elif self.state == CircuitState.HALF_OPEN: + # Any failure in half-open reopens the circuit + self._transition(CircuitState.OPEN) + + def force_open(self): + """Force the circuit open (for testing/manual intervention).""" + with self._lock: + self._transition(CircuitState.OPEN) + self.last_failure_time = time.monotonic() + + def force_close(self): + """Force the circuit closed (for testing/manual intervention).""" + with self._lock: + self._transition(CircuitState.CLOSED) + + def status(self) -> Dict: + """Get current circuit breaker status.""" + with self._lock: + return { + "name": self.name, + "state": self.state.value, + "failure_count": self.failure_count, + "success_count": self.success_count, + "time_in_state": time.monotonic() - self.last_state_change, + "last_failure": self.last_failure_time + } + + +class CircuitBreakerRegistry: + """Registry of circuit breakers for all MCP servers. + + Provides centralized management and monitoring of circuit breakers. + """ + + def __init__(self, config: Optional[CircuitBreakerConfig] = None): + """Initialize registry with optional shared config.""" + self.config = config or CircuitBreakerConfig() + self.breakers: Dict[str, CircuitBreaker] = {} + self._lock = threading.Lock() + self._setup_defaults() + + def _setup_defaults(self): + """Create circuit breakers for known MCP servers.""" + servers = [ + "fundamentals-basket", + "valuation-basket", + "volatility-basket", + "macro-basket", + "news-basket", + "sentiment-basket" + ] + for server in servers: + self.breakers[server] = CircuitBreaker(name=server, config=self.config) + + def get(self, server: str) -> CircuitBreaker: + """Get or create circuit breaker for a server.""" + with self._lock: + if server not in self.breakers: + self.breakers[server] = CircuitBreaker(name=server, config=self.config) + return self.breakers[server] + + def allow_request(self, server: str) -> bool: + """Check if request to server should be allowed.""" + return self.get(server).allow_request() + + def record_success(self, server: str): + """Record successful request to server.""" + self.get(server).record_success() + + def record_failure(self, server: str, error: Optional[str] = None): + """Record failed request to server.""" + self.get(server).record_failure(error) + + def status(self) -> Dict: + """Get status of all circuit breakers.""" + return { + name: breaker.status() + for name, breaker in self.breakers.items() + } + + def all_closed(self) -> bool: + """Check if all circuit breakers are closed (healthy).""" + return all( + b.state == CircuitState.CLOSED + for b in self.breakers.values() + ) + + def open_breakers(self) -> list: + """Get list of servers with open circuit breakers.""" + return [ + name for name, b in self.breakers.items() + if b.state == CircuitState.OPEN + ] + + def reset_all(self): + """Reset all circuit breakers to closed state.""" + for breaker in self.breakers.values(): + breaker.force_close() + + +# Global registry instance +_registry: Optional[CircuitBreakerRegistry] = None + + +def get_circuit_breaker_registry() -> CircuitBreakerRegistry: + """Get the global circuit breaker registry.""" + global _registry + if _registry is None: + _registry = CircuitBreakerRegistry() + return _registry + + +async def with_circuit_breaker( + server: str, + func: Callable, + *args, + **kwargs +) -> Any: + """Execute function with circuit breaker protection. + + Args: + server: MCP server name + func: Async function to execute + *args, **kwargs: Arguments for func + + Returns: + Result from func + + Raises: + CircuitOpenError: If circuit is open + Original exception: If func fails + """ + registry = get_circuit_breaker_registry() + + if not registry.allow_request(server): + raise CircuitOpenError(f"Circuit breaker open for {server}") + + try: + result = await func(*args, **kwargs) + registry.record_success(server) + return result + except Exception as e: + registry.record_failure(server, str(e)) + raise + + +class CircuitOpenError(Exception): + """Raised when circuit breaker is open and request is rejected.""" + pass + + +if __name__ == "__main__": + # Demo usage + registry = get_circuit_breaker_registry() + print("Initial status:") + for name, status in registry.status().items(): + print(f" {name}: {status['state']}") + + # Simulate failures + print("\nSimulating failures for fundamentals-basket...") + for i in range(6): + registry.record_failure("fundamentals-basket", f"Error {i}") + print(f" Failure {i+1}: state = {registry.get('fundamentals-basket').state.value}") + + print("\nFinal status:") + for name, status in registry.status().items(): + print(f" {name}: {status['state']}") + + print(f"\nOpen breakers: {registry.open_breakers()}") diff --git a/tests/mcp_reliability/company_sampler.py b/tests/mcp_reliability/company_sampler.py new file mode 100644 index 0000000000000000000000000000000000000000..e29ba9aee47acfff43eecb001dc573f8d36c9ce0 --- /dev/null +++ b/tests/mcp_reliability/company_sampler.py @@ -0,0 +1,200 @@ +""" +Company Sampler - Random selection of tickers for MCP stress testing. + +Supports multiple sampling strategies: +- Uniform random +- Stratified by sector +- Market cap weighted (simulated) +- Edge case focused +""" + +import json +import random +from pathlib import Path +from typing import List, Dict, Optional +from enum import Enum +from dataclasses import dataclass + + +class SamplingStrategy(Enum): + UNIFORM = "uniform" + STRATIFIED = "stratified" + EDGE_CASE = "edge_case" + MIXED = "mixed" + + +@dataclass +class Company: + ticker: str + name: str + sector: str + note: Optional[str] = None + + +class CompanySampler: + """Samples companies for MCP stress testing with configurable strategies.""" + + def __init__(self, fixture_path: Optional[Path] = None): + """Initialize sampler with ticker fixture data. + + Args: + fixture_path: Path to test_tickers.json. If None, uses default location. + """ + if fixture_path is None: + fixture_path = Path(__file__).parent.parent / "fixtures" / "test_tickers.json" + + with open(fixture_path) as f: + data = json.load(f) + + self.companies = [ + Company(**c) for c in data.get("sp500_sample", []) + ] + self.edge_cases = [ + Company(**c) for c in data.get("edge_cases", []) + ] + self.sectors = data.get("sectors", []) + self._by_sector: Dict[str, List[Company]] = {} + self._build_sector_index() + + def _build_sector_index(self): + """Build index of companies by sector for stratified sampling.""" + for company in self.companies: + if company.sector not in self._by_sector: + self._by_sector[company.sector] = [] + self._by_sector[company.sector].append(company) + + def sample( + self, + n: int, + strategy: SamplingStrategy = SamplingStrategy.UNIFORM, + seed: Optional[int] = None + ) -> List[Company]: + """Sample n companies using the specified strategy. + + Args: + n: Number of companies to sample + strategy: Sampling strategy to use + seed: Random seed for reproducibility + + Returns: + List of sampled Company objects + """ + if seed is not None: + random.seed(seed) + + if strategy == SamplingStrategy.UNIFORM: + return self._sample_uniform(n) + elif strategy == SamplingStrategy.STRATIFIED: + return self._sample_stratified(n) + elif strategy == SamplingStrategy.EDGE_CASE: + return self._sample_edge_case(n) + elif strategy == SamplingStrategy.MIXED: + return self._sample_mixed(n) + else: + raise ValueError(f"Unknown sampling strategy: {strategy}") + + def _sample_uniform(self, n: int) -> List[Company]: + """Uniform random sampling from all companies.""" + pool = self.companies.copy() + n = min(n, len(pool)) + return random.sample(pool, n) + + def _sample_stratified(self, n: int) -> List[Company]: + """Stratified sampling - equal representation from each sector.""" + result = [] + sectors = list(self._by_sector.keys()) + per_sector = max(1, n // len(sectors)) + + for sector in sectors: + sector_companies = self._by_sector.get(sector, []) + sample_size = min(per_sector, len(sector_companies)) + result.extend(random.sample(sector_companies, sample_size)) + + # Fill remaining with random samples if needed + if len(result) < n: + remaining = [c for c in self.companies if c not in result] + extra = min(n - len(result), len(remaining)) + result.extend(random.sample(remaining, extra)) + + return result[:n] + + def _sample_edge_case(self, n: int) -> List[Company]: + """Sample primarily from edge cases, fill with normal if needed.""" + result = [] + + # Start with all edge cases + edge_sample_size = min(n, len(self.edge_cases)) + result.extend(random.sample(self.edge_cases, edge_sample_size)) + + # Fill remaining with normal companies + if len(result) < n: + remaining = n - len(result) + result.extend(random.sample(self.companies, remaining)) + + return result[:n] + + def _sample_mixed(self, n: int) -> List[Company]: + """Mixed strategy: 70% uniform, 20% stratified boost, 10% edge cases.""" + result = [] + + # 10% edge cases + edge_n = max(1, n // 10) + edge_sample = min(edge_n, len(self.edge_cases)) + result.extend(random.sample(self.edge_cases, edge_sample)) + + # 90% from main pool (with some stratification) + remaining = n - len(result) + main_sample = self._sample_uniform(remaining) + result.extend(main_sample) + + random.shuffle(result) + return result[:n] + + def get_all_tickers(self) -> List[str]: + """Get all available tickers.""" + return [c.ticker for c in self.companies + self.edge_cases] + + def get_sectors(self) -> List[str]: + """Get list of available sectors.""" + return self.sectors.copy() + + def get_by_sector(self, sector: str) -> List[Company]: + """Get all companies in a specific sector.""" + return self._by_sector.get(sector, []).copy() + + +def create_test_batch( + batch_size: int = 20, + strategy: str = "uniform", + seed: Optional[int] = None +) -> List[Dict]: + """Convenience function to create a test batch. + + Args: + batch_size: Number of companies in batch + strategy: "uniform", "stratified", "edge_case", or "mixed" + seed: Random seed for reproducibility + + Returns: + List of dicts with ticker and name + """ + sampler = CompanySampler() + strategy_enum = SamplingStrategy(strategy) + companies = sampler.sample(batch_size, strategy_enum, seed) + return [{"ticker": c.ticker, "name": c.name, "sector": c.sector} for c in companies] + + +if __name__ == "__main__": + # Demo usage + sampler = CompanySampler() + print("=== Uniform Sample (10) ===") + for c in sampler.sample(10, SamplingStrategy.UNIFORM, seed=42): + print(f" {c.ticker}: {c.name} ({c.sector})") + + print("\n=== Stratified Sample (10) ===") + for c in sampler.sample(10, SamplingStrategy.STRATIFIED, seed=42): + print(f" {c.ticker}: {c.name} ({c.sector})") + + print("\n=== Edge Case Sample (5) ===") + for c in sampler.sample(5, SamplingStrategy.EDGE_CASE, seed=42): + print(f" {c.ticker}: {c.name} ({c.sector})") diff --git a/tests/mcp_reliability/conftest.py b/tests/mcp_reliability/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..c5f42bc20ed9c64a35ff1d0ed8d2005304518e12 --- /dev/null +++ b/tests/mcp_reliability/conftest.py @@ -0,0 +1,44 @@ +""" +Pytest configuration and fixtures for MCP reliability tests. +""" + +import pytest +import asyncio +from pathlib import Path + + +def pytest_configure(config): + """Configure custom markers.""" + config.addinivalue_line("markers", "smoke: quick smoke tests for basic functionality") + config.addinivalue_line("markers", "standard: standard reliability tests") + config.addinivalue_line("markers", "stress: high-load stress tests") + config.addinivalue_line("markers", "soak: long-running soak tests") + + +@pytest.fixture(scope="session") +def event_loop(): + """Create event loop for async tests.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session") +def fixtures_dir(): + """Path to test fixtures directory.""" + return Path(__file__).parent.parent / "fixtures" + + +@pytest.fixture(autouse=True) +def reset_singletons(): + """Reset global singletons before each test.""" + from tests.mcp_reliability.rate_limiter import get_rate_limiter_registry + from tests.mcp_reliability.circuit_breaker import get_circuit_breaker_registry + + # Reset circuit breakers + cb_registry = get_circuit_breaker_registry() + cb_registry.reset_all() + + yield + + # Cleanup after test if needed diff --git a/tests/mcp_reliability/rate_limiter.py b/tests/mcp_reliability/rate_limiter.py new file mode 100644 index 0000000000000000000000000000000000000000..35cd267a9ec4ca1766600cc852a8f5f329a25abb --- /dev/null +++ b/tests/mcp_reliability/rate_limiter.py @@ -0,0 +1,263 @@ +""" +Rate Limiter - Token bucket and sliding window implementations for API rate limiting. + +Prevents self-DoS during stress testing by enforcing per-API rate limits. +""" + +import time +import asyncio +from typing import Dict, Optional +from dataclasses import dataclass, field +from collections import deque +import threading + + +@dataclass +class TokenBucket: + """Token bucket rate limiter. + + Allows bursting up to capacity, refills at steady rate. + Thread-safe implementation. + """ + rate: float # Tokens per second + capacity: int # Maximum tokens (burst capacity) + tokens: float = field(init=False) + last_update: float = field(init=False) + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def __post_init__(self): + self.tokens = float(self.capacity) + self.last_update = time.monotonic() + + def _refill(self): + """Refill tokens based on elapsed time.""" + now = time.monotonic() + elapsed = now - self.last_update + self.tokens = min(self.capacity, self.tokens + elapsed * self.rate) + self.last_update = now + + def acquire(self, tokens: int = 1) -> bool: + """Try to acquire tokens. Returns True if successful.""" + with self._lock: + self._refill() + if self.tokens >= tokens: + self.tokens -= tokens + return True + return False + + async def acquire_async(self, tokens: int = 1, timeout: float = 30.0) -> bool: + """Async version - waits until tokens available or timeout.""" + start = time.monotonic() + while time.monotonic() - start < timeout: + if self.acquire(tokens): + return True + # Wait for estimated refill time + wait_time = min(0.1, (tokens - self.tokens) / self.rate) + await asyncio.sleep(max(0.01, wait_time)) + return False + + def tokens_available(self) -> float: + """Get current available tokens (without modifying state).""" + with self._lock: + self._refill() + return self.tokens + + +@dataclass +class SlidingWindowLimiter: + """Sliding window rate limiter. + + Tracks requests in a time window, more accurate than token bucket + for strict rate limits. + """ + max_requests: int # Maximum requests in window + window_seconds: float # Window duration + _requests: deque = field(default_factory=deque, repr=False) + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def _cleanup(self): + """Remove expired timestamps from window.""" + cutoff = time.monotonic() - self.window_seconds + while self._requests and self._requests[0] < cutoff: + self._requests.popleft() + + def acquire(self) -> bool: + """Try to acquire a request slot. Returns True if allowed.""" + with self._lock: + self._cleanup() + if len(self._requests) < self.max_requests: + self._requests.append(time.monotonic()) + return True + return False + + async def acquire_async(self, timeout: float = 30.0) -> bool: + """Async version - waits until slot available or timeout.""" + start = time.monotonic() + while time.monotonic() - start < timeout: + if self.acquire(): + return True + # Estimate wait time until oldest request expires + with self._lock: + if self._requests: + oldest = self._requests[0] + wait_time = max(0.01, oldest + self.window_seconds - time.monotonic()) + else: + wait_time = 0.01 + await asyncio.sleep(min(0.5, wait_time)) + return False + + def requests_in_window(self) -> int: + """Get current request count in window.""" + with self._lock: + self._cleanup() + return len(self._requests) + + +class DailyQuotaTracker: + """Tracks daily API quota usage. + + For APIs with daily limits (NYT: 500/day, NewsAPI: 100/day). + """ + + def __init__(self, daily_limit: int, name: str = "api"): + self.daily_limit = daily_limit + self.name = name + self.used = 0 + self.reset_date = self._current_date() + self._lock = threading.Lock() + + def _current_date(self) -> str: + return time.strftime("%Y-%m-%d") + + def _check_reset(self): + """Reset counter if day changed.""" + today = self._current_date() + if today != self.reset_date: + self.used = 0 + self.reset_date = today + + def acquire(self, count: int = 1) -> bool: + """Try to use quota. Returns True if within limit.""" + with self._lock: + self._check_reset() + if self.used + count <= self.daily_limit: + self.used += count + return True + return False + + def remaining(self) -> int: + """Get remaining quota for today.""" + with self._lock: + self._check_reset() + return max(0, self.daily_limit - self.used) + + +class RateLimiterRegistry: + """Registry of rate limiters for different APIs. + + Centralizes rate limit configuration and provides unified access. + """ + + def __init__(self): + self.limiters: Dict[str, TokenBucket | SlidingWindowLimiter] = {} + self.quotas: Dict[str, DailyQuotaTracker] = {} + self._setup_defaults() + + def _setup_defaults(self): + """Configure default rate limiters based on known API limits.""" + # Token bucket limiters (burst-friendly) + self.limiters["sec_edgar"] = TokenBucket(rate=10, capacity=10) + self.limiters["yahoo_finance"] = TokenBucket(rate=5, capacity=20) + self.limiters["finnhub"] = TokenBucket(rate=1, capacity=5) + + # Sliding window limiters (strict limits) + self.limiters["fred"] = SlidingWindowLimiter(max_requests=120, window_seconds=60) + self.limiters["reddit"] = SlidingWindowLimiter(max_requests=100, window_seconds=60) + + # Daily quota trackers + self.quotas["nyt"] = DailyQuotaTracker(daily_limit=500, name="NYT") + self.quotas["newsapi"] = DailyQuotaTracker(daily_limit=100, name="NewsAPI") + self.quotas["tavily"] = DailyQuotaTracker(daily_limit=33, name="Tavily") # ~1000/month + + def get_limiter(self, api: str) -> Optional[TokenBucket | SlidingWindowLimiter]: + """Get rate limiter for an API.""" + return self.limiters.get(api.lower()) + + def get_quota(self, api: str) -> Optional[DailyQuotaTracker]: + """Get quota tracker for an API.""" + return self.quotas.get(api.lower()) + + async def acquire(self, api: str, timeout: float = 30.0) -> bool: + """Acquire rate limit and quota for an API. + + Returns True if both rate limit and quota allow the request. + """ + api_lower = api.lower() + + # Check daily quota first (faster to reject) + quota = self.quotas.get(api_lower) + if quota and not quota.acquire(): + return False + + # Then check rate limiter + limiter = self.limiters.get(api_lower) + if limiter: + return await limiter.acquire_async(timeout=timeout) + + # No limiter configured - allow by default + return True + + def status(self) -> Dict: + """Get status of all rate limiters and quotas.""" + status = {"limiters": {}, "quotas": {}} + + for name, limiter in self.limiters.items(): + if isinstance(limiter, TokenBucket): + status["limiters"][name] = { + "type": "token_bucket", + "available": limiter.tokens_available(), + "capacity": limiter.capacity + } + elif isinstance(limiter, SlidingWindowLimiter): + status["limiters"][name] = { + "type": "sliding_window", + "used": limiter.requests_in_window(), + "max": limiter.max_requests + } + + for name, quota in self.quotas.items(): + status["quotas"][name] = { + "remaining": quota.remaining(), + "daily_limit": quota.daily_limit + } + + return status + + +# Global registry instance +_registry: Optional[RateLimiterRegistry] = None + + +def get_rate_limiter_registry() -> RateLimiterRegistry: + """Get the global rate limiter registry.""" + global _registry + if _registry is None: + _registry = RateLimiterRegistry() + return _registry + + +if __name__ == "__main__": + import asyncio + + async def demo(): + registry = get_rate_limiter_registry() + print("Initial status:", registry.status()) + + # Simulate some API calls + for api in ["sec_edgar", "fred", "nyt"]: + result = await registry.acquire(api) + print(f"{api}: {'allowed' if result else 'blocked'}") + + print("\nAfter requests:", registry.status()) + + asyncio.run(demo()) diff --git a/tests/mcp_reliability/result_classifier.py b/tests/mcp_reliability/result_classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..ea4b47f0e7c135493d7da98ebdbbc3a4132a9bdb --- /dev/null +++ b/tests/mcp_reliability/result_classifier.py @@ -0,0 +1,401 @@ +""" +Result Classifier - Classifies MCP server responses for reliability analysis. + +Classification categories: +- SUCCESS: Valid response with expected data +- PARTIAL: Response OK but missing some fields +- FALLBACK: Primary source failed, secondary succeeded +- TRANSIENT: Temporary error (rate limit, timeout) +- PERSISTENT: Repeated failures +- HARD_FAILURE: Unrecoverable error +""" + +import json +from typing import Dict, Any, Optional, List +from dataclasses import dataclass, field +from enum import Enum +from datetime import datetime + + +class ResultCategory(Enum): + SUCCESS = "success" + PARTIAL = "partial" + FALLBACK = "fallback" + TRANSIENT = "transient" + PERSISTENT = "persistent" + HARD_FAILURE = "hard_failure" + RATE_LIMITED = "rate_limited" + TIMEOUT = "timeout" + HF_DEPENDENCY = "hf_dependency" + COLD_START = "cold_start" + UNKNOWN = "unknown" + + +@dataclass +class ClassificationResult: + """Result of classifying an MCP response.""" + category: ResultCategory + server: str + ticker: str + latency_ms: float + data_completeness: float # 0.0 to 1.0 + fallback_used: bool = False + primary_source: Optional[str] = None + fallback_source: Optional[str] = None + error_message: Optional[str] = None + raw_response: Optional[Dict] = None + timestamp: datetime = field(default_factory=datetime.utcnow) + + def to_dict(self) -> Dict: + """Convert to dictionary for logging/serialization.""" + return { + "timestamp": self.timestamp.isoformat() + "Z", + "category": self.category.value, + "server": self.server, + "ticker": self.ticker, + "latency_ms": self.latency_ms, + "data_completeness": self.data_completeness, + "fallback_used": self.fallback_used, + "primary_source": self.primary_source, + "fallback_source": self.fallback_source, + "error_message": self.error_message + } + + def to_json(self) -> str: + """Convert to JSON string for logging.""" + return json.dumps(self.to_dict()) + + +class ResultClassifier: + """Classifies MCP server responses based on content and error patterns.""" + + # Expected fields per server for completeness calculation + EXPECTED_FIELDS = { + "fundamentals-basket": { + "required": ["ticker", "financials"], + "optional": ["debt", "cash_flow", "swot_category"] + }, + "valuation-basket": { + "required": ["metrics"], + "optional": ["overall_signal", "swot_category"] + }, + "volatility-basket": { + "required": ["metrics"], + "optional": ["swot_category", "interpretation"] + }, + "macro-basket": { + "required": ["metrics"], + "optional": ["swot_category", "interpretation"] + }, + "news-basket": { + "required": ["results"], + "optional": ["query", "source"] + }, + "sentiment-basket": { + "required": ["composite_score"], + "optional": ["finnhub_score", "reddit_score", "overall_swot_category"] + } + } + + # Fallback detection patterns + FALLBACK_INDICATORS = { + "fundamentals-basket": { + "field": "source", + "fallback_values": ["yahoo_fallback", "yfinance"] + }, + "volatility-basket": { + "field": "vix_source", + "fallback_values": ["yahoo", "yfinance"] + }, + "news-basket": { + "primary_field": "tavily_results", + "fallback_field": "nyt_results" + }, + "sentiment-basket": { + "field": "finnhub_score", + "fallback_indicator": None # null means fallback to reddit + } + } + + def __init__(self): + self.attempt_counts: Dict[str, int] = {} # Track consecutive failures + + def classify( + self, + server: str, + ticker: str, + response: Optional[Dict], + error: Optional[Exception], + latency_ms: float + ) -> ClassificationResult: + """Classify an MCP server response. + + Args: + server: MCP server name + ticker: Stock ticker tested + response: Response dict (if successful) + error: Exception (if failed) + latency_ms: Request latency + + Returns: + ClassificationResult with category and metadata + """ + key = f"{server}:{ticker}" + + # Handle errors first + if error: + return self._classify_error(server, ticker, error, latency_ms) + + # Handle missing response + if response is None: + return ClassificationResult( + category=ResultCategory.HARD_FAILURE, + server=server, + ticker=ticker, + latency_ms=latency_ms, + data_completeness=0.0, + error_message="No response received" + ) + + # Check for error in response + if isinstance(response, dict) and "error" in response: + return self._classify_response_error(server, ticker, response, latency_ms) + + # Successful response - check completeness and fallback + completeness = self._calculate_completeness(server, response) + fallback_info = self._detect_fallback(server, response) + + # Reset failure counter on success + self.attempt_counts[key] = 0 + + if fallback_info["used"]: + return ClassificationResult( + category=ResultCategory.FALLBACK, + server=server, + ticker=ticker, + latency_ms=latency_ms, + data_completeness=completeness, + fallback_used=True, + primary_source=fallback_info.get("primary"), + fallback_source=fallback_info.get("fallback"), + raw_response=response + ) + elif completeness < 0.5: + return ClassificationResult( + category=ResultCategory.PARTIAL, + server=server, + ticker=ticker, + latency_ms=latency_ms, + data_completeness=completeness, + raw_response=response + ) + else: + return ClassificationResult( + category=ResultCategory.SUCCESS, + server=server, + ticker=ticker, + latency_ms=latency_ms, + data_completeness=completeness, + raw_response=response + ) + + def _classify_error( + self, + server: str, + ticker: str, + error: Exception, + latency_ms: float + ) -> ClassificationResult: + """Classify an error response.""" + key = f"{server}:{ticker}" + error_str = str(error).lower() + + # Increment attempt counter + self.attempt_counts[key] = self.attempt_counts.get(key, 0) + 1 + attempts = self.attempt_counts[key] + + # Classify error type + if "429" in error_str or "rate limit" in error_str: + category = ResultCategory.RATE_LIMITED + elif "timeout" in error_str or "timed out" in error_str: + category = ResultCategory.TIMEOUT + elif "huggingface" in error_str or "hf.space" in error_str: + category = ResultCategory.HF_DEPENDENCY + elif "cold start" in error_str: + category = ResultCategory.COLD_START + elif "503" in error_str or "502" in error_str or "500" in error_str: + category = ResultCategory.TRANSIENT if attempts < 3 else ResultCategory.PERSISTENT + elif "400" in error_str or "401" in error_str or "403" in error_str or "404" in error_str: + category = ResultCategory.HARD_FAILURE + else: + category = ResultCategory.TRANSIENT if attempts < 3 else ResultCategory.PERSISTENT + + return ClassificationResult( + category=category, + server=server, + ticker=ticker, + latency_ms=latency_ms, + data_completeness=0.0, + error_message=str(error) + ) + + def _classify_response_error( + self, + server: str, + ticker: str, + response: Dict, + latency_ms: float + ) -> ClassificationResult: + """Classify an error embedded in a response.""" + error_msg = response.get("error", "Unknown error") + + return ClassificationResult( + category=ResultCategory.HARD_FAILURE, + server=server, + ticker=ticker, + latency_ms=latency_ms, + data_completeness=0.0, + error_message=error_msg, + raw_response=response + ) + + def _calculate_completeness(self, server: str, response: Dict) -> float: + """Calculate data completeness for a response.""" + schema = self.EXPECTED_FIELDS.get(server, {"required": [], "optional": []}) + + required = schema["required"] + optional = schema["optional"] + + if not required and not optional: + return 1.0 # Unknown server, assume complete + + required_present = sum(1 for f in required if f in response and response[f]) + optional_present = sum(1 for f in optional if f in response and response[f]) + + total_required = len(required) + total_optional = len(optional) + + if total_required == 0: + return 1.0 if total_optional == 0 else optional_present / total_optional + + # Weight: required fields = 70%, optional = 30% + required_score = required_present / total_required if total_required else 1.0 + optional_score = optional_present / total_optional if total_optional else 1.0 + + return 0.7 * required_score + 0.3 * optional_score + + def _detect_fallback(self, server: str, response: Dict) -> Dict: + """Detect if fallback was used in response.""" + indicators = self.FALLBACK_INDICATORS.get(server) + if not indicators: + return {"used": False} + + # Simple field-based detection + if "field" in indicators: + field = indicators["field"] + value = response.get(field) + + if "fallback_values" in indicators: + if value in indicators["fallback_values"]: + return { + "used": True, + "primary": f"primary_{server}", + "fallback": value + } + + if "fallback_indicator" in indicators: + if value is indicators["fallback_indicator"]: + return { + "used": True, + "primary": field, + "fallback": "alternative" + } + + # News-basket: check if primary is empty but fallback has data + if "primary_field" in indicators and "fallback_field" in indicators: + primary = response.get(indicators["primary_field"], []) + fallback = response.get(indicators["fallback_field"], []) + if not primary and fallback: + return { + "used": True, + "primary": indicators["primary_field"], + "fallback": indicators["fallback_field"] + } + + return {"used": False} + + def reset_counters(self): + """Reset all attempt counters.""" + self.attempt_counts.clear() + + +class ResultAggregator: + """Aggregates classification results for analysis.""" + + def __init__(self): + self.results: List[ClassificationResult] = [] + self.counts: Dict[ResultCategory, int] = {cat: 0 for cat in ResultCategory} + self.by_server: Dict[str, Dict[ResultCategory, int]] = {} + self.latencies: List[float] = [] + + def add(self, result: ClassificationResult): + """Add a classification result.""" + self.results.append(result) + self.counts[result.category] += 1 + self.latencies.append(result.latency_ms) + + if result.server not in self.by_server: + self.by_server[result.server] = {cat: 0 for cat in ResultCategory} + self.by_server[result.server][result.category] += 1 + + def summary(self) -> Dict: + """Generate summary statistics.""" + total = len(self.results) + if total == 0: + return {"total": 0, "success_rate": 0.0} + + success_count = self.counts[ResultCategory.SUCCESS] + self.counts[ResultCategory.PARTIAL] + fallback_count = self.counts[ResultCategory.FALLBACK] + + return { + "total": total, + "success_rate": (success_count + fallback_count) / total, + "fallback_rate": fallback_count / total, + "failure_rate": sum( + self.counts[c] for c in [ + ResultCategory.HARD_FAILURE, + ResultCategory.PERSISTENT + ] + ) / total, + "by_category": {cat.value: count for cat, count in self.counts.items()}, + "by_server": { + server: {cat.value: count for cat, count in cats.items()} + for server, cats in self.by_server.items() + }, + "latency_p50": sorted(self.latencies)[len(self.latencies)//2] if self.latencies else 0, + "latency_p95": sorted(self.latencies)[int(len(self.latencies)*0.95)] if self.latencies else 0, + "latency_p99": sorted(self.latencies)[int(len(self.latencies)*0.99)] if self.latencies else 0 + } + + +if __name__ == "__main__": + # Demo usage + classifier = ResultClassifier() + aggregator = ResultAggregator() + + # Simulate some results + test_cases = [ + ("fundamentals-basket", "AAPL", {"ticker": "AAPL", "financials": {"revenue": 1000}}, None, 250), + ("fundamentals-basket", "MSFT", {"ticker": "MSFT", "financials": {"revenue": 2000}, "source": "yahoo_fallback"}, None, 500), + ("valuation-basket", "GOOGL", {"metrics": {"pe_ratio": 25}}, None, 150), + ("news-basket", "TSLA", None, Exception("429 Rate limit exceeded"), 0), + ("sentiment-basket", "NVDA", {"error": "Finnhub API key invalid"}, None, 100), + ] + + for server, ticker, response, error, latency in test_cases: + result = classifier.classify(server, ticker, response, error, latency) + aggregator.add(result) + print(f"{ticker} via {server}: {result.category.value}") + + print("\nSummary:") + print(json.dumps(aggregator.summary(), indent=2)) diff --git a/tests/mcp_reliability/test_stress.py b/tests/mcp_reliability/test_stress.py new file mode 100644 index 0000000000000000000000000000000000000000..bcef76e812dfbddcc12ea6fdd5acff1e5a837c55 --- /dev/null +++ b/tests/mcp_reliability/test_stress.py @@ -0,0 +1,489 @@ +""" +MCP Server Stress Test Suite + +High-frequency, randomized testing of MCP servers with: +- Company sampling strategies +- Rate limiting protection +- Circuit breaker patterns +- Result classification and aggregation +""" + +import asyncio +import random +import time +import json +import sys +from pathlib import Path +from typing import List, Dict, Optional, Any +from dataclasses import dataclass +from datetime import datetime + +# pytest is optional - only needed when running as test suite +try: + import pytest + PYTEST_AVAILABLE = True +except ImportError: + PYTEST_AVAILABLE = False + # Create dummy decorator for when pytest isn't available + class pytest: + @staticmethod + def fixture(*args, **kwargs): + def decorator(func): + return func + return decorator + class mark: + @staticmethod + def asyncio(func): + return func + @staticmethod + def smoke(func): + return func + @staticmethod + def standard(func): + return func + @staticmethod + def stress(func): + return func + +# Add parent paths for imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from tests.mcp_reliability.company_sampler import ( + CompanySampler, SamplingStrategy, Company, create_test_batch +) +from tests.mcp_reliability.rate_limiter import ( + get_rate_limiter_registry, RateLimiterRegistry +) +from tests.mcp_reliability.circuit_breaker import ( + get_circuit_breaker_registry, CircuitBreakerRegistry, CircuitOpenError +) +from tests.mcp_reliability.result_classifier import ( + ResultClassifier, ResultAggregator, ResultCategory, ClassificationResult +) + + +@dataclass +class TestConfig: + """Configuration for stress test runs.""" + batch_size: int = 20 + sampling_strategy: str = "uniform" + max_concurrent: int = 5 + request_interval_ms: int = 200 + timeout_seconds: float = 60.0 + retry_attempts: int = 3 + seed: Optional[int] = None + servers: List[str] = None + + def __post_init__(self): + if self.servers is None: + self.servers = [ + "fundamentals-basket", + "valuation-basket", + "volatility-basket", + "macro-basket", + "news-basket", + "sentiment-basket" + ] + + +class MCPTestRunner: + """Orchestrates stress testing of MCP servers.""" + + def __init__(self, config: TestConfig): + self.config = config + self.sampler = CompanySampler() + self.rate_limiters = get_rate_limiter_registry() + self.circuit_breakers = get_circuit_breaker_registry() + self.classifier = ResultClassifier() + self.aggregator = ResultAggregator() + self.results: List[ClassificationResult] = [] + + async def _call_mcp_server( + self, + server: str, + ticker: str, + company_name: str + ) -> Dict: + """Call an MCP server and return the response. + + Uses mock responses by default. Set USE_REAL_MCP=1 to use actual MCP servers. + """ + import os + + # Use mock by default for framework testing + if not os.getenv("USE_REAL_MCP"): + return await self._mock_mcp_response(server, ticker) + + # Import the actual MCP client when USE_REAL_MCP is set + try: + from mcp_client import call_mcp_server + + # Map server to tool name and arguments + server_tools = { + "fundamentals-basket": ("get_sec_fundamentals", {"ticker": ticker}), + "valuation-basket": ("get_valuation_basket", {"ticker": ticker}), + "volatility-basket": ("get_volatility_basket", {"ticker": ticker}), + "macro-basket": ("get_macro_basket", {}), + "news-basket": ("get_all_sources_news", {"ticker": ticker, "company_name": company_name}), + "sentiment-basket": ("get_sentiment_basket", {"ticker": ticker, "company_name": company_name}), + } + + tool_config = server_tools.get(server) + if tool_config: + tool_name, arguments = tool_config + return await call_mcp_server(server, tool_name, arguments, timeout=self.config.timeout_seconds) + else: + return {"error": f"Unknown server: {server}"} + + except ImportError as e: + # Fallback to mock response if import fails + return await self._mock_mcp_response(server, ticker) + + async def _mock_mcp_response(self, server: str, ticker: str) -> Dict: + """Generate mock response for testing the framework.""" + await asyncio.sleep(random.uniform(0.05, 0.2)) # Simulate latency + + # Simulate random failures (3% chance) - low for smoke tests + if random.random() < 0.03: + raise Exception("Simulated API error: 503 Service Unavailable") + + # Simulate rate limits (2% chance) + if random.random() < 0.02: + raise Exception("429 Rate limit exceeded") + + # Mock responses by server + responses = { + "fundamentals-basket": { + "ticker": ticker, + "financials": {"revenue": random.randint(1000, 100000) * 1000000}, + "debt": {"debt_to_equity": random.uniform(0.5, 2.0)}, + "swot_category": random.choice(["STRENGTH", "WEAKNESS", "NEUTRAL"]) + }, + "valuation-basket": { + "metrics": { + "pe_ratio": {"trailing": random.uniform(10, 50)}, + "pb_ratio": random.uniform(1, 10) + }, + "overall_signal": random.choice(["BUY", "HOLD", "SELL"]) + }, + "volatility-basket": { + "metrics": { + "beta": {"value": random.uniform(0.5, 2.0)}, + "vix": {"value": random.uniform(15, 35)} + } + }, + "macro-basket": { + "metrics": { + "gdp_growth": {"value": random.uniform(1, 4)}, + "interest_rate": {"value": random.uniform(4, 6)} + } + }, + "news-basket": { + "results": [{"title": f"News about {ticker}", "url": "https://example.com"}] + }, + "sentiment-basket": { + "composite_score": random.uniform(30, 70), + "finnhub_score": random.uniform(20, 80), + "reddit_score": random.uniform(20, 80) + } + } + + return responses.get(server, {"ticker": ticker}) + + async def _test_single( + self, + server: str, + ticker: str, + company_name: str + ) -> ClassificationResult: + """Test a single server/ticker combination.""" + # Check circuit breaker + if not self.circuit_breakers.allow_request(server): + return ClassificationResult( + category=ResultCategory.HARD_FAILURE, + server=server, + ticker=ticker, + latency_ms=0, + data_completeness=0.0, + error_message="Circuit breaker open" + ) + + # Map server to API for rate limiting + api_map = { + "fundamentals-basket": "sec_edgar", + "valuation-basket": "yahoo_finance", + "volatility-basket": "fred", + "macro-basket": "fred", + "news-basket": "tavily", + "sentiment-basket": "finnhub" + } + api = api_map.get(server, server) + + # Wait for rate limit + if not await self.rate_limiters.acquire(api, timeout=10.0): + return ClassificationResult( + category=ResultCategory.RATE_LIMITED, + server=server, + ticker=ticker, + latency_ms=0, + data_completeness=0.0, + error_message="Rate limit wait timeout" + ) + + # Make the request + start_time = time.perf_counter() + error = None + response = None + + try: + response = await asyncio.wait_for( + self._call_mcp_server(server, ticker, company_name), + timeout=self.config.timeout_seconds + ) + except asyncio.TimeoutError: + error = Exception(f"Timeout after {self.config.timeout_seconds}s") + except Exception as e: + error = e + + latency_ms = (time.perf_counter() - start_time) * 1000 + + # Classify result + result = self.classifier.classify(server, ticker, response, error, latency_ms) + + # Update circuit breaker + if result.category in [ResultCategory.SUCCESS, ResultCategory.PARTIAL, ResultCategory.FALLBACK]: + self.circuit_breakers.record_success(server) + else: + self.circuit_breakers.record_failure(server, result.error_message) + + return result + + async def _test_batch( + self, + companies: List[Company], + servers: List[str] + ) -> List[ClassificationResult]: + """Test a batch of companies against servers.""" + tasks = [] + + for company in companies: + for server in servers: + tasks.append(self._test_single(server, company.ticker, company.name)) + + # Add jitter between task creation + await asyncio.sleep(self.config.request_interval_ms / 1000 * random.uniform(0.5, 1.5)) + + # Execute with concurrency limit + semaphore = asyncio.Semaphore(self.config.max_concurrent) + + async def limited_task(task): + async with semaphore: + return await task + + results = await asyncio.gather(*[limited_task(t) for t in tasks], return_exceptions=True) + + # Filter out exceptions and convert to ClassificationResult + valid_results = [] + for r in results: + if isinstance(r, ClassificationResult): + valid_results.append(r) + elif isinstance(r, Exception): + valid_results.append(ClassificationResult( + category=ResultCategory.UNKNOWN, + server="unknown", + ticker="unknown", + latency_ms=0, + data_completeness=0.0, + error_message=str(r) + )) + + return valid_results + + async def run(self) -> Dict: + """Run the stress test and return results.""" + start_time = datetime.utcnow() + + # Sample companies + strategy = SamplingStrategy(self.config.sampling_strategy) + companies = self.sampler.sample( + self.config.batch_size, + strategy, + self.config.seed + ) + + print(f"Testing {len(companies)} companies against {len(self.config.servers)} servers") + print(f"Strategy: {self.config.sampling_strategy}, Seed: {self.config.seed}") + + # Run tests + results = await self._test_batch(companies, self.config.servers) + + # Aggregate results + for result in results: + self.aggregator.add(result) + self.results.append(result) + + # Generate summary + summary = self.aggregator.summary() + summary["test_config"] = { + "batch_size": self.config.batch_size, + "sampling_strategy": self.config.sampling_strategy, + "servers": self.config.servers, + "seed": self.config.seed + } + summary["start_time"] = start_time.isoformat() + "Z" + summary["end_time"] = datetime.utcnow().isoformat() + "Z" + summary["circuit_breaker_status"] = self.circuit_breakers.status() + summary["rate_limiter_status"] = self.rate_limiters.status() + + return summary + + def export_results(self, path: Path): + """Export detailed results to NDJSON file.""" + with open(path, "w") as f: + for result in self.results: + f.write(result.to_json() + "\n") + + +# --- Pytest Test Cases --- + +@pytest.fixture +def test_config(): + """Default test configuration for smoke tests.""" + return TestConfig( + batch_size=5, + sampling_strategy="uniform", + max_concurrent=3, + seed=42 + ) + + +@pytest.fixture +def runner(test_config): + """Create test runner instance.""" + return MCPTestRunner(test_config) + + +@pytest.mark.smoke +@pytest.mark.asyncio +async def test_smoke_basic_connectivity(runner): + """Smoke test: verify basic MCP connectivity.""" + summary = await runner.run() + + assert summary["total"] > 0, "No tests were executed" + print(f"\nSmoke test results: {summary['total']} tests, {summary['success_rate']:.1%} success rate") + + +@pytest.mark.smoke +@pytest.mark.asyncio +async def test_smoke_all_servers_reachable(runner): + """Smoke test: verify all MCP servers are reachable.""" + summary = await runner.run() + + for server in runner.config.servers: + server_results = summary["by_server"].get(server, {}) + total_for_server = sum(server_results.values()) + assert total_for_server > 0, f"No results for server {server}" + + +@pytest.mark.standard +@pytest.mark.asyncio +async def test_standard_reliability(): + """Standard reliability test with larger batch.""" + config = TestConfig( + batch_size=50, + sampling_strategy="mixed", + max_concurrent=5, + seed=int(time.time()) + ) + runner = MCPTestRunner(config) + summary = await runner.run() + + # Success + Partial + Fallback should be >= 90% + effective_success = ( + summary["by_category"]["success"] + + summary["by_category"]["partial"] + + summary["by_category"]["fallback"] + ) / summary["total"] + + assert effective_success >= 0.90, f"Effective success rate {effective_success:.1%} < 90%" + + +@pytest.mark.stress +@pytest.mark.asyncio +async def test_stress_high_concurrency(): + """Stress test with high concurrency.""" + config = TestConfig( + batch_size=100, + sampling_strategy="uniform", + max_concurrent=10, + request_interval_ms=50, + seed=int(time.time()) + ) + runner = MCPTestRunner(config) + summary = await runner.run() + + # Just verify it completes without crashing + assert summary["total"] > 0 + print(f"\nStress test: {summary['total']} tests, P99 latency: {summary['latency_p99']:.0f}ms") + + +@pytest.mark.asyncio +async def test_circuit_breaker_triggers(): + """Test that circuit breaker opens on repeated failures.""" + registry = get_circuit_breaker_registry() + registry.reset_all() + + # Simulate 6 failures (threshold is 5) + for i in range(6): + registry.record_failure("fundamentals-basket", f"Error {i}") + + assert "fundamentals-basket" in registry.open_breakers() + + +@pytest.mark.asyncio +async def test_rate_limiter_respects_limits(): + """Test that rate limiter prevents rapid requests.""" + registry = get_rate_limiter_registry() + + # Try to acquire 20 rapid requests on SEC EDGAR (limit: 10/sec) + acquired = 0 + for _ in range(20): + if await registry.acquire("sec_edgar", timeout=0.1): + acquired += 1 + + # Should have acquired roughly 10 (the capacity) + assert acquired <= 12, f"Rate limiter allowed too many requests: {acquired}" + + +if __name__ == "__main__": + # Run as standalone script + import argparse + + parser = argparse.ArgumentParser(description="MCP Server Stress Test") + parser.add_argument("--batch-size", type=int, default=20, help="Number of companies to test") + parser.add_argument("--strategy", default="uniform", choices=["uniform", "stratified", "edge_case", "mixed"]) + parser.add_argument("--max-concurrent", type=int, default=5, help="Max concurrent requests") + parser.add_argument("--seed", type=int, help="Random seed for reproducibility") + parser.add_argument("--output", type=Path, help="Output path for detailed results") + args = parser.parse_args() + + config = TestConfig( + batch_size=args.batch_size, + sampling_strategy=args.strategy, + max_concurrent=args.max_concurrent, + seed=args.seed + ) + + async def main(): + runner = MCPTestRunner(config) + summary = await runner.run() + print("\n" + "="*60) + print("TEST SUMMARY") + print("="*60) + print(json.dumps(summary, indent=2)) + + if args.output: + runner.export_results(args.output) + print(f"\nDetailed results exported to: {args.output}") + + asyncio.run(main()) diff --git a/tests/test_mcp_e2e.py b/tests/test_mcp_e2e.py new file mode 100644 index 0000000000000000000000000000000000000000..ad119bc8ebbda74618d10cd7aec624c69289dd1d --- /dev/null +++ b/tests/test_mcp_e2e.py @@ -0,0 +1,583 @@ +""" +E2E test for all 6 MCP servers. +Fetches data, validates responses, and generates a markdown report. + +Usage: python tests/test_mcp_e2e.py [TICKER] [COMPANY_NAME] +Default: KO "The Coca-Cola Company" +""" +import asyncio +import sys +import os +import importlib.util +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional, Callable + +# Project root +PROJECT_ROOT = Path(__file__).parent.parent + +# Load environment variables from project .env +from dotenv import load_dotenv +load_dotenv(PROJECT_ROOT / ".env") + + +def load_module_from_path(module_name: str, file_path: Path): + """Dynamically load a module from a specific file path.""" + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + + # Add the module's directory to sys.path temporarily for relative imports + module_dir = str(file_path.parent) + if module_dir not in sys.path: + sys.path.insert(0, module_dir) + + spec.loader.exec_module(module) + return module + +# Default test company +DEFAULT_TICKER = "KO" +DEFAULT_COMPANY = "The Coca-Cola Company" + + +class MCPTestResult: + """Result from testing a single MCP.""" + def __init__(self, name: str): + self.name = name + self.status = "FAIL" + self.data: Optional[Dict] = None + self.errors: List[str] = [] + self.warnings: List[str] = [] + self.item_count = 0 + self.duration_ms = 0 + + +async def test_fundamentals(ticker: str) -> MCPTestResult: + """Test fundamentals-basket MCP.""" + result = MCPTestResult("fundamentals") + start = datetime.now() + + try: + module_path = PROJECT_ROOT / "mcp-servers" / "fundamentals-basket" / "server_legacy.py" + module = load_module_from_path("fundamentals_server", module_path) + get_all_sources_fundamentals = module.get_all_sources_fundamentals + + data = await get_all_sources_fundamentals(ticker) + result.data = data + + if not isinstance(data, dict): + result.errors.append("Response is not a dict") + return result + + # Schema validation - fundamentals uses sec_edgar/yahoo_finance keys + sec_data = data.get("sec_edgar", {}) + yahoo_data = data.get("yahoo_finance", {}) + + if not sec_data and not yahoo_data: + result.errors.append("No SEC or Yahoo data") + else: + # Count only dict metrics with values (matches extraction logic) + sec_metrics = sec_data.get("data", {}) if isinstance(sec_data, dict) else {} + yahoo_metrics = yahoo_data.get("data", {}) if isinstance(yahoo_data, dict) else {} + sec_count = sum(1 for v in sec_metrics.values() if isinstance(v, dict)) + yahoo_count = sum(1 for v in yahoo_metrics.values() if isinstance(v, dict)) + result.item_count = sec_count + yahoo_count + if result.item_count == 0: + result.warnings.append("No data items returned") + + result.status = "PASS" if not result.errors else "FAIL" + + except Exception as e: + result.errors.append(str(e)) + + result.duration_ms = int((datetime.now() - start).total_seconds() * 1000) + return result + + +async def test_valuation(ticker: str) -> MCPTestResult: + """Test valuation-basket MCP.""" + result = MCPTestResult("valuation") + start = datetime.now() + + try: + module_path = PROJECT_ROOT / "mcp-servers" / "valuation-basket" / "server.py" + module = load_module_from_path("valuation_server", module_path) + get_all_sources_valuation = module.get_all_sources_valuation + + data = await get_all_sources_valuation(ticker) + result.data = data + + if not isinstance(data, dict): + result.errors.append("Response is not a dict") + return result + + # Schema validation + if "sources" not in data: + result.errors.append("Missing 'sources' key") + else: + sources = data.get("sources", {}) + result.item_count = sum( + len(v.get("data", {})) if isinstance(v, dict) else 0 + for v in sources.values() + ) + if result.item_count == 0: + result.warnings.append("No data items returned") + + result.status = "PASS" if not result.errors else "FAIL" + + except Exception as e: + result.errors.append(str(e)) + + result.duration_ms = int((datetime.now() - start).total_seconds() * 1000) + return result + + +async def test_volatility(ticker: str) -> MCPTestResult: + """Test volatility-basket MCP.""" + result = MCPTestResult("volatility") + start = datetime.now() + + try: + module_path = PROJECT_ROOT / "mcp-servers" / "volatility-basket" / "server.py" + module = load_module_from_path("volatility_server", module_path) + get_all_sources_volatility = module.get_all_sources_volatility + + data = await get_all_sources_volatility(ticker) + result.data = data + + if not isinstance(data, dict): + result.errors.append("Response is not a dict") + return result + + # Schema validation + if "metrics" not in data: + result.errors.append("Missing 'metrics' key") + else: + result.item_count = len(data.get("metrics", {})) + if result.item_count == 0: + result.warnings.append("No metrics returned") + + result.status = "PASS" if not result.errors else "FAIL" + + except Exception as e: + result.errors.append(str(e)) + + result.duration_ms = int((datetime.now() - start).total_seconds() * 1000) + return result + + +async def test_macro() -> MCPTestResult: + """Test macro-basket MCP.""" + result = MCPTestResult("macro") + start = datetime.now() + + try: + module_path = PROJECT_ROOT / "mcp-servers" / "macro-basket" / "server.py" + module = load_module_from_path("macro_server", module_path) + get_all_sources_macro = module.get_all_sources_macro + + data = await get_all_sources_macro() + result.data = data + + if not isinstance(data, dict): + result.errors.append("Response is not a dict") + return result + + # Schema validation + if "metrics" not in data: + result.errors.append("Missing 'metrics' key") + else: + result.item_count = len(data.get("metrics", {})) + if result.item_count == 0: + result.warnings.append("No metrics returned") + + result.status = "PASS" if not result.errors else "FAIL" + + except Exception as e: + result.errors.append(str(e)) + + result.duration_ms = int((datetime.now() - start).total_seconds() * 1000) + return result + + +async def test_news(ticker: str, company_name: str) -> MCPTestResult: + """Test news-basket MCP.""" + result = MCPTestResult("news") + start = datetime.now() + + try: + module_path = PROJECT_ROOT / "mcp-servers" / "news-basket" / "server.py" + module = load_module_from_path("news_server", module_path) + get_all_sources_news = module.get_all_sources_news + + data = await get_all_sources_news(ticker, company_name) + result.data = data + + if not isinstance(data, dict): + result.errors.append("Response is not a dict") + return result + + # Schema validation + if "items" not in data: + result.errors.append("Missing 'items' key") + else: + items = data.get("items", []) + result.item_count = len(items) + if result.item_count == 0: + result.warnings.append("No news items returned") + else: + # Validate item schema + for item in items[:3]: + if "title" not in item: + result.warnings.append("Item missing 'title'") + break + if "url" not in item: + result.warnings.append("Item missing 'url'") + break + + result.status = "PASS" if not result.errors else "FAIL" + + except Exception as e: + result.errors.append(str(e)) + + result.duration_ms = int((datetime.now() - start).total_seconds() * 1000) + return result + + +async def test_sentiment(ticker: str, company_name: str) -> MCPTestResult: + """Test sentiment-basket MCP.""" + result = MCPTestResult("sentiment") + start = datetime.now() + + try: + module_path = PROJECT_ROOT / "mcp-servers" / "sentiment-basket" / "server.py" + module = load_module_from_path("sentiment_server", module_path) + get_all_sources_sentiment = module.get_all_sources_sentiment + + data = await get_all_sources_sentiment(ticker, company_name) + result.data = data + + if not isinstance(data, dict): + result.errors.append("Response is not a dict") + return result + + # Schema validation + if "items" not in data: + result.errors.append("Missing 'items' key") + else: + items = data.get("items", []) + result.item_count = len(items) + if result.item_count == 0: + result.warnings.append("No sentiment items returned") + else: + # Validate item schema + for item in items[:3]: + if "title" not in item: + result.warnings.append("Item missing 'title'") + break + if "url" not in item: + result.warnings.append("Item missing 'url'") + break + + result.status = "PASS" if not result.errors else "FAIL" + + except Exception as e: + result.errors.append(str(e)) + + result.duration_ms = int((datetime.now() - start).total_seconds() * 1000) + return result + + +async def run_all_tests(ticker: str, company_name: str) -> List[MCPTestResult]: + """Run all MCP tests.""" + print(f"\nRunning E2E tests for {company_name} ({ticker})...") + print("-" * 50) + + # Run tests - some in parallel, some sequential to avoid import conflicts + results = [] + + # Quantitative tests + print("Testing fundamentals-basket...", end=" ", flush=True) + r = await test_fundamentals(ticker) + print(f"{r.status} ({r.duration_ms}ms)") + results.append(r) + + print("Testing valuation-basket...", end=" ", flush=True) + r = await test_valuation(ticker) + print(f"{r.status} ({r.duration_ms}ms)") + results.append(r) + + print("Testing volatility-basket...", end=" ", flush=True) + r = await test_volatility(ticker) + print(f"{r.status} ({r.duration_ms}ms)") + results.append(r) + + print("Testing macro-basket...", end=" ", flush=True) + r = await test_macro() + print(f"{r.status} ({r.duration_ms}ms)") + results.append(r) + + # Qualitative tests + print("Testing news-basket...", end=" ", flush=True) + r = await test_news(ticker, company_name) + print(f"{r.status} ({r.duration_ms}ms)") + results.append(r) + + print("Testing sentiment-basket...", end=" ", flush=True) + r = await test_sentiment(ticker, company_name) + print(f"{r.status} ({r.duration_ms}ms)") + results.append(r) + + return results + + +def format_value(val: Any) -> str: + """Format a value for display - raw output for now.""" + if val is None: + return "-" + # Just return string representation of value + return str(val) + + +def extract_quantitative_rows(results: List[MCPTestResult], ticker: str) -> List[Dict]: + """Extract quantitative data rows from results.""" + rows = [] + + # Fundamentals - uses sec_edgar/yahoo_finance structure with nested 'data' key + fund_result = next((r for r in results if r.name == "fundamentals"), None) + if fund_result and fund_result.data: + # SEC EDGAR data - metrics are inside .data + sec_wrapper = fund_result.data.get("sec_edgar", {}) + sec_data = sec_wrapper.get("data", {}) if isinstance(sec_wrapper, dict) else {} + for metric_name, metric_val in sec_data.items(): + if isinstance(metric_val, dict): + rows.append({ + "metric": metric_name, + "value": format_value(metric_val.get("value")), + "data_type": metric_val.get("data_type", "FY"), + "as_of": metric_val.get("end_date", "-"), + "filed": metric_val.get("filed", "-"), + "source": "SEC EDGAR", + "category": "Fundamentals", + }) + + # Yahoo Finance data - metrics are inside .data, as_of is at wrapper level + yahoo_wrapper = fund_result.data.get("yahoo_finance", {}) + yahoo_as_of = yahoo_wrapper.get("as_of", "-") if isinstance(yahoo_wrapper, dict) else "-" + yahoo_data = yahoo_wrapper.get("data", {}) if isinstance(yahoo_wrapper, dict) else {} + for metric_name, metric_val in yahoo_data.items(): + if isinstance(metric_val, dict): + rows.append({ + "metric": metric_name, + "value": format_value(metric_val.get("value")), + "data_type": metric_val.get("period", metric_val.get("data_type", "TTM")), + "as_of": metric_val.get("end_date", metric_val.get("as_of", yahoo_as_of)), + "filed": metric_val.get("filed", "-"), + "source": "Yahoo Finance", + "category": "Fundamentals", + }) + + # Valuation + val_result = next((r for r in results if r.name == "valuation"), None) + if val_result and val_result.data: + sources = val_result.data.get("sources", {}) + as_of = val_result.data.get("as_of", "-") + for source_name, source_data in sources.items(): + if isinstance(source_data, dict) and "data" in source_data: + for metric_name, metric_val in source_data["data"].items(): + # Handle both dict and scalar values + if isinstance(metric_val, dict): + value = format_value(metric_val.get("value")) + data_type = metric_val.get("data_type", "-") + metric_as_of = metric_val.get("as_of", as_of) + else: + value = format_value(metric_val) + data_type = "-" + metric_as_of = as_of + rows.append({ + "metric": metric_name, + "value": value, + "data_type": data_type, + "as_of": metric_as_of, + "source": source_name, + "category": "Valuation", + }) + + # Volatility + vol_result = next((r for r in results if r.name == "volatility"), None) + if vol_result and vol_result.data: + metrics = vol_result.data.get("metrics", {}) + for metric_name, metric_val in metrics.items(): + if isinstance(metric_val, dict): + rows.append({ + "metric": metric_name, + "value": format_value(metric_val.get("value")), + "data_type": metric_val.get("data_type", "-"), + "as_of": metric_val.get("as_of", "-"), + "filed": "-", + "source": metric_val.get("source", "-"), + "category": "Volatility", + }) + + # Macro + macro_result = next((r for r in results if r.name == "macro"), None) + if macro_result and macro_result.data: + metrics = macro_result.data.get("metrics", {}) + for metric_name, metric_val in metrics.items(): + if isinstance(metric_val, dict): + rows.append({ + "metric": metric_name, + "value": format_value(metric_val.get("value")), + "data_type": metric_val.get("data_type", "-"), + "as_of": metric_val.get("as_of", "-"), + "filed": "-", + "source": metric_val.get("source", "-"), + "category": "Macro", + }) + + return rows + + +def extract_date(item: Dict) -> str: + """Extract date (YYYY-MM-DD) from item, checking both date and datetime fields.""" + # Try 'date' first, then 'datetime' + val = item.get("date") or item.get("datetime") or "-" + if val == "-": + return val + # Extract just the date portion (first 10 chars: YYYY-MM-DD) + val_str = str(val) + if len(val_str) >= 10: + return val_str[:10] + return val_str + + +def extract_qualitative_rows(results: List[MCPTestResult]) -> List[Dict]: + """Extract qualitative data rows from results.""" + rows = [] + + # News + news_result = next((r for r in results if r.name == "news"), None) + if news_result and news_result.data: + items = news_result.data.get("items", []) + for item in items[:10]: # Limit to 10 + rows.append({ + "title": item.get("title", "-")[:80], + "date": extract_date(item), + "source": item.get("source", "-"), + "subreddit": "-", + "url": item.get("url", "-"), + "category": "News", + }) + + # Sentiment + sent_result = next((r for r in results if r.name == "sentiment"), None) + if sent_result and sent_result.data: + items = sent_result.data.get("items", []) + for item in items[:10]: # Limit to 10 + subreddit = item.get("subreddit") or "-" + rows.append({ + "title": item.get("title", "-")[:80], + "date": extract_date(item), + "source": item.get("source", "-"), + "subreddit": subreddit if subreddit != "None" else "-", + "url": item.get("url", "-"), + "category": "Sentiment", + }) + + return rows + + +def generate_report(results: List[MCPTestResult], ticker: str, company_name: str) -> str: + """Generate markdown report.""" + # Expected item counts per MCP (quantitative only - dict metrics with values) + expected_counts = { + "fundamentals": 9, # SEC EDGAR (5 universal) + Yahoo Finance (4 supplementary) + "valuation": 11, # Yahoo Finance only (11 universal, excludes ev_ebitda) + "volatility": 5, # VIX, VXN, beta, historical_vol, implied_vol + "macro": 4, # GDP, interest_rate, CPI, unemployment + } + + lines = [ + f"# MCP E2E Test Report: {company_name} ({ticker})", + "", + "## Summary", + "", + "| S/N | MCP | Status | Expected | Actual | Duration | Errors | Warnings |", + "|-----|-----|--------|----------|--------|----------|--------|----------|", + ] + + for i, r in enumerate(results, 1): + expected = expected_counts.get(r.name, "-") + errors = "; ".join(r.errors) if r.errors else "-" + warnings = "; ".join(r.warnings) if r.warnings else "-" + lines.append(f"| {i} | {r.name} | {r.status} | {expected} | {r.item_count} | {r.duration_ms}ms | {errors} | {warnings} |") + + # Quantitative Data + lines.extend([ + "", + "---", + "", + "## Quantitative Data", + "", + "| S/N | Metric | Value | Data Type | As Of | Source | Category |", + "|-----|--------|-------|-----------|-------|--------|----------|", + ]) + + quant_rows = extract_quantitative_rows(results, ticker) + for i, row in enumerate(quant_rows, 1): + lines.append(f"| {i} | {row['metric']} | {row['value']} | {row['data_type']} | {row['as_of']} | {row['source']} | {row['category']} |") + + if not quant_rows: + lines.append("| - | - | - | - | - | - | - |") + + # Qualitative Data + lines.extend([ + "", + "---", + "", + "## Qualitative Data", + "", + "| S/N | Title | Date | Source | Subreddit | URL | Category |", + "|-----|-------|------|--------|-----------|-----|----------|", + ]) + + qual_rows = extract_qualitative_rows(results) + for i, row in enumerate(qual_rows, 1): + url_link = f"[Link]({row['url']})" if row['url'] != "-" else "-" + lines.append(f"| {i} | {row['title']} | {row['date']} | {row['source']} | {row['subreddit']} | {url_link} | {row['category']} |") + + if not qual_rows: + lines.append("| - | - | - | - | - | - | - |") + + lines.append("") + return "\n".join(lines) + + +def main(): + """Main entry point.""" + # Parse args + ticker = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_TICKER + company_name = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_COMPANY + + # Run tests + results = asyncio.run(run_all_tests(ticker, company_name)) + + # Generate report + report = generate_report(results, ticker, company_name) + + # Write report + output_path = PROJECT_ROOT / "docs" / f"mcp_test_report_{ticker}.md" + output_path.write_text(report) + + print("-" * 50) + print(f"Report generated: {output_path}") + + # Summary + passed = sum(1 for r in results if r.status == "PASS") + total = len(results) + print(f"\nResult: {passed}/{total} MCPs passed") + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main())