diff --git a/docs/CONFIGURATION_GUIDE.md b/docs/CONFIGURATION_GUIDE.md index 2605a65d1..acd2a6ede 100644 --- a/docs/CONFIGURATION_GUIDE.md +++ b/docs/CONFIGURATION_GUIDE.md @@ -428,6 +428,9 @@ OPENAI_API_VERSION=2024-10-21 # DashScope (Alibaba Cloud Qwen3 models) DASHSCOPE_API_KEY=sk-xxxxxxxxxxxxx + +# Optional market sentiment data for News Agent +ADANOS_API_KEY=sk_live_xxxxxxxxxxxxx ``` ### Model Configuration diff --git a/python/valuecell/agents/news_agent/core.py b/python/valuecell/agents/news_agent/core.py index 8bd6f992c..a26ac4544 100644 --- a/python/valuecell/agents/news_agent/core.py +++ b/python/valuecell/agents/news_agent/core.py @@ -11,7 +11,12 @@ from valuecell.core.types import BaseAgent, StreamResponse from .prompts import NEWS_AGENT_INSTRUCTIONS -from .tools import get_breaking_news, get_financial_news, web_search +from .tools import ( + get_breaking_news, + get_financial_news, + get_market_sentiment, + web_search, +) class NewsAgent(BaseAgent): @@ -27,7 +32,9 @@ def __init__(self, **kwargs): # Load tools based on configuration available_tools = [] - available_tools.extend([web_search, get_breaking_news, get_financial_news]) + available_tools.extend( + [web_search, get_breaking_news, get_financial_news, get_market_sentiment] + ) # Use create_model_for_agent to load agent-specific configuration self.knowledge_news_agent = Agent( @@ -119,12 +126,17 @@ def get_capabilities(self) -> Dict[str, Any]: "name": "get_financial_news", "description": "Get financial and market news", }, + { + "name": "get_market_sentiment", + "description": "Get stock market sentiment from Adanos", + }, ], "supported_queries": [ "Latest news", "Breaking news", "Financial news", "Market updates", + "Stock sentiment", "Topic-specific news search", ], } diff --git a/python/valuecell/agents/news_agent/prompts.py b/python/valuecell/agents/news_agent/prompts.py index 1b5d76c1c..f24adca3e 100644 --- a/python/valuecell/agents/news_agent/prompts.py +++ b/python/valuecell/agents/news_agent/prompts.py @@ -6,6 +6,7 @@ ## Tool Usage - Use `get_breaking_news()` for urgent updates - Use `get_financial_news()` for market and business news +- Use `get_market_sentiment()` when users ask about stock sentiment, social discussion, FinTwit/X sentiment, news sentiment, or Polymarket signals - Use `web_search()` for comprehensive information gathering ## Critical Output Rules @@ -31,6 +32,7 @@ 1. **Market Overview**: Key movements and indicators 2. **Individual Stocks**: Company news and price changes 3. **Economic Factors**: Economic data or policy changes +4. **Sentiment Signals**: When requested, cite the selected sentiment source and report sentiment score, buzz score, trend, and notable directional signals ## Guidelines - Start immediately with news headlines diff --git a/python/valuecell/agents/news_agent/tools.py b/python/valuecell/agents/news_agent/tools.py index 038dcd84d..468058857 100644 --- a/python/valuecell/agents/news_agent/tools.py +++ b/python/valuecell/agents/news_agent/tools.py @@ -1,14 +1,19 @@ """News-related tools for the News Agent.""" +import asyncio import os from datetime import datetime from typing import Optional +import requests from agno.agent import Agent from loguru import logger from valuecell.adapters.models import create_model +ADANOS_API_BASE_URL = "https://api.adanos.org" +ADANOS_SOURCES = {"reddit", "x", "news", "polymarket"} + async def web_search(query: str) -> str: """Search web for the given query and return a summary of the top results. @@ -113,3 +118,114 @@ async def get_financial_news( except Exception as e: logger.error(f"Error fetching financial news: {e}") return f"Error fetching financial news: {str(e)}" + + +async def get_market_sentiment( + ticker: str, + source: str = "news", + days: int = 7, +) -> str: + """Get stock market sentiment from Adanos Market Sentiment API. + + Use this tool when users ask about stock sentiment, market mood, social + discussion, FinTwit/X sentiment, news sentiment, or Polymarket signals for + a US-listed equity. + + Args: + ticker: Stock ticker symbol, for example "AAPL" or "TSLA". + source: Sentiment source. One of: reddit, x, news, polymarket. + days: Lookback window in days. Defaults to 7. + + Returns: + Formatted sentiment summary or configuration/error guidance. + """ + + try: + payload = await asyncio.to_thread( + _fetch_adanos_stock_sentiment, + ticker=ticker, + source=source, + days=days, + ) + except ValueError as e: + return str(e) + except requests.RequestException as e: + logger.error(f"Error fetching Adanos market sentiment: {e}") + return f"Error fetching market sentiment: {str(e)}" + + return _format_adanos_stock_sentiment(payload, ticker=ticker, source=source) + + +def _fetch_adanos_stock_sentiment( + ticker: str, + source: str, + days: int, +) -> dict: + ticker = str(ticker or "").strip().upper() + if not ticker: + raise ValueError("ticker must not be empty") + + source = str(source or "").strip().lower() + if source not in ADANOS_SOURCES: + raise ValueError("source must be one of: reddit, x, news, polymarket") + + try: + days = int(days) + except (TypeError, ValueError) as e: + raise ValueError("days must be an integer") from e + if days < 1 or days > 365: + raise ValueError("days must be between 1 and 365") + + api_key = os.getenv("ADANOS_API_KEY", "").strip() + if not api_key: + raise ValueError("ADANOS_API_KEY is not configured") + + response = requests.get( + f"{ADANOS_API_BASE_URL}/{source}/stocks/v1/stock/{ticker}", + headers={"X-API-Key": api_key, "Accept": "application/json"}, + params={"days": days}, + timeout=10, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("Adanos response must be a JSON object") + return payload + + +def _format_adanos_stock_sentiment( + payload: dict, + ticker: str, + source: str, +) -> str: + ticker = str(payload.get("ticker") or ticker).upper() + company_name = payload.get("company_name") or payload.get("company") or ticker + sentiment_score = payload.get("sentiment_score") + buzz_score = payload.get("buzz_score") + trend = payload.get("trend") + mentions = payload.get("mentions") + bullish_pct = payload.get("bullish_pct") + bearish_pct = payload.get("bearish_pct") + + lines = [ + f"Market sentiment for {ticker} ({company_name})", + f"Source: {source.strip().lower()}", + ] + if sentiment_score is not None: + lines.append(f"Sentiment score: {sentiment_score}") + if buzz_score is not None: + lines.append(f"Buzz score: {buzz_score}") + if trend: + lines.append(f"Trend: {trend}") + if mentions is not None: + lines.append(f"Mentions: {mentions}") + if bullish_pct is not None: + lines.append(f"Bullish: {bullish_pct}") + if bearish_pct is not None: + lines.append(f"Bearish: {bearish_pct}") + + summary = payload.get("summary") or payload.get("explanation") + if summary: + lines.append(f"Summary: {summary}") + + return "\n".join(lines) diff --git a/python/valuecell/tests/test_news_agent_market_sentiment_tool.py b/python/valuecell/tests/test_news_agent_market_sentiment_tool.py new file mode 100644 index 000000000..96a36f846 --- /dev/null +++ b/python/valuecell/tests/test_news_agent_market_sentiment_tool.py @@ -0,0 +1,142 @@ +import importlib.util +from pathlib import Path + +import pytest +import requests + + +TOOLS_PATH = ( + Path(__file__).resolve().parents[1] / "agents" / "news_agent" / "tools.py" +) +TOOLS_SPEC = importlib.util.spec_from_file_location("news_agent_tools", TOOLS_PATH) +tools = importlib.util.module_from_spec(TOOLS_SPEC) +TOOLS_SPEC.loader.exec_module(tools) + + +class _Response: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self._payload + + +def test_fetch_adanos_stock_sentiment_builds_expected_request(monkeypatch): + calls = [] + + def fake_get(url, headers, params, timeout): + calls.append( + { + "url": url, + "headers": headers, + "params": params, + "timeout": timeout, + } + ) + return _Response({"ticker": "AAPL", "sentiment_score": 0.35}) + + monkeypatch.setenv("ADANOS_API_KEY", "test-key") + monkeypatch.setattr(requests, "get", fake_get) + + payload = tools._fetch_adanos_stock_sentiment( + ticker=" aapl ", + source="News", + days=3, + ) + + assert payload == {"ticker": "AAPL", "sentiment_score": 0.35} + assert calls == [ + { + "url": "https://api.adanos.org/news/stocks/v1/stock/AAPL", + "headers": {"X-API-Key": "test-key", "Accept": "application/json"}, + "params": {"days": 3}, + "timeout": 10, + } + ] + + +@pytest.mark.asyncio +async def test_get_market_sentiment_reports_missing_api_key(monkeypatch): + monkeypatch.delenv("ADANOS_API_KEY", raising=False) + + result = await tools.get_market_sentiment("AAPL") + + assert result == "ADANOS_API_KEY is not configured" + + +@pytest.mark.asyncio +async def test_get_market_sentiment_returns_formatted_payload(monkeypatch): + def fake_fetch(ticker, source, days): + assert ticker == "TSLA" + assert source == "reddit" + assert days == 14 + return { + "ticker": "TSLA", + "company_name": "Tesla Inc.", + "sentiment_score": 0.42, + "buzz_score": 88.5, + } + + monkeypatch.setattr(tools, "_fetch_adanos_stock_sentiment", fake_fetch) + + result = await tools.get_market_sentiment("TSLA", source="reddit", days=14) + + assert "Market sentiment for TSLA (Tesla Inc.)" in result + assert "Source: reddit" in result + assert "Sentiment score: 0.42" in result + assert "Buzz score: 88.5" in result + + +def test_fetch_adanos_stock_sentiment_validates_inputs(monkeypatch): + monkeypatch.setenv("ADANOS_API_KEY", "test-key") + + with pytest.raises(ValueError, match="ticker must not be empty"): + tools._fetch_adanos_stock_sentiment("", "news", 7) + + with pytest.raises(ValueError, match="source must be one of"): + tools._fetch_adanos_stock_sentiment("AAPL", "invalid", 7) + + with pytest.raises(ValueError, match="days must be an integer"): + tools._fetch_adanos_stock_sentiment("AAPL", "news", "invalid") + + with pytest.raises(ValueError, match="days must be between 1 and 365"): + tools._fetch_adanos_stock_sentiment("AAPL", "news", 0) + + +def test_fetch_adanos_stock_sentiment_rejects_non_object_payload(monkeypatch): + monkeypatch.setenv("ADANOS_API_KEY", "test-key") + monkeypatch.setattr(requests, "get", lambda *args, **kwargs: _Response([])) + + with pytest.raises(ValueError, match="JSON object"): + tools._fetch_adanos_stock_sentiment("AAPL", "news", 7) + + +def test_format_adanos_stock_sentiment_includes_available_fields(): + result = tools._format_adanos_stock_sentiment( + { + "ticker": "TSLA", + "company_name": "Tesla Inc.", + "sentiment_score": 0.42, + "buzz_score": 88.5, + "trend": "rising", + "mentions": 123, + "bullish_pct": 0.61, + "bearish_pct": 0.22, + "summary": "Discussion is improving across news and social sources.", + }, + ticker="tsla", + source="News", + ) + + assert "Market sentiment for TSLA (Tesla Inc.)" in result + assert "Source: news" in result + assert "Sentiment score: 0.42" in result + assert "Buzz score: 88.5" in result + assert "Trend: rising" in result + assert "Mentions: 123" in result + assert "Bullish: 0.61" in result + assert "Bearish: 0.22" in result + assert "Summary: Discussion is improving" in result