From 0ec9afa2a386f2ff82abe4f5b7376a6d16c64ec2 Mon Sep 17 00:00:00 2001 From: Mehran Moazeni <77067119+Mehranmzn@users.noreplygithub.com> Date: Tue, 9 Dec 2025 17:25:51 +0100 Subject: [PATCH 01/11] Add channel reporting and streaming services --- .env.example | 1 + README.md | 46 +- api/routers/health.py | 6 +- docs/index.md | 47 +- pyproject.toml | 4 +- requirements.txt | 4 +- .../agents/channel_report_agent.py | 146 +++++++ .../agents/financial_agent.py | 363 +++++++++++----- .../agents/report_analysis_agent.py | 21 +- .../config/settings.py | 20 +- src/tradegraph_financial_advisor/main.py | 99 ++++- .../reporting/__init__.py | 5 + .../reporting/pdf_reporter.py | 210 +++++++++ .../server/channel_server.py | 92 ++++ .../services/__init__.py | 12 +- .../services/channel_stream_service.py | 408 ++++++++++++++++++ .../services/local_scraping_service.py | 140 +++--- .../services/market_data_clients.py | 158 +++++++ .../services/price_trend_service.py | 180 ++++++++ .../visualization/charts.py | 19 +- .../workflows/analysis_workflow.py | 17 + tests/conftest.py | 150 +++++-- tests/unit/test_agents.py | 91 ++-- tests/unit/test_channels.py | 74 ++++ tradegraph.duckdb | Bin 0 -> 1585152 bytes uv.lock | 188 ++------ 26 files changed, 2088 insertions(+), 413 deletions(-) create mode 100644 src/tradegraph_financial_advisor/agents/channel_report_agent.py create mode 100644 src/tradegraph_financial_advisor/reporting/__init__.py create mode 100644 src/tradegraph_financial_advisor/reporting/pdf_reporter.py create mode 100644 src/tradegraph_financial_advisor/server/channel_server.py create mode 100644 src/tradegraph_financial_advisor/services/channel_stream_service.py create mode 100644 src/tradegraph_financial_advisor/services/market_data_clients.py create mode 100644 src/tradegraph_financial_advisor/services/price_trend_service.py create mode 100644 tests/unit/test_channels.py create mode 100644 tradegraph.duckdb diff --git a/.env.example b/.env.example index 1d037a6..bc490fe 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ OPENAI_API_KEY=your_openai_api_key_here +FINNHUB_API_KEY=your_finnhub_api_key_here # Optional but recommended ALPHA_VANTAGE_API_KEY=your_alpha_vantage_api_key_here FINANCIAL_DATA_API_KEY=your_financial_data_api_key_here diff --git a/README.md b/README.md index 5ac7f08..f4ca5d4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A sophisticated multi-agent financial analysis system that uses **LangGraph**, * - **SEC Filing Analysis**: Deep analysis of 10-K and 10-Q reports using AI - **Technical Analysis**: Comprehensive technical indicators and chart pattern recognition - **Sentiment Analysis**: AI-powered sentiment analysis of news and social media +- **WebSocket News Channels**: Dedicated multi-stream WebSockets for tier-one financial news, open agencies, and real-time pricing - **Portfolio Optimization**: Intelligent portfolio construction with risk management - **Trading Recommendations**: Buy/Sell/Hold recommendations with confidence scores - **Risk Assessment**: Multi-factor risk analysis and position sizing @@ -67,6 +68,7 @@ Required environment variables: ```env OPENAI_API_KEY=your_openai_api_key_here +FINNHUB_API_KEY=your_finnhub_api_key_here # Optional but recommended ALPHA_VANTAGE_API_KEY=your_alpha_vantage_api_key_here FINANCIAL_DATA_API_KEY=your_financial_data_api_key_here @@ -85,23 +87,55 @@ DEFAULT_PORTFOLIO_SIZE=100000 ```bash # Basic analysis -tradegraph AAPL MSFT GOOGL +uv run tradegraph AAPL MSFT GOOGL # Comprehensive analysis with custom parameters -tradegraph AAPL MSFT GOOGL \ +uv run tradegraph AAPL MSFT GOOGL \ --portfolio-size 250000 \ --risk-tolerance aggressive \ --time-horizon long_term \ --analysis-type comprehensive # Quick analysis -tradegraph TSLA NVDA --analysis-type quick +uv run tradegraph TSLA NVDA --analysis-type quick # Generate alerts only -tradegraph AAPL --alerts-only +uv run tradegraph AAPL --alerts-only # JSON output -tradegraph AAPL MSFT --output-format json > analysis.json +uv run tradegraph AAPL MSFT --output-format json > analysis.json +``` + +### Real-Time WebSocket Channels + +The repository now ships with a FastAPI service that exposes three dedicated WebSocket channels: + +1. `top_market_crypto` – Reuters, CNBC, WSJ, MarketWatch, and CoinDesk headlines +2. `open_source_agencies` – Guardian, BBC, Al Jazeera, NPR, and Financial Express (all free/open access) +3. `live_price_stream` – Finnhub (equities) + Binance (crypto) price snapshots with last year/month/day/hour trends + +Launch the channel server with `uv` and subscribe from any WebSocket client: + +```bash +uv run uvicorn tradegraph_financial_advisor.server.channel_server:app --reload +``` + +Example subscription (JavaScript snippet): + +```js +const socket = new WebSocket('ws://127.0.0.1:8000/ws/top_market_crypto?symbols=AAPL,MSFT,BTC-USD'); +socket.onmessage = (event) => { + console.log(JSON.parse(event.data)); +}; +``` + +### PDF Financial Reports + +Financial agents can now condense all three channels into a PDF that covers news context, buy/hold/sell guidance, risk mix, and multi-horizon price trends. Trend snapshots are limited to month/week/day/hour windows so the report explicitly reflects month-to-date momentum. + +```bash +uv run tradegraph AAPL BTC-USD --analysis-type comprehensive --channel-report \ + --pdf-path results/aapl_crypto_multichannel.pdf ``` ### Python API Usage @@ -284,7 +318,7 @@ GOOGL: HOLD (Confidence: 65.0%) - **LangGraph**: Workflow orchestration and agent coordination - **OpenAI GPT-4**: Natural language processing and analysis -- **yfinance**: Financial data retrieval +- **Finnhub** (equities) and **Binance** (crypto) for live/historical pricing - **pandas/numpy**: Data processing and analysis - **aiohttp**: Async HTTP requests - **pydantic**: Data validation and serialization diff --git a/api/routers/health.py b/api/routers/health.py index baff9ef..be2e4e1 100644 --- a/api/routers/health.py +++ b/api/routers/health.py @@ -269,7 +269,6 @@ async def check_dependencies(): "pydantic", "langchain", "langgraph", - "yfinance", "pandas", "numpy", "aiohttp", @@ -290,7 +289,7 @@ async def check_dependencies(): # Check environment variables import os - env_vars = ["OPENAI_API_KEY"] + env_vars = ["OPENAI_API_KEY", "FINNHUB_API_KEY"] for var in env_vars: dependencies[f"env_{var.lower()}"] = { "status": "configured" if os.getenv(var) else "missing" @@ -298,7 +297,8 @@ async def check_dependencies(): # Check external services (simplified) dependencies["external_apis"] = { - "openai": "configured" if os.getenv("OPENAI_API_KEY") else "not_configured" + "openai": "configured" if os.getenv("OPENAI_API_KEY") else "not_configured", + "finnhub": "configured" if os.getenv("FINNHUB_API_KEY") else "not_configured", } return APIResponse(success=True, data=dependencies, message="Dependencies check completed") diff --git a/docs/index.md b/docs/index.md index a9bf0ed..96f480a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,12 +64,18 @@ OPENAI_API_KEY=your_openai_key ```bash # Basic analysis - tradegraph AAPL MSFT GOOGL + uv run tradegraph AAPL MSFT GOOGL # Comprehensive analysis with custom parameters - tradegraph AAPL MSFT --portfolio-size 250000 \ + uv run tradegraph AAPL MSFT --portfolio-size 250000 \ --risk-tolerance aggressive \ --analysis-type comprehensive + + # Quick screen + uv run tradegraph TSLA NVDA --analysis-type quick + + # Alerts-only + JSON output + uv run tradegraph AAPL --alerts-only --output-format json ``` === "Python API" @@ -107,6 +113,43 @@ OPENAI_API_KEY=your_openai_key # Open http://localhost:3000 ``` +### Real-Time WebSocket Channels + +Three curated channels expose tier-one market news, open-license agencies, and real-time price trends via FastAPI: + +1. `top_market_crypto` – Reuters, CNBC, Wall Street Journal, MarketWatch, and CoinDesk +2. `open_source_agencies` – The Guardian, BBC Business, Al Jazeera, NPR, and Financial Express +3. `live_price_stream` – Finnhub equities + Binance crypto spot prices with last year/month/day/hour performance + +Start the channel server locally: + +```bash +uv run uvicorn tradegraph_financial_advisor.server.channel_server:app --reload +``` + +Listen from any WebSocket client: + +```js +const socket = new WebSocket('ws://127.0.0.1:8000/ws/top_market_crypto?symbols=AAPL,MSFT,BTC-USD'); +socket.onmessage = (event) => { + console.log(JSON.parse(event.data)); +}; +``` + +Snapshots are also available via `GET /channels/{channel_id}?symbols=AAPL,MSFT`. + +### Multichannel PDF Reports + +Generate investor-ready PDFs that merge channel summaries, recommendations, and the trend matrix (month/week/day/hour lookback so the results focus on month-to-date moves): + +```bash +uv run tradegraph AAPL BTC-USD --analysis-type comprehensive --channel-report \ + --pdf-path results/aapl_crypto_multichannel.pdf +``` + +The resulting file includes the ChannelReportAgent executive summary, news highlights, allocation guidance, and a multi-horizon trend table. + + ## 📈 Example Output ```json diff --git a/pyproject.toml b/pyproject.toml index 896602b..b647f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "requests>=2.31.0", "aiohttp>=3.9.0", "beautifulsoup4>=4.12.0", + "feedparser>=6.0.0", "pandas>=2.1.0", "numpy>=1.24.0", "python-dotenv>=1.0.0", @@ -37,11 +38,12 @@ dependencies = [ "pydantic-settings>=2.0.0", "asyncio>=3.4.3", "loguru>=0.7.0", - "yfinance>=0.2.0", "alpha-vantage>=2.3.0", "python-dateutil>=2.8.0", "plotly>=5.15.0", "fastapi>=0.118.0", + "reportlab>=4.0.0", + "uvicorn>=0.30.0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index ef6730b..4a398b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,15 +7,17 @@ mcp>=1.0.0 requests>=2.31.0 aiohttp>=3.9.0 beautifulsoup4>=4.12.0 +feedparser>=6.0.0 pandas>=2.1.0 numpy>=1.24.0 python-dotenv>=1.0.0 pydantic>=2.5.0 loguru>=0.7.0 -yfinance>=0.2.0 alpha-vantage>=2.3.0 pytest>=7.4.0 pytest-asyncio>=0.21.0 pytest-cov>=4.1.0 python-dateutil>=2.8.0 plotly>=5.15.0 +reportlab>=4.0.0 +uvicorn>=0.30.0 diff --git a/src/tradegraph_financial_advisor/agents/channel_report_agent.py b/src/tradegraph_financial_advisor/agents/channel_report_agent.py new file mode 100644 index 0000000..03ac33c --- /dev/null +++ b/src/tradegraph_financial_advisor/agents/channel_report_agent.py @@ -0,0 +1,146 @@ +"""Agent that summarizes streaming channel data for PDF reports.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from loguru import logger +from langchain_core.messages import HumanMessage +from langchain_openai import ChatOpenAI + +from .base_agent import BaseAgent +from ..config.settings import settings +from ..utils.helpers import generate_summary + + +class ChannelReportAgent(BaseAgent): + """Synthesizes channel payloads and market trends into a narrative.""" + + def __init__( + self, + *, + llm_model_name: str = "gpt-5-nano", + llm_client: Optional[ChatOpenAI] = None, + enable_llm: bool = True, + **kwargs: Any, + ) -> None: + super().__init__( + name="ChannelReportAgent", + description="Summarizes websocket channel data for investor-ready narratives", + **kwargs, + ) + self.llm = llm_client + if not self.llm and enable_llm and settings.openai_api_key: + self.llm = ChatOpenAI( + model=llm_model_name, + temperature=0.1, + api_key=settings.openai_api_key, + ) + + async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: + channel_payloads = input_data.get("channel_payloads", {}) + price_trends = input_data.get("price_trends", {}) + recommendations = input_data.get("recommendations", []) + + fallback = self._build_fallback_summary( + channel_payloads, price_trends, recommendations + ) + + if not self.llm: + return fallback + + try: + prompt_payload = { + "channel_payloads": channel_payloads, + "price_trends": price_trends, + "recommendations": recommendations, + } + prompt = ( + "You are TradeGraph's senior analyst. Combine the multichannel news feeds, " + "risk context, and trend data below into a concise JSON summary with " + "keys: news_takeaways (list of strings), risk_assessment (string), " + "buy_or_sell_view (string), trend_commentary (string), key_stats (object), " + "and summary_text (string)." + ) + response = await self.llm.ainvoke( + [HumanMessage(content=f"{prompt}\nINPUT:\n{json.dumps(prompt_payload)[:6000]}")] + ) + data = json.loads(response.content) + fallback.update({k: v for k, v in data.items() if v}) + return fallback + except Exception as exc: # pragma: no cover - network/LLM variability + logger.warning(f"ChannelReportAgent LLM summary failed: {exc}") + return fallback + + def _build_fallback_summary( + self, + channel_payloads: Dict[str, Any], + price_trends: Dict[str, Any], + recommendations: List[Dict[str, Any]], + ) -> Dict[str, Any]: + news_takeaways: List[str] = [] + for channel_id, payload in channel_payloads.items(): + items = payload.get("items", []) + if not items: + continue + titles = ", ".join(item.get("title", "").strip() for item in items[:2]) + news_takeaways.append( + f"{payload.get('title', channel_id)} highlights: {titles}" + ) + + risk_counts: Dict[str, int] = {} + for rec in recommendations: + risk = rec.get("risk_level", "unknown") + risk_counts[risk] = risk_counts.get(risk, 0) + 1 + + buy_view = "hold" + buy_votes = sum(1 for rec in recommendations if "buy" in str(rec.get("recommendation", "")).lower()) + sell_votes = sum(1 for rec in recommendations if "sell" in str(rec.get("recommendation", "")).lower()) + if buy_votes > sell_votes: + buy_view = "buy" + elif sell_votes > buy_votes: + buy_view = "reduce" + + trend_commentary = self._summarize_trends(price_trends) + summary_text = generate_summary(" ".join(news_takeaways)) or ( + "Latest headlines aggregated across equity, crypto, and free news agencies." + ) + + return { + "news_takeaways": news_takeaways, + "risk_assessment": f"Risk mix: {risk_counts or {'unknown': 0}}", + "buy_or_sell_view": buy_view, + "trend_commentary": trend_commentary, + "key_stats": { + "recommendation_count": len(recommendations), + "channels": list(channel_payloads.keys()), + }, + "summary_text": summary_text, + } + + def _summarize_trends(self, price_trends: Dict[str, Any]) -> str: + if not price_trends: + return "Trend data unavailable." + phrases = [] + for symbol, payload in price_trends.items(): + trends = payload.get("trends", {}) + month = trends.get("last_month", {}).get("percent_change") + week = trends.get("last_week", {}).get("percent_change") + day = trends.get("last_day", {}).get("percent_change") + hour = trends.get("last_hour", {}).get("percent_change") + parts = [] + if month is not None: + parts.append(f"{month:+.1f}% 1M") + if week is not None: + parts.append(f"{week:+.1f}% 1W") + if day is not None: + parts.append(f"{day:+.1f}% 1D") + if hour is not None: + parts.append(f"{hour:+.1f}% 1H") + if parts: + phrases.append(f"{symbol}: {' / '.join(parts)} (month-to-date view)") + return "; ".join(phrases) if phrases else "Trend data unavailable." + + +__all__ = ["ChannelReportAgent"] diff --git a/src/tradegraph_financial_advisor/agents/financial_agent.py b/src/tradegraph_financial_advisor/agents/financial_agent.py index c54376c..826904f 100644 --- a/src/tradegraph_financial_advisor/agents/financial_agent.py +++ b/src/tradegraph_financial_advisor/agents/financial_agent.py @@ -1,23 +1,30 @@ -from typing import Any, Dict, Optional -from datetime import datetime +from typing import Any, Dict, Optional, Tuple +from datetime import datetime, timedelta, timezone import aiohttp -import yfinance as yf import pandas as pd from loguru import logger from .base_agent import BaseAgent from ..models.financial_data import CompanyFinancials, MarketData, TechnicalIndicators from ..config.settings import settings +from ..services.market_data_clients import FinnhubClient, BinanceClient class FinancialAnalysisAgent(BaseAgent): def __init__(self, **kwargs): + finnhub_client = kwargs.pop("finnhub_client", None) + binance_client = kwargs.pop("binance_client", None) super().__init__( name="FinancialAnalysisAgent", description="Analyzes company financials and technical indicators", **kwargs, ) self.session: Optional[aiohttp.ClientSession] = None + self.finnhub_client = finnhub_client or FinnhubClient(settings.finnhub_api_key) + self.binance_client = binance_client or BinanceClient() + self._owns_finnhub = finnhub_client is None + self._owns_binance = binance_client is None + self._profile_cache: Dict[str, Optional[Dict[str, Any]]] = {} async def start(self) -> None: await super().start() @@ -28,6 +35,10 @@ async def start(self) -> None: async def stop(self) -> None: if self.session: await self.session.close() + if self._owns_finnhub: + await self.finnhub_client.close() + if self._owns_binance: + await self.binance_client.close() await super().stop() async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: @@ -43,21 +54,33 @@ async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: for symbol in symbols: try: symbol_data = {} + market_data: Optional[MarketData] = None if include_market_data: - market_data = await self._get_market_data(symbol) + if self._is_crypto(symbol): + market_data = await self._get_crypto_market_data(symbol) + else: + market_data = await self._get_equity_market_data(symbol) symbol_data["market_data"] = ( market_data.dict() if market_data else None ) if include_financials: - financials = await self._get_company_financials(symbol) - symbol_data["financials"] = ( - financials.dict() if financials else None - ) + if self._is_crypto(symbol): + symbol_data["financials"] = None + else: + financials = await self._get_company_financials( + symbol, market_data + ) + symbol_data["financials"] = ( + financials.dict() if financials else None + ) if include_technical: - technical = await self._get_technical_indicators(symbol) + if self._is_crypto(symbol): + technical = await self._get_crypto_technical_indicators(symbol) + else: + technical = await self._get_equity_technical_indicators(symbol) symbol_data["technical_indicators"] = ( technical.dict() if technical else None ) @@ -73,61 +96,91 @@ async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: "analysis_timestamp": datetime.now().isoformat(), } - async def _get_market_data(self, symbol: str) -> Optional[MarketData]: + async def _get_equity_market_data(self, symbol: str) -> Optional[MarketData]: try: - ticker = yf.Ticker(symbol) - info = ticker.info - history = ticker.history(period="1d") + quote = await self.finnhub_client.get_quote(symbol) + if not quote: + return None - if history.empty: + current_price = quote.get("c") + open_price = quote.get("o") + if current_price is None or open_price is None: return None - latest = history.iloc[-1] + change = float(current_price) - float(open_price) + change_percent = ( + (change / float(open_price)) * 100 if open_price else 0.0 + ) + volume = int(quote.get("v") or 0) + market_cap = await self._get_market_cap(symbol) - market_data = MarketData( + return MarketData( symbol=symbol, - current_price=float(latest["Close"]), - change=float(latest["Close"] - latest["Open"]), - change_percent=float( - (latest["Close"] - latest["Open"]) / latest["Open"] * 100 - ), - volume=int(latest["Volume"]), - market_cap=info.get("marketCap"), - pe_ratio=info.get("trailingPE"), + current_price=float(current_price), + change=float(change), + change_percent=float(change_percent), + volume=volume, + market_cap=market_cap, + pe_ratio=None, timestamp=datetime.now(), ) - return market_data - except Exception as e: logger.error(f"Error fetching market data for {symbol}: {str(e)}") return None - async def _get_company_financials(self, symbol: str) -> Optional[CompanyFinancials]: + async def _get_crypto_market_data(self, symbol: str) -> Optional[MarketData]: try: - ticker = yf.Ticker(symbol) - info = ticker.info + klines = await self.binance_client.get_klines( + symbol, + interval="1m", + limit=120, + ) + if not klines: + return None + + first = klines[0] + last = klines[-1] + start_price = float(first[1]) + end_price = float(last[4]) + change = end_price - start_price + change_percent = (change / start_price * 100) if start_price else 0.0 + volume = sum(float(kline[5]) for kline in klines) + + return MarketData( + symbol=symbol, + current_price=end_price, + change=change, + change_percent=change_percent, + volume=int(volume), + market_cap=None, + pe_ratio=None, + timestamp=datetime.now(), + ) + + except Exception as e: + logger.error(f"Error fetching crypto market data for {symbol}: {str(e)}") + return None + + async def _get_company_financials( + self, symbol: str, market_data: Optional[MarketData] + ) -> Optional[CompanyFinancials]: + try: + details = await self._get_ticker_details(symbol) + if not details and not market_data: + return None + + high_52, low_52 = await self._get_52_week_range(symbol) financials = CompanyFinancials( symbol=symbol, - company_name=info.get("longName", symbol), - market_cap=info.get("marketCap"), - pe_ratio=info.get("trailingPE"), - eps=info.get("trailingEps"), - revenue=info.get("totalRevenue"), - net_income=info.get("netIncomeToCommon"), - debt_to_equity=info.get("debtToEquity"), - current_ratio=info.get("currentRatio"), - return_on_equity=info.get("returnOnEquity"), - return_on_assets=info.get("returnOnAssets"), - price_to_book=info.get("priceToBook"), - dividend_yield=info.get("dividendYield"), - beta=info.get("beta"), - fifty_two_week_high=info.get("fiftyTwoWeekHigh"), - fifty_two_week_low=info.get("fiftyTwoWeekLow"), - current_price=info.get("currentPrice"), + company_name=(details or {}).get("name", symbol), + market_cap=(details or {}).get("market_cap"), + current_price=market_data.current_price if market_data else None, + fifty_two_week_high=high_52, + fifty_two_week_low=low_52, report_date=datetime.now(), - report_type="quarterly", + report_type="summary", ) return financials @@ -136,76 +189,31 @@ async def _get_company_financials(self, symbol: str) -> Optional[CompanyFinancia logger.error(f"Error fetching financials for {symbol}: {str(e)}") return None - async def _get_technical_indicators( + async def _get_equity_technical_indicators( self, symbol: str ) -> Optional[TechnicalIndicators]: try: - ticker = yf.Ticker(symbol) - history = ticker.history(period="3mo") # 3 months of data + now = datetime.now(timezone.utc) + candles = await self.finnhub_client.get_candles( + symbol, + resolution="D", + start=now - timedelta(days=160), + end=now, + ) + if candles.get("s") != "ok": + return None - if len(history) < 50: # Need enough data for indicators + closes = candles.get("c", []) + highs = candles.get("h", []) + lows = candles.get("l", []) + if len(closes) < 50: return None - # Calculate technical indicators - close_prices = history["Close"] - - # Simple Moving Averages - sma_20 = close_prices.rolling(window=20).mean().iloc[-1] - sma_50 = close_prices.rolling(window=50).mean().iloc[-1] - - # Exponential Moving Averages - ema_12_series = close_prices.ewm(span=12).mean() - ema_26_series = close_prices.ewm(span=26).mean() - ema_12 = ema_12_series.iloc[-1] - ema_26 = ema_26_series.iloc[-1] - - # RSI (simplified calculation) - delta = close_prices.diff() - gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() - loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() - rs = gain / loss - rsi = 100 - (100 / (1 + rs)).iloc[-1] - - # MACD - macd_line = ema_12_series - ema_26_series - macd_signal = macd_line.ewm(span=9).mean().iloc[-1] - - # Bollinger Bands - bb_window = 20 - bb_std = close_prices.rolling(window=bb_window).std().iloc[-1] - bb_sma = close_prices.rolling(window=bb_window).mean().iloc[-1] - bollinger_upper = bb_sma + (bb_std * 2) - bollinger_lower = bb_sma - (bb_std * 2) - - # Support and Resistance (simplified) - recent_high = history["High"].tail(20).max() - recent_low = history["Low"].tail(20).min() - - technical = TechnicalIndicators( - symbol=symbol, - sma_20=float(sma_20) if not pd.isna(sma_20) else None, - sma_50=float(sma_50) if not pd.isna(sma_50) else None, - ema_12=float(ema_12) if not pd.isna(ema_12) else None, - ema_26=float(ema_26) if not pd.isna(ema_26) else None, - rsi=float(rsi) if not pd.isna(rsi) else None, - macd=( - float(macd_line.iloc[-1]) - if not pd.isna(macd_line.iloc[-1]) - else None - ), - macd_signal=float(macd_signal) if not pd.isna(macd_signal) else None, - bollinger_upper=( - float(bollinger_upper) if not pd.isna(bollinger_upper) else None - ), - bollinger_lower=( - float(bollinger_lower) if not pd.isna(bollinger_lower) else None - ), - support_level=float(recent_low), - resistance_level=float(recent_high), - timestamp=datetime.now(), - ) + close_prices = pd.Series([float(value) for value in closes]) + high_series = pd.Series([float(value) for value in highs]) + low_series = pd.Series([float(value) for value in lows]) - return technical + return self._build_technical_indicators(symbol, close_prices, high_series, low_series) except Exception as e: logger.error( @@ -213,12 +221,137 @@ async def _get_technical_indicators( ) return None + async def _get_crypto_technical_indicators( + self, symbol: str + ) -> Optional[TechnicalIndicators]: + try: + klines = await self.binance_client.get_klines( + symbol, + interval="1d", + limit=160, + ) + if len(klines) < 50: + return None + + close_prices = pd.Series([float(item[4]) for item in klines]) + high_series = pd.Series([float(item[2]) for item in klines]) + low_series = pd.Series([float(item[3]) for item in klines]) + + return self._build_technical_indicators(symbol, close_prices, high_series, low_series) + + except Exception as e: + logger.error( + f"Error calculating crypto technical indicators for {symbol}: {str(e)}" + ) + return None + + def _build_technical_indicators( + self, + symbol: str, + close_prices: pd.Series, + high_series: pd.Series, + low_series: pd.Series, + ) -> TechnicalIndicators: + sma_20 = close_prices.rolling(window=20).mean().iloc[-1] + sma_50 = close_prices.rolling(window=50).mean().iloc[-1] + + ema_12_series = close_prices.ewm(span=12).mean() + ema_26_series = close_prices.ewm(span=26).mean() + ema_12 = ema_12_series.iloc[-1] + ema_26 = ema_26_series.iloc[-1] + + delta = close_prices.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss + rsi = 100 - (100 / (1 + rs)).iloc[-1] + + macd_line = ema_12_series - ema_26_series + macd_signal = macd_line.ewm(span=9).mean().iloc[-1] + + bb_window = 20 + bb_std = close_prices.rolling(window=bb_window).std().iloc[-1] + bb_sma = close_prices.rolling(window=bb_window).mean().iloc[-1] + bollinger_upper = bb_sma + (bb_std * 2) + bollinger_lower = bb_sma - (bb_std * 2) + + recent_high = float(high_series.tail(20).max()) + recent_low = float(low_series.tail(20).min()) + + return TechnicalIndicators( + symbol=symbol, + sma_20=float(sma_20) if pd.notna(sma_20) else None, + sma_50=float(sma_50) if pd.notna(sma_50) else None, + ema_12=float(ema_12) if pd.notna(ema_12) else None, + ema_26=float(ema_26) if pd.notna(ema_26) else None, + rsi=float(rsi) if pd.notna(rsi) else None, + macd=float(macd_line.iloc[-1]) if pd.notna(macd_line.iloc[-1]) else None, + macd_signal=float(macd_signal) if pd.notna(macd_signal) else None, + bollinger_upper=float(bollinger_upper) + if pd.notna(bollinger_upper) + else None, + bollinger_lower=float(bollinger_lower) + if pd.notna(bollinger_lower) + else None, + support_level=recent_low, + resistance_level=recent_high, + timestamp=datetime.now(), + ) + async def _health_check_impl(self) -> None: - # Test yfinance by fetching a simple stock quote try: - ticker = yf.Ticker("AAPL") - info = ticker.info - if not info: - raise Exception("Unable to fetch test data") + quote = await self.finnhub_client.get_quote("AAPL") + if not quote or quote.get("c") is None: + raise Exception("Finnhub quote unavailable") except Exception as e: - raise Exception(f"yfinance health check failed: {str(e)}") + raise Exception(f"Finnhub health check failed: {str(e)}") + + async def _get_market_cap(self, symbol: str) -> Optional[float]: + profile = await self._get_company_profile(symbol) + return (profile or {}).get("market_cap") + + async def _get_company_profile(self, symbol: str) -> Optional[Dict[str, Any]]: + if symbol in self._profile_cache: + return self._profile_cache[symbol] + profile = await self.finnhub_client.get_company_profile(symbol) + if profile: + normalized = { + "name": profile.get("name") or profile.get("ticker") or symbol, + "market_cap": profile.get("marketCapitalization"), + } + else: + normalized = None + self._profile_cache[symbol] = normalized + return normalized + + async def _get_52_week_range( + self, symbol: str + ) -> Tuple[Optional[float], Optional[float]]: + now = datetime.now(timezone.utc) + try: + candles = await self.finnhub_client.get_candles( + symbol, + resolution="D", + start=now - timedelta(days=365), + end=now, + ) + except Exception as exc: + logger.warning(f"Failed to compute 52-week range for {symbol}: {exc}") + return None, None + if candles.get("s") != "ok": + return None, None + highs = candles.get("h", []) + lows = candles.get("l", []) + if not highs or not lows: + return None, None + return float(max(highs)), float(min(lows)) + + @staticmethod + def _is_crypto(symbol: str) -> bool: + normalized = symbol.upper() + if normalized.startswith("X:") or normalized.startswith("CRYPTO:"): + return True + if "-" in normalized: + _, suffix = normalized.split("-", 1) + return suffix in {"USD", "USDT", "BTC", "ETH"} + return False diff --git a/src/tradegraph_financial_advisor/agents/report_analysis_agent.py b/src/tradegraph_financial_advisor/agents/report_analysis_agent.py index fa40417..dfd86e9 100644 --- a/src/tradegraph_financial_advisor/agents/report_analysis_agent.py +++ b/src/tradegraph_financial_advisor/agents/report_analysis_agent.py @@ -176,7 +176,7 @@ async def _analyze_single_report( response = await self.llm.ainvoke([HumanMessage(content=analysis_prompt)]) try: - analysis_data = json.loads(response.content) + analysis_data = self._parse_json_response(response.content) analysis_data["report_type"] = report_type analysis_data["filing_url"] = filing.get("url", "") analysis_data["analysis_date"] = datetime.now().isoformat() @@ -194,6 +194,25 @@ async def _analyze_single_report( logger.error(f"Error analyzing single report for {symbol}: {str(e)}") return {"error": str(e), "report_type": report_type} + def _parse_json_response(self, payload: str) -> Dict[str, Any]: + """Accept JSON output even if wrapped in code fences or commentary.""" + + cleaned = (payload or "").strip() + if not cleaned: + raise json.JSONDecodeError("Empty response", payload, 0) + + if "```" in cleaned: + segments = [segment.strip() for segment in cleaned.split("```") if segment.strip()] + if segments: + cleaned = segments[-1] + + start = cleaned.find("{") + end = cleaned.rfind("}") + if start != -1 and end != -1 and end > start: + cleaned = cleaned[start : end + 1] + + return json.loads(cleaned) + async def _generate_comprehensive_summary( self, symbol: str, report_analyses: List[Dict[str, Any]] ) -> Dict[str, Any]: diff --git a/src/tradegraph_financial_advisor/config/settings.py b/src/tradegraph_financial_advisor/config/settings.py index f509ca9..b586145 100644 --- a/src/tradegraph_financial_advisor/config/settings.py +++ b/src/tradegraph_financial_advisor/config/settings.py @@ -1,13 +1,16 @@ from typing import List, Optional +import os + +from dotenv import load_dotenv from pydantic import Field from pydantic_settings import BaseSettings -from dotenv import load_dotenv load_dotenv() class Settings(BaseSettings): openai_api_key: str = Field("", env="OPENAI_API_KEY") + finnhub_api_key: str = Field("", env="FINNHUB_API_KEY") alpha_vantage_api_key: Optional[str] = Field(None, env="ALPHA_VANTAGE_API_KEY") financial_data_api_key: Optional[str] = Field(None, env="FINANCIAL_DATA_API_KEY") @@ -28,7 +31,7 @@ class Settings(BaseSettings): analysis_depth: str = Field("detailed", env="ANALYSIS_DEPTH") default_portfolio_size: float = Field(100000.0, env="DEFAULT_PORTFOLIO_SIZE") - model_config = {"env_file": ".env", "case_sensitive": False} + model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"} @classmethod def get_news_sources_list(cls, v: str) -> List[str]: @@ -38,3 +41,16 @@ def get_news_sources_list(cls, v: str) -> List[str]: settings = Settings() + + +def refresh_openai_api_key() -> None: + """Reload API keys from the environment at runtime.""" + + load_dotenv(override=True) + for env_var, attr in ( + ("OPENAI_API_KEY", "openai_api_key"), + ("FINNHUB_API_KEY", "finnhub_api_key"), + ): + value = (os.getenv(env_var) or "").strip() + if value: + setattr(settings, attr, value) diff --git a/src/tradegraph_financial_advisor/main.py b/src/tradegraph_financial_advisor/main.py index b244c6b..bac70dc 100644 --- a/src/tradegraph_financial_advisor/main.py +++ b/src/tradegraph_financial_advisor/main.py @@ -1,6 +1,6 @@ import asyncio import sys -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from datetime import datetime import argparse from loguru import logger @@ -8,9 +8,13 @@ from .workflows.analysis_workflow import FinancialAnalysisWorkflow from .agents.recommendation_engine import TradingRecommendationEngine from .agents.report_analysis_agent import ReportAnalysisAgent -from .config.settings import settings +from .agents.channel_report_agent import ChannelReportAgent +from .config.settings import settings, refresh_openai_api_key from .utils.helpers import save_analysis_results from .visualization import charts +from .services.channel_stream_service import FinancialNewsChannelService +from .services.price_trend_service import PriceTrendService +from .reporting import ChannelPDFReportWriter class FinancialAdvisor: @@ -21,6 +25,10 @@ def __init__(self, llm_model_name: str = "gpt-5-nano"): model_name=self.llm_model_name ) self.report_analyzer = ReportAnalysisAgent(llm_model_name=self.llm_model_name) + self.channel_report_agent = ChannelReportAgent(llm_model_name=self.llm_model_name) + self.channel_service = FinancialNewsChannelService() + self.trend_service = PriceTrendService() + self.pdf_report_writer = ChannelPDFReportWriter() async def analyze_portfolio( self, @@ -175,6 +183,55 @@ async def quick_analysis( logger.error(f"Quick analysis failed: {str(e)}") raise + async def generate_channel_pdf_report( + self, + symbols: List[str], + *, + portfolio_size: Optional[float] = None, + risk_tolerance: str = "medium", + time_horizon: str = "medium_term", + include_reports: bool = False, + existing_results: Optional[Dict[str, Any]] = None, + output_path: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a PDF report that merges channel streams, recommendations, and trends.""" + + reference_results = existing_results + if reference_results is None: + reference_results = await self.analyze_portfolio( + symbols=symbols, + portfolio_size=portfolio_size, + risk_tolerance=risk_tolerance, + time_horizon=time_horizon, + include_reports=include_reports, + ) + + channel_streams = reference_results.get("channel_streams") or {} + if not channel_streams: + channel_streams = await self.channel_service.collect_all_channels(symbols) + await self.channel_service.close() + + price_trends = await self.trend_service.get_trends_for_symbols(symbols) + + summary_payload = await self.channel_report_agent.execute( + { + "channel_payloads": channel_streams, + "price_trends": price_trends, + "recommendations": reference_results.get("recommendations", []), + } + ) + + pdf_path = self.pdf_report_writer.build_report( + summary_payload=summary_payload, + channel_payloads=channel_streams, + price_trends=price_trends, + recommendations=reference_results.get("recommendations", []), + symbols=symbols, + output_path=output_path, + ) + + return {"pdf_path": pdf_path, "summary": summary_payload} + async def get_stock_alerts(self, symbols: List[str]) -> List[Dict[str, Any]]: """ Generate real-time alerts for given symbols. @@ -328,9 +385,22 @@ async def main(): parser.add_argument( "--alerts-only", action="store_true", help="Generate alerts only" ) + parser.add_argument( + "--channel-report", + action="store_true", + help="Generate the multichannel PDF report after analysis", + ) + parser.add_argument( + "--pdf-path", + type=str, + help="Optional output path for the PDF report", + ) args = parser.parse_args() + # Refresh the OpenAI API key so CLI runs pick up env changes immediately + refresh_openai_api_key() + # Configure logging logger.remove() # Remove default handler logger.add( @@ -422,6 +492,31 @@ async def main(): f"Failed to generate portfolio allocation chart: {str(e)}" ) + if args.channel_report: + try: + existing_reference = ( + results + if isinstance(results, dict) + and results.get("channel_streams") + else None + ) + pdf_info = await advisor.generate_channel_pdf_report( + symbols=args.symbols, + portfolio_size=args.portfolio_size, + risk_tolerance=args.risk_tolerance, + time_horizon=args.time_horizon, + include_reports=args.analysis_type == "comprehensive", + existing_results=existing_reference, + output_path=args.pdf_path, + ) + logger.info( + f"Channel PDF report saved to: {pdf_info['pdf_path']}" + ) + except Exception as pdf_exc: + logger.warning( + f"Failed to create PDF channel report: {pdf_exc}" + ) + # Display results based on output format if args.output_format == "json": import json diff --git a/src/tradegraph_financial_advisor/reporting/__init__.py b/src/tradegraph_financial_advisor/reporting/__init__.py new file mode 100644 index 0000000..ca30cf1 --- /dev/null +++ b/src/tradegraph_financial_advisor/reporting/__init__.py @@ -0,0 +1,5 @@ +"""Reporting utilities for TradeGraph.""" + +from .pdf_reporter import ChannelPDFReportWriter + +__all__ = ["ChannelPDFReportWriter"] diff --git a/src/tradegraph_financial_advisor/reporting/pdf_reporter.py b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py new file mode 100644 index 0000000..1156c5e --- /dev/null +++ b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py @@ -0,0 +1,210 @@ +"""PDF utilities to render multi-channel financial reports.""" + +from __future__ import annotations + +import os +from datetime import datetime +from typing import Any, Dict, List, Optional +import textwrap + +from reportlab.lib.pagesizes import LETTER +from reportlab.lib.units import inch +from reportlab.pdfgen import canvas + + +class ChannelPDFReportWriter: + """Minimal PDF builder for the multichannel financial report.""" + + def __init__(self) -> None: + self.page_width, self.page_height = LETTER + self.margin = 0.75 * inch + self.line_height = 14 + + def build_report( + self, + *, + summary_payload: Dict[str, Any], + channel_payloads: Dict[str, Any], + price_trends: Dict[str, Any], + recommendations: List[Dict[str, Any]], + symbols: List[str], + output_path: Optional[str] = None, + ) -> str: + os.makedirs("results", exist_ok=True) + if not output_path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_path = os.path.join( + "results", f"tradegraph_multichannel_{timestamp}.pdf" + ) + + doc = canvas.Canvas(output_path, pagesize=LETTER) + cursor_y = self.page_height - self.margin + + cursor_y = self._draw_title(doc, cursor_y, "TradeGraph Multichannel Report") + cursor_y = self._draw_subtitle( + doc, + cursor_y, + f"Symbols: {', '.join(symbols)} | Generated {datetime.now():%Y-%m-%d %H:%M UTC}", + ) + + cursor_y = self._draw_section( + doc, cursor_y, "Executive Summary", summary_payload.get("summary_text", "") + ) + + news_text = "\n".join(summary_payload.get("news_takeaways", [])) + cursor_y = self._draw_section(doc, cursor_y, "News Highlights", news_text) + + cursor_y = self._draw_section( + doc, + cursor_y, + "Risk & Signals", + f"Suggested Stance: {summary_payload.get('buy_or_sell_view', 'n/a').upper()}\n" + f"Risk Assessment: {summary_payload.get('risk_assessment', 'n/a')}\n" + f"Trend Notes: {summary_payload.get('trend_commentary', 'n/a')}", + ) + + cursor_y = self._draw_channel_breakdown(doc, cursor_y, channel_payloads) + cursor_y = self._draw_recommendations(doc, cursor_y, recommendations) + cursor_y = self._draw_trends(doc, cursor_y, price_trends) + + doc.save() + return output_path + + def _draw_title(self, doc: canvas.Canvas, cursor_y: float, text: str) -> float: + doc.setFont("Helvetica-Bold", 20) + doc.drawString(self.margin, cursor_y, text) + return cursor_y - 24 + + def _draw_subtitle(self, doc: canvas.Canvas, cursor_y: float, text: str) -> float: + doc.setFont("Helvetica", 11) + doc.drawString(self.margin, cursor_y, text) + return cursor_y - 18 + + def _draw_section( + self, doc: canvas.Canvas, cursor_y: float, title: str, body: str + ) -> float: + cursor_y = self._ensure_space(doc, cursor_y, min_height=80) + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, title) + cursor_y -= 18 + doc.setFont("Helvetica", 11) + wrapped = self._wrap_text(body, 96) + for line in wrapped: + doc.drawString(self.margin, cursor_y, line) + cursor_y -= self.line_height + return cursor_y - 6 + + def _draw_channel_breakdown( + self, doc: canvas.Canvas, cursor_y: float, channels: Dict[str, Any] + ) -> float: + cursor_y = self._ensure_space(doc, cursor_y, min_height=120) + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, "Channel Breakdown") + cursor_y -= 18 + doc.setFont("Helvetica", 10) + + for channel_id, payload in channels.items(): + cursor_y = self._ensure_space(doc, cursor_y, min_height=60) + doc.setFont("Helvetica-Bold", 12) + doc.drawString(self.margin, cursor_y, payload.get("title", channel_id)) + cursor_y -= 14 + doc.setFont("Helvetica", 10) + highlights = [item.get("title", "") for item in payload.get("items", [])[:3]] + body = "; ".join(highlights) or "No items collected" + for line in self._wrap_text(body, 90): + doc.drawString(self.margin + 10, cursor_y, f"- {line}") + cursor_y -= self.line_height + return cursor_y + + def _draw_recommendations( + self, doc: canvas.Canvas, cursor_y: float, recommendations: List[Dict[str, Any]] + ) -> float: + if not recommendations: + return cursor_y + cursor_y = self._ensure_space(doc, cursor_y, min_height=80) + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, "Trading Recommendations") + cursor_y -= 18 + doc.setFont("Helvetica", 10) + for rec in recommendations: + cursor_y = self._ensure_space(doc, cursor_y, min_height=50) + symbol_line = ( + f"{rec.get('symbol')} | {rec.get('recommendation', '').upper()} | " + f"Risk: {rec.get('risk_level', 'n/a')} | Confidence: {rec.get('confidence_score', 0):.2f}" + ) + doc.drawString(self.margin, cursor_y, symbol_line) + cursor_y -= self.line_height + details = ( + f"Target ${rec.get('target_price', 'n/a')} | Stop ${rec.get('stop_loss', 'n/a')} | " + f"Allocation {rec.get('recommended_allocation', 0):.1%}" + ) + doc.drawString(self.margin, cursor_y, details) + cursor_y -= self.line_height + factors = ", ".join(rec.get("key_factors", [])[:2]) + if factors: + doc.drawString(self.margin, cursor_y, f"Factors: {factors}") + cursor_y -= self.line_height + cursor_y -= 4 + return cursor_y + + def _draw_trends( + self, + doc: canvas.Canvas, + cursor_y: float, + price_trends: Dict[str, Any], + ) -> float: + if not price_trends: + return cursor_y + cursor_y = self._ensure_space(doc, cursor_y, min_height=120) + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, "Trend Snapshots") + cursor_y -= 18 + doc.setFont("Helvetica", 9) + doc.drawString( + self.margin, + cursor_y, + "Month-to-date focus: showing 1M, 1W, 1D, and 1H moves from the latest pricing.", + ) + cursor_y -= self.line_height + doc.setFont("Helvetica", 10) + + headers = "Symbol 1M % 1W % 1D % 1H %" + doc.drawString(self.margin, cursor_y, headers) + cursor_y -= self.line_height + for symbol, payload in price_trends.items(): + trends = payload.get("trends", {}) + row = ( + f"{symbol:<10}" + f"{self._format_pct(trends.get('last_month')):<9}" + f"{self._format_pct(trends.get('last_week')):<9}" + f"{self._format_pct(trends.get('last_day')):<9}" + f"{self._format_pct(trends.get('last_hour')):<9}" + ) + doc.drawString(self.margin, cursor_y, row) + cursor_y -= self.line_height + return cursor_y + + def _ensure_space( + self, doc: canvas.Canvas, cursor_y: float, *, min_height: float + ) -> float: + if cursor_y - min_height <= self.margin: + doc.showPage() + cursor_y = self.page_height - self.margin + return cursor_y + + def _wrap_text(self, text: str, width: int) -> List[str]: + if not text: + return ["n/a"] + return textwrap.wrap(text, width=width) or [text] + + @staticmethod + def _format_pct(trend: Optional[Dict[str, Any]]) -> str: + if not trend: + return " - " + percent = trend.get("percent_change") + if percent is None: + return " - " + return f"{percent:+.1f}%" + + +__all__ = ["ChannelPDFReportWriter"] diff --git a/src/tradegraph_financial_advisor/server/channel_server.py b/src/tradegraph_financial_advisor/server/channel_server.py new file mode 100644 index 0000000..956cd9b --- /dev/null +++ b/src/tradegraph_financial_advisor/server/channel_server.py @@ -0,0 +1,92 @@ +"""FastAPI server that exposes financial channels over WebSockets.""" + +from __future__ import annotations + +import asyncio +from typing import List, Optional + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, HTTPException +from fastapi.responses import JSONResponse +from loguru import logger + +from ..services.channel_stream_service import ( + FinancialNewsChannelService, + ChannelType, + CHANNEL_REGISTRY, +) + +app = FastAPI( + title="TradeGraph Financial Channels", + description="Real-time websocket feeds for multi-source news and pricing streams", + version="1.0.0", +) + +channel_service = FinancialNewsChannelService() + + +def _parse_symbols(symbols: Optional[str]) -> List[str]: + if not symbols: + return [] + return [symbol.strip() for symbol in symbols.split(",") if symbol.strip()] + + +@app.on_event("shutdown") +async def shutdown_event() -> None: + await channel_service.close() + + +@app.get("/health") +async def health() -> JSONResponse: + return JSONResponse( + { + "status": "ok", + "channel_count": len(ChannelType), + } + ) + + +@app.get("/channels") +async def list_channels() -> List[dict]: + return channel_service.describe_channels() + + +@app.get("/channels/{channel_id}") +async def channel_snapshot(channel_id: str, symbols: Optional[str] = Query(None)): + try: + payload = await channel_service.fetch_channel_payload( + channel_id, _parse_symbols(symbols) + ) + return payload + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.websocket("/ws/{channel_id}") +async def channel_stream(websocket: WebSocket, channel_id: str) -> None: + try: + channel_type = ChannelType.from_value(channel_id) + except ValueError as exc: # pragma: no cover - validated per connection + await websocket.close(code=4404, reason=str(exc)) + return + + definition = CHANNEL_REGISTRY[channel_type] + await websocket.accept() + + symbols_param = websocket.query_params.get("symbols") + symbols = _parse_symbols(symbols_param) + + try: + while True: + payload = await channel_service.fetch_channel_payload( + channel_type.value, symbols or None + ) + await websocket.send_json(payload) + await asyncio.sleep(max(5, definition.refresh_seconds)) + except WebSocketDisconnect: + logger.info("Websocket client disconnected: {}", channel_id) + except Exception as exc: + logger.error(f"Websocket streaming error for {channel_id}: {exc}") + await websocket.close(code=1011, reason=str(exc)) + + +__all__ = ["app"] diff --git a/src/tradegraph_financial_advisor/services/__init__.py b/src/tradegraph_financial_advisor/services/__init__.py index 36f14fc..2cfa9c2 100644 --- a/src/tradegraph_financial_advisor/services/__init__.py +++ b/src/tradegraph_financial_advisor/services/__init__.py @@ -1,3 +1,13 @@ from .local_scraping_service import LocalScrapingService +from .channel_stream_service import FinancialNewsChannelService, ChannelType +from .price_trend_service import PriceTrendService +from .market_data_clients import FinnhubClient, BinanceClient -__all__ = ["LocalScrapingService"] +__all__ = [ + "LocalScrapingService", + "FinancialNewsChannelService", + "ChannelType", + "PriceTrendService", + "FinnhubClient", + "BinanceClient", +] diff --git a/src/tradegraph_financial_advisor/services/channel_stream_service.py b/src/tradegraph_financial_advisor/services/channel_stream_service.py new file mode 100644 index 0000000..b828d7f --- /dev/null +++ b/src/tradegraph_financial_advisor/services/channel_stream_service.py @@ -0,0 +1,408 @@ +"""Streaming channel infrastructure for multi-source financial news and pricing.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional, Sequence + +import aiohttp +import feedparser +from loguru import logger + +from ..utils.helpers import generate_summary +from .price_trend_service import PriceTrendService + + +DEFAULT_HEADERS = { + "Accept": "application/rss+xml,application/xml;q=0.9,*/*;q=0.8", + "User-Agent": "TradeGraphBot/1.0 (https://github.com/Mehranmzn/TradeGraph)", +} + + +@dataclass +class NewsSource: + """Metadata describing a remote news source.""" + + id: str + name: str + url: str + coverage: str + topics: List[str] + category: str + is_open_access: bool = True + supports_crypto: bool = False + + +@dataclass +class ChannelDefinition: + """Configuration for a websocket enabled channel.""" + + channel_id: str + title: str + description: str + refresh_seconds: int + sources: List[NewsSource] = field(default_factory=list) + stream_type: str = "news" + default_symbols: Sequence[str] = field(default_factory=lambda: ("AAPL", "MSFT")) + + def metadata(self) -> Dict[str, Any]: + return { + "channel_id": self.channel_id, + "title": self.title, + "description": self.description, + "refresh_seconds": self.refresh_seconds, + "stream_type": self.stream_type, + "default_symbols": list(self.default_symbols), + "source_count": len(self.sources), + "sources": [asdict(source) for source in self.sources], + } + + +class ChannelType(str, Enum): + """Supported channel identifiers.""" + + TOP_MARKET_CRYPTO = "top_market_crypto" + OPEN_SOURCE_AGENCIES = "open_source_agencies" + LIVE_PRICE_STREAM = "live_price_stream" + + @classmethod + def from_value(cls, value: str) -> "ChannelType": + for member in cls: + if member.value == value: + return member + raise ValueError(f"Unsupported channel: {value}") + + +CHANNEL_REGISTRY: Dict[ChannelType, ChannelDefinition] = { + ChannelType.TOP_MARKET_CRYPTO: ChannelDefinition( + channel_id=ChannelType.TOP_MARKET_CRYPTO.value, + title="Top Market & Crypto Headlines", + description=( + "Aggregates market-moving stories from Reuters, CNBC, Wall Street Journal, " + "MarketWatch, and CoinDesk to cover both equities and crypto." + ), + refresh_seconds=45, + sources=[ + NewsSource( + id="reuters", + name="Reuters Top News", + url="https://feeds.reuters.com/reuters/topNews", + coverage="Global business and markets", + topics=["stocks", "macro", "economy"], + category="tier_one", + ), + NewsSource( + id="cnbc", + name="CNBC Markets", + url="https://www.cnbc.com/id/100003114/device/rss/rss.html", + coverage="US and global equity markets", + topics=["stocks", "earnings", "macro"], + category="tier_one", + ), + NewsSource( + id="wsj", + name="WSJ Markets", + url="https://feeds.a.dj.com/rss/RSSMarketsMain.xml", + coverage="Wall Street Journal markets desk", + topics=["stocks", "policy", "macro"], + category="tier_one", + ), + NewsSource( + id="marketwatch", + name="MarketWatch Top Stories", + url="https://www.marketwatch.com/rss/topstories", + coverage="MarketWatch newsroom", + topics=["stocks", "factors", "macro"], + category="tier_one", + ), + NewsSource( + id="coindesk", + name="CoinDesk", + url="https://www.coindesk.com/arc/outboundfeeds/rss/", + coverage="Digital assets and blockchain", + topics=["crypto", "regulation"], + category="crypto", + supports_crypto=True, + ), + ], + ), + ChannelType.OPEN_SOURCE_AGENCIES: ChannelDefinition( + channel_id=ChannelType.OPEN_SOURCE_AGENCIES.value, + title="Open News Agencies", + description=( + "Five open-license newsrooms (The Guardian, BBC Business, Al Jazeera, NPR, " + "and Financial Express) for freely accessible economic reporting." + ), + refresh_seconds=60, + sources=[ + NewsSource( + id="guardian", + name="The Guardian Business", + url="https://www.theguardian.com/business/rss", + coverage="Guardian Open Platform (CC BY)", + topics=["global", "policy", "companies"], + category="open_agency", + ), + NewsSource( + id="bbc", + name="BBC Business", + url="https://feeds.bbci.co.uk/news/business/rss.xml", + coverage="BBC World Service", + topics=["economy", "markets"], + category="open_agency", + ), + NewsSource( + id="aljazeera", + name="Al Jazeera Economy", + url="https://www.aljazeera.com/xml/rss/all.xml", + coverage="Global south lens", + topics=["emerging", "energy"], + category="open_agency", + ), + NewsSource( + id="npr", + name="NPR Economy", + url="https://www.npr.org/rss/rss.php?id=1006", + coverage="US economy (Creative Commons)", + topics=["policy", "inflation"], + category="open_agency", + ), + NewsSource( + id="financialexpress", + name="Financial Express Markets", + url="https://www.financialexpress.com/feed/market/", + coverage="Free-to-read Indian markets coverage", + topics=["asia", "markets"], + category="open_agency", + ), + ], + ), + ChannelType.LIVE_PRICE_STREAM: ChannelDefinition( + channel_id=ChannelType.LIVE_PRICE_STREAM.value, + title="Live Price & Trend Stream", + description=( + "Combines Finnhub equity aggregates and Binance crypto klines with TradeGraph trend analytics " + "to serve last year/month/day/hour performance for equities and crypto." + ), + refresh_seconds=30, + stream_type="prices", + default_symbols=("AAPL", "MSFT", "BTC-USD", "ETH-USD"), + ), +} + + +class FinancialNewsChannelService: + """Fetches and structures channel payloads for websocket consumers.""" + + def __init__( + self, + *, + max_items_per_source: int = 5, + max_items_per_channel: int = 25, + price_service: Optional[PriceTrendService] = None, + ) -> None: + self.max_items_per_source = max_items_per_source + self.max_items_per_channel = max_items_per_channel + self.price_service = price_service or PriceTrendService() + self._session_lock = asyncio.Lock() + self._session: Optional[aiohttp.ClientSession] = None + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + async def _get_session(self) -> aiohttp.ClientSession: + async with self._session_lock: + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=20) + self._session = aiohttp.ClientSession(timeout=timeout) + return self._session + + def describe_channels(self) -> List[Dict[str, Any]]: + return [definition.metadata() for definition in CHANNEL_REGISTRY.values()] + + async def collect_all_channels( + self, symbols: Optional[Sequence[str]] = None + ) -> Dict[str, Any]: + tasks = [ + self.fetch_channel_payload(channel.value, symbols) + for channel in ChannelType + ] + payloads = await asyncio.gather(*tasks, return_exceptions=True) + + result: Dict[str, Any] = {} + for channel, payload in zip(ChannelType, payloads): + if isinstance(payload, Exception): + logger.warning( + f"Failed to collect channel {channel.value}: {payload}" + ) + continue + result[channel.value] = payload + return result + + async def fetch_channel_payload( + self, channel_id: str, symbols: Optional[Sequence[str]] = None + ) -> Dict[str, Any]: + channel_type = ChannelType.from_value(channel_id) + channel_def = CHANNEL_REGISTRY[channel_type] + normalized_symbols = self._normalize_symbols(symbols) or [ + sym.upper() for sym in channel_def.default_symbols + ] + + if channel_def.stream_type == "prices": + items = await self._build_price_items(normalized_symbols) + else: + items = await self._collect_news(channel_def, normalized_symbols) + + return { + "channel_id": channel_def.channel_id, + "title": channel_def.title, + "description": channel_def.description, + "fetched_at": datetime.now(timezone.utc).isoformat(), + "symbols": normalized_symbols, + "items": items, + "source_count": len(channel_def.sources), + } + + async def _collect_news( + self, channel_definition: ChannelDefinition, symbols: Sequence[str] + ) -> List[Dict[str, Any]]: + session = await self._get_session() + tasks = [ + self._fetch_source_news(session, source, symbols) + for source in channel_definition.sources + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + collected: List[Dict[str, Any]] = [] + for source, payload in zip(channel_definition.sources, results): + if isinstance(payload, Exception): + logger.warning( + f"Failed to fetch feed from {source.name}: {payload}" + ) + continue + collected.extend(payload) + + collected.sort( + key=lambda item: item.get("published_at", ""), + reverse=True, + ) + return collected[: self.max_items_per_channel] + + async def _fetch_source_news( + self, + session: aiohttp.ClientSession, + source: NewsSource, + symbols: Sequence[str], + ) -> List[Dict[str, Any]]: + try: + async with session.get(source.url, headers=DEFAULT_HEADERS) as response: + if response.status == 403: + logger.info( + f"{source.name} feed blocked with 403. Skipping until access is restored." + ) + return [] + if response.status != 200: + logger.warning( + f"{source.name} feed returned status {response.status}. Skipping." + ) + return [] + feed_body = await response.text() + except aiohttp.ClientConnectorError as exc: + logger.info(f"{source.name} feed unreachable: {exc}") + return [] + except Exception as exc: + logger.warning(f"{source.name} feed failed: {exc}") + return [] + + feed = await asyncio.to_thread(feedparser.parse, feed_body) + articles: List[Dict[str, Any]] = [] + + for entry in feed.entries[: self.max_items_per_source * 2]: + normalized = self._normalize_entry(entry, source, symbols) + if not normalized: + continue + articles.append(normalized) + + return articles[: self.max_items_per_source] + + def _normalize_entry( + self, entry: Any, source: NewsSource, symbols: Sequence[str] + ) -> Optional[Dict[str, Any]]: + title = entry.get("title") or "" + summary = entry.get("summary") or entry.get("description") or "" + link = entry.get("link") or source.url + + matched_symbols = [ + symbol + for symbol in symbols + if symbol in title.upper() or symbol in summary.upper() + ] + + if symbols and not matched_symbols and source.category != "open_agency": + # For top-tier feeds, keep only relevant tickers when provided. + return None + + published = None + published_data = entry.get("published_parsed") or entry.get( + "updated_parsed" + ) + if published_data: + published = datetime(*published_data[:6], tzinfo=timezone.utc) + + summarized = generate_summary(summary) + tags = [ + tag.get("term") + for tag in entry.get("tags", []) + if isinstance(tag, dict) and tag.get("term") + ] + + return { + "title": title.strip(), + "summary": summarized, + "raw_summary": summary.strip(), + "url": link, + "source": source.name, + "source_id": source.id, + "coverage": source.coverage, + "topics": list({*source.topics, *(tags or [])}), + "matched_symbols": matched_symbols, + "published_at": published.isoformat() if published else None, + } + + async def _build_price_items( + self, symbols: Sequence[str] + ) -> List[Dict[str, Any]]: + trends = await self.price_service.get_trends_for_symbols(list(symbols)) + items: List[Dict[str, Any]] = [] + for symbol in symbols: + symbol_data = trends.get(symbol) + if not symbol_data: + continue + items.append( + { + "symbol": symbol, + "current_price": symbol_data.get("current_price"), + "pricing_timestamp": symbol_data.get("pricing_timestamp"), + "trends": symbol_data.get("trends", {}), + } + ) + return items + + @staticmethod + def _normalize_symbols(symbols: Optional[Sequence[str]]) -> List[str]: + if not symbols: + return [] + return [sym.strip().upper() for sym in symbols if sym and sym.strip()] + + +__all__ = [ + "FinancialNewsChannelService", + "ChannelType", + "ChannelDefinition", + "NewsSource", + "CHANNEL_REGISTRY", +] diff --git a/src/tradegraph_financial_advisor/services/local_scraping_service.py b/src/tradegraph_financial_advisor/services/local_scraping_service.py index 864338f..2ba7974 100644 --- a/src/tradegraph_financial_advisor/services/local_scraping_service.py +++ b/src/tradegraph_financial_advisor/services/local_scraping_service.py @@ -1,7 +1,14 @@ -from typing import Any, Dict, List +"""Local scraping helpers that supplement API-based news collection.""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Sequence + from loguru import logger from ddgs import DDGS from crawl4ai import AsyncWebCrawler + from ..models.financial_data import NewsArticle from ..utils.helpers import generate_summary @@ -11,76 +18,91 @@ def __init__(self): self.crawler = AsyncWebCrawler() async def search_and_scrape_news( - self, symbols: List[str], max_articles_per_symbol: int = 5 + self, symbols: Sequence[str], max_articles_per_symbol: int = 5 ) -> List[NewsArticle]: - all_articles = [] - async with DDGS() as ddgs: + """Use ddgs + Crawl4AI to pull supplemental ticker news.""" + + all_articles: List[NewsArticle] = [] + if not symbols: + return all_articles + + with DDGS() as ddgs: for symbol in symbols: query = f"{symbol} stock news" try: - results = await ddgs.news( - keywords=query, + results = await self._ddgs_news( + ddgs, + query=query, region="us-en", safesearch="off", timelimit="d", max_results=max_articles_per_symbol, ) - if results: - for result in results: - try: - scraped_data = await self.crawler.arun(result["url"]) - if scraped_data and scraped_data.markdown: - article = NewsArticle( - title=result.get("title", ""), - url=result.get("url", ""), - content=scraped_data.markdown, - summary=generate_summary(scraped_data.markdown), - source=result.get("source", ""), - published_at=result.get("date", ""), - symbols=[symbol], - ) - all_articles.append(article) - except Exception as e: - logger.warning( - f"Failed to scrape article {result['url']}: {e}" - ) - except Exception as e: - logger.error(f"Failed to search for news for symbol {symbol}: {e}") + except Exception as exc: + logger.error(f"Failed ddgs news search for {symbol}: {exc}") + continue + + for result in results: + try: + url = result.get("url") or result.get("href") + if not url: + continue + scraped_data = await self.crawler.arun(url) + if not scraped_data or not scraped_data.markdown: + continue + article = NewsArticle( + title=result.get("title", ""), + url=url, + content=scraped_data.markdown, + summary=generate_summary(scraped_data.markdown), + source=result.get("source", "ddgs"), + published_at=result.get("date", ""), + symbols=[symbol], + ) + all_articles.append(article) + except Exception as scrape_exc: + logger.warning(f"Failed to scrape article {result.get('url')}: {scrape_exc}") + return all_articles async def search_and_scrape_financial_reports( self, company_symbol: str, report_type: str = "10-K" ) -> List[Dict[str, Any]]: query = f"{company_symbol} {report_type} site:sec.gov" - filings = [] - async with DDGS() as ddgs: + filings: List[Dict[str, Any]] = [] + + with DDGS() as ddgs: try: - results = await ddgs.text( - keywords=query, + results = await self._ddgs_text( + ddgs, + query=query, region="us-en", safesearch="off", max_results=5, ) - if results: - for result in results: - try: - scraped_data = await self.crawler.arun(result["href"]) - if scraped_data and scraped_data.markdown: - filings.append( - { - "url": result["href"], - "content": scraped_data.markdown, - "report_type": report_type, - } - ) - except Exception as e: - logger.warning( - f"Failed to scrape report {result['href']}: {e}" - ) - except Exception as e: + except Exception as exc: logger.error( - f"Failed to search for financial reports for symbol {company_symbol}: {e}" + f"Failed ddgs filing search for {company_symbol} {report_type}: {exc}" ) + return filings + + for result in results: + href = result.get("href") or result.get("url") + if not href: + continue + try: + scraped_data = await self.crawler.arun(href) + if scraped_data and scraped_data.markdown: + filings.append( + { + "url": href, + "content": scraped_data.markdown, + "report_type": report_type, + } + ) + except Exception as scrape_exc: + logger.warning(f"Failed to scrape report {href}: {scrape_exc}") + return filings async def start(self): @@ -94,3 +116,23 @@ async def stop(self): async def health_check(self) -> bool: # For now, we assume the service is healthy if it can be instantiated. return True + + async def _ddgs_news( + self, ddgs: DDGS, *, query: str, **kwargs: Any + ) -> List[Dict[str, Any]]: + """Run blocking DDGS.news in a worker thread.""" + + def _runner() -> List[Dict[str, Any]]: + return list(ddgs.news(query, **kwargs)) + + return await asyncio.to_thread(_runner) + + async def _ddgs_text( + self, ddgs: DDGS, *, query: str, **kwargs: Any + ) -> List[Dict[str, Any]]: + """Run blocking DDGS.text in a worker thread.""" + + def _runner() -> List[Dict[str, Any]]: + return list(ddgs.text(query, **kwargs)) + + return await asyncio.to_thread(_runner) diff --git a/src/tradegraph_financial_advisor/services/market_data_clients.py b/src/tradegraph_financial_advisor/services/market_data_clients.py new file mode 100644 index 0000000..f9b7588 --- /dev/null +++ b/src/tradegraph_financial_advisor/services/market_data_clients.py @@ -0,0 +1,158 @@ +"""Async HTTP clients for Finnhub (equities) and Binance (crypto).""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any, Dict, List, Optional + +import aiohttp +from loguru import logger + + +class FinnhubClient: + BASE_URL = "https://finnhub.io/api/v1" + + def __init__(self, api_key: str, *, timeout: int = 20) -> None: + if not api_key: + raise ValueError("FINNHUB_API_KEY is required for market data") + self.api_key = api_key + self.timeout = timeout + self._session: Optional[aiohttp.ClientSession] = None + self._session_lock = asyncio.Lock() + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + async def get_quote(self, symbol: str) -> Optional[Dict[str, Any]]: + try: + return await self._get_json("/quote", params={"symbol": symbol}) + except Exception as exc: + logger.warning(f"Finnhub quote failed for {symbol}: {exc}") + return None + + async def get_company_profile(self, symbol: str) -> Optional[Dict[str, Any]]: + try: + return await self._get_json("/stock/profile2", params={"symbol": symbol}) + except Exception as exc: + logger.warning(f"Finnhub profile failed for {symbol}: {exc}") + return None + + async def get_candles( + self, + symbol: str, + *, + resolution: str, + start: datetime, + end: datetime, + ) -> Dict[str, Any]: + params = { + "symbol": symbol, + "resolution": resolution, + "from": int(start.timestamp()), + "to": int(end.timestamp()), + } + return await self._get_json("/stock/candle", params=params) + + async def _get_json( + self, path: str, params: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + session = await self._get_session() + params = params.copy() if params else {} + params["token"] = self.api_key + url = f"{self.BASE_URL}{path}" + async with session.get(url, params=params) as response: + if response.status != 200: + text = await response.text() + raise RuntimeError( + f"Finnhub request failed ({response.status}) for {path}: {text[:200]}" + ) + return await response.json() + + async def _get_session(self) -> aiohttp.ClientSession: + async with self._session_lock: + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=self.timeout) + self._session = aiohttp.ClientSession(timeout=timeout) + return self._session + + +class BinanceClient: + BASE_URL = "https://api.binance.com" + + def __init__(self, *, timeout: int = 20) -> None: + self.timeout = timeout + self._session: Optional[aiohttp.ClientSession] = None + self._session_lock = asyncio.Lock() + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + async def get_price(self, symbol: str) -> Optional[float]: + formatted = self._format_symbol(symbol) + try: + data = await self._get_json( + "/api/v3/ticker/price", params={"symbol": formatted} + ) + price = data.get("price") if isinstance(data, dict) else None + return float(price) if price is not None else None + except Exception as exc: + logger.warning(f"Binance price failed for {symbol}: {exc}") + return None + + async def get_klines( + self, + symbol: str, + *, + interval: str, + limit: int = 500, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + ) -> List[List[Any]]: + formatted = self._format_symbol(symbol) + params: Dict[str, Any] = { + "symbol": formatted, + "interval": interval, + "limit": min(limit, 1000), + } + if start: + params["startTime"] = int(start.timestamp() * 1000) + if end: + params["endTime"] = int(end.timestamp() * 1000) + data = await self._get_json("/api/v3/klines", params=params) + return data if isinstance(data, list) else [] + + async def _get_json( + self, path: str, params: Optional[Dict[str, Any]] = None + ) -> Any: + session = await self._get_session() + url = f"{self.BASE_URL}{path}" + async with session.get(url, params=params) as response: + if response.status != 200: + text = await response.text() + raise RuntimeError( + f"Binance request failed ({response.status}) for {path}: {text[:200]}" + ) + return await response.json() + + async def _get_session(self) -> aiohttp.ClientSession: + async with self._session_lock: + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=self.timeout) + self._session = aiohttp.ClientSession(timeout=timeout) + return self._session + + def _format_symbol(self, symbol: str) -> str: + normalized = symbol.strip().upper().replace(":", "") + if "-" in normalized: + base, quote = normalized.split("-", 1) + elif normalized.endswith("USDT"): + return normalized + elif normalized.endswith("USD"): + base, quote = normalized[:-3], "USD" + else: + base, quote = normalized, "USDT" + quote = "USDT" if quote in {"USD", "USDT"} else quote + return f"{base}{quote}" diff --git a/src/tradegraph_financial_advisor/services/price_trend_service.py b/src/tradegraph_financial_advisor/services/price_trend_service.py new file mode 100644 index 0000000..73838dc --- /dev/null +++ b/src/tradegraph_financial_advisor/services/price_trend_service.py @@ -0,0 +1,180 @@ +"""Price trend utilities shared by websocket streams and PDF reports.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, Sequence + +from loguru import logger + +from ..config.settings import settings +from .market_data_clients import FinnhubClient, BinanceClient + + +@dataclass(frozen=True) +class AggregateWindow: + equity_resolution: str + crypto_interval: str + window_seconds: int + limit: int = 500 + + +class PriceTrendService: + """Fetches historical pricing windows and builds normalized trend payloads.""" + + def __init__( + self, + *, + max_concurrent: int = 4, + finnhub_client: Optional[FinnhubClient] = None, + binance_client: Optional[BinanceClient] = None, + ) -> None: + self._semaphore = asyncio.Semaphore(max_concurrent) + self._finnhub = finnhub_client or FinnhubClient(settings.finnhub_api_key) + self._binance = binance_client or BinanceClient() + self._owns_finnhub = finnhub_client is None + self._owns_binance = binance_client is None + self._timeframes: Dict[str, AggregateWindow] = { + "last_month": AggregateWindow("60", "1h", 30 * 24 * 3600, limit=720), + "last_week": AggregateWindow("30", "30m", 7 * 24 * 3600, limit=336), + "last_day": AggregateWindow("5", "5m", 24 * 3600, limit=288), + "last_hour": AggregateWindow("1", "1m", 3 * 3600, limit=180), + } + + async def close(self) -> None: + if self._owns_finnhub: + await self._finnhub.close() + if self._owns_binance: + await self._binance.close() + + async def get_trends_for_symbols( + self, symbols: Sequence[str] + ) -> Dict[str, Dict[str, Any]]: + normalized = [sym.strip().upper() for sym in symbols if sym] + tasks = [self._run_symbol(symbol) for symbol in normalized] + results = await asyncio.gather(*tasks, return_exceptions=True) + + payload: Dict[str, Dict[str, Any]] = {} + for symbol, result in zip(normalized, results): + if isinstance(result, Exception): + logger.warning(f"Trend calculation failed for {symbol}: {result}") + continue + if result: + payload[symbol] = result + return payload + + async def _run_symbol(self, symbol: str) -> Optional[Dict[str, Any]]: + async with self._semaphore: + now = datetime.now(timezone.utc) + trend_tasks = { + label: asyncio.create_task(self._fetch_trend(symbol, spec, now)) + for label, spec in self._timeframes.items() + } + results = await asyncio.gather(*trend_tasks.values(), return_exceptions=True) + + trends: Dict[str, Any] = {} + for label, result in zip(trend_tasks.keys(), results): + if isinstance(result, Exception): + logger.warning(f"Trend window {label} failed for {symbol}: {result}") + continue + if result: + trends[label] = result + + if not trends: + return None + + snapshot: Dict[str, Any] = { + "symbol": symbol, + "trends": trends, + "pricing_timestamp": now.isoformat(), + } + snapshot["current_price"] = self._determine_current_price(trends) + return snapshot + + async def _fetch_trend( + self, symbol: str, spec: AggregateWindow, now: datetime + ) -> Optional[Dict[str, Any]]: + if self._is_crypto(symbol): + return await self._fetch_crypto_trend(symbol, spec, now) + return await self._fetch_equity_trend(symbol, spec, now) + + async def _fetch_equity_trend( + self, symbol: str, spec: AggregateWindow, now: datetime + ) -> Optional[Dict[str, Any]]: + start = now - timedelta(seconds=spec.window_seconds) + try: + candles = await self._finnhub.get_candles( + symbol, + resolution=spec.equity_resolution, + start=start, + end=now, + ) + except Exception as exc: + logger.warning(f"Finnhub aggregates failed for {symbol}: {exc}") + return None + if candles.get("s") != "ok": + return None + closes = candles.get("c", []) + return self._summarize_series(closes) + + async def _fetch_crypto_trend( + self, symbol: str, spec: AggregateWindow, now: datetime + ) -> Optional[Dict[str, Any]]: + start = now - timedelta(seconds=spec.window_seconds) + try: + klines = await self._binance.get_klines( + symbol, + interval=spec.crypto_interval, + limit=spec.limit, + start=start, + end=now, + ) + except Exception as exc: + logger.warning(f"Binance klines failed for {symbol}: {exc}") + return None + if not klines: + return None + closes = [float(item[4]) for item in klines] + return self._summarize_series(closes) + + def _determine_current_price(self, trends: Dict[str, Any]) -> Optional[float]: + for key in ("last_hour", "last_day", "last_week", "last_month"): + trend = trends.get(key) + if trend and trend.get("end") is not None: + return trend["end"] + return None + + @staticmethod + def _summarize_series(series: Sequence[float]) -> Optional[Dict[str, Any]]: + values = [float(value) for value in series if value is not None] + if len(values) < 2: + return None + start = values[0] + end = values[-1] + if start == 0: + return None + change = end - start + pct_change = (change / start) * 100 + direction = "bullish" if change > 0 else ("bearish" if change < 0 else "flat") + return { + "start": start, + "end": end, + "change": change, + "percent_change": pct_change, + "direction": direction, + } + + @staticmethod + def _is_crypto(symbol: str) -> bool: + normalized = symbol.upper() + if normalized.startswith("X:") or normalized.startswith("CRYPTO:"): + return True + if "-" in normalized: + _, suffix = normalized.split("-", 1) + return suffix in {"USD", "USDT", "BTC", "ETH"} + return False + + +__all__ = ["PriceTrendService"] diff --git a/src/tradegraph_financial_advisor/visualization/charts.py b/src/tradegraph_financial_advisor/visualization/charts.py index 304d01a..c75fd53 100644 --- a/src/tradegraph_financial_advisor/visualization/charts.py +++ b/src/tradegraph_financial_advisor/visualization/charts.py @@ -11,8 +11,23 @@ def create_portfolio_allocation_chart( recommendations: List of recommendation dicts from analysis results output_path: Where to save the HTML file """ - symbols = [rec["symbol"] for rec in recommendations] - allocations = [rec["allocation_percentage"] * 100 for rec in recommendations] + if not recommendations: + raise ValueError("No recommendations supplied for allocation chart") + + symbols = [rec.get("symbol", "?") for rec in recommendations] + allocations = [] + for rec in recommendations: + allocation_value = rec.get("recommended_allocation") + if allocation_value is None: + allocation_value = rec.get("allocation_percentage") + if allocation_value is None: + # fall back to max_position_size / portfolio size if present + portfolio_size = rec.get("portfolio_size") or 1 + max_position = rec.get("max_position_size") + allocation_value = ( + (max_position / portfolio_size) if max_position and portfolio_size else 0 + ) + allocations.append(float(allocation_value) * 100) fig = go.Figure( data=[ diff --git a/src/tradegraph_financial_advisor/workflows/analysis_workflow.py b/src/tradegraph_financial_advisor/workflows/analysis_workflow.py index 215b4c6..eaea34c 100644 --- a/src/tradegraph_financial_advisor/workflows/analysis_workflow.py +++ b/src/tradegraph_financial_advisor/workflows/analysis_workflow.py @@ -10,6 +10,7 @@ from ..agents.financial_agent import FinancialAnalysisAgent from ..agents.recommendation_engine import TradingRecommendationEngine from ..services.local_scraping_service import LocalScrapingService +from ..services.channel_stream_service import FinancialNewsChannelService from ..models.recommendations import ( TradingRecommendation, RecommendationType, @@ -31,6 +32,7 @@ class AnalysisState(TypedDict): messages: List[Any] next_step: str error_messages: List[str] + channel_streams: Dict[str, Any] class FinancialAnalysisWorkflow: @@ -49,6 +51,7 @@ def __init__( model_name=self.llm_model_name ) self.local_scraping_service = scraping_service or LocalScrapingService() + self.channel_service = FinancialNewsChannelService() self.workflow = None self._build_workflow() @@ -102,6 +105,7 @@ async def analyze_portfolio( messages=[], next_step="collect_news", error_messages=[], + channel_streams={}, ) try: @@ -121,6 +125,7 @@ async def analyze_portfolio( "financial_data": result.get("financial_data", {}), "recommendations": result.get("recommendations", []), "analysis_context": result.get("analysis_context", {}), + "channel_streams": result.get("channel_streams", {}), } return analysis_result @@ -133,6 +138,7 @@ async def analyze_portfolio( await self.news_agent.stop() await self.financial_agent.stop() await self.local_scraping_service.stop() + await self.channel_service.close() async def _collect_news(self, state: AnalysisState) -> AnalysisState: try: @@ -172,6 +178,17 @@ async def _collect_news(self, state: AnalysisState) -> AnalysisState: "collection_timestamp": datetime.now().isoformat(), } + # Capture websocket channel payloads for downstream reporting + try: + channel_payloads = await self.channel_service.collect_all_channels( + state["symbols"] + ) + state["channel_streams"] = channel_payloads + except Exception as channel_exc: + logger.warning( + f"Failed to collect channel streams: {channel_exc}" + ) + state["messages"].append( AIMessage(content=f"Collected {len(combined_news)} news articles") ) diff --git a/tests/conftest.py b/tests/conftest.py index 014b136..2ab5f08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -186,51 +186,123 @@ def sample_recommendations(): @pytest.fixture -def mock_yfinance_ticker(): - """Mock yfinance Ticker for testing.""" - mock_ticker = Mock() - mock_ticker.info = { - "longName": "Apple Inc.", - "currentPrice": 195.89, - "marketCap": 3000000000000, - "trailingPE": 28.5, - "trailingEps": 6.88, - "totalRevenue": 394328000000, - "netIncomeToCommon": 99803000000, - "debtToEquity": 1.73, - "currentRatio": 1.05, - "returnOnEquity": 0.175, - "returnOnAssets": 0.225, - "priceToBook": 5.02, - "dividendYield": 0.0047, - "beta": 1.29, - "fiftyTwoWeekHigh": 199.62, - "fiftyTwoWeekLow": 164.08, +def sample_channel_streams(): + return { + "top_market_crypto": { + "channel_id": "top_market_crypto", + "title": "Top Market & Crypto Headlines", + "items": [ + { + "title": "Markets rally on soft CPI", + "summary": "Major indices advanced after inflation cooled.", + "source": "Reuters", + "published_at": datetime.now().isoformat(), + }, + { + "title": "Bitcoin holds above 60k", + "summary": "Crypto markets consolidate gains.", + "source": "CoinDesk", + "published_at": datetime.now().isoformat(), + }, + ], + }, + "open_source_agencies": { + "channel_id": "open_source_agencies", + "title": "Open News Agencies", + "items": [ + { + "title": "Guardian: policy outlook improves", + "summary": "Central banks signal patience.", + "source": "Guardian", + "published_at": datetime.now().isoformat(), + } + ], + }, } - # Mock historical data - import pandas as pd - import numpy as np - dates = pd.date_range(end=datetime.now(), periods=100, freq="D") - prices = 190 + np.cumsum(np.random.randn(100) * 0.5) +@pytest.fixture +def sample_price_trends(): + return { + "AAPL": { + "symbol": "AAPL", + "current_price": 195.0, + "pricing_timestamp": datetime.now().isoformat(), + "trends": { + "last_month": { + "start": 180.0, + "end": 195.0, + "change": 15.0, + "percent_change": 8.3, + "direction": "bullish", + }, + "last_week": { + "start": 190.0, + "end": 195.0, + "change": 5.0, + "percent_change": 2.6, + "direction": "bullish", + }, + "last_day": { + "start": 194.0, + "end": 195.0, + "change": 1.0, + "percent_change": 0.5, + "direction": "bullish", + }, + "last_hour": { + "start": 194.5, + "end": 195.0, + "change": 0.5, + "percent_change": 0.26, + "direction": "bullish", + }, + }, + } + } - mock_history = pd.DataFrame( - { - "Open": prices * 0.99, - "High": prices * 1.02, - "Low": prices * 0.98, - "Close": prices, - "Volume": np.random.randint(20000000, 60000000, 100), - }, - index=dates, - ) - mock_ticker.history.return_value = mock_history - mock_ticker.quarterly_financials = pd.DataFrame() - mock_ticker.financials = pd.DataFrame() +@pytest.fixture +def mock_finnhub_client(): + """Mock Finnhub client for testing.""" + client = AsyncMock() + + client.get_quote.return_value = {"c": 195.5, "o": 193.0, "v": 25000000} + + async def get_candles(symbol, resolution, start, end): + base = 150.0 + closes = [base + i for i in range(160)] + return { + "s": "ok", + "c": closes, + "h": [value + 2 for value in closes], + "l": [value - 2 for value in closes], + } + + client.get_candles.side_effect = get_candles + client.get_company_profile.return_value = { + "name": "Apple Inc.", + "marketCapitalization": 3_000_000_000_000, + } + client.close.return_value = None + return client + - return mock_ticker +@pytest.fixture +def mock_binance_client(): + """Mock Binance client for testing.""" + client = AsyncMock() + + async def get_klines(symbol, interval, limit=500, start=None, end=None): + return [ + [0, 100.0 + i, 102.0 + i, 98.0 + i, 101.0 + i, 1500 + i] + for i in range(limit) + ] + + client.get_klines.side_effect = get_klines + client.get_price.return_value = 101.0 + client.close.return_value = None + return client @pytest.fixture diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py index 4316a70..9c07634 100644 --- a/tests/unit/test_agents.py +++ b/tests/unit/test_agents.py @@ -162,64 +162,77 @@ class TestFinancialAnalysisAgent: """Test FinancialAnalysisAgent functionality.""" @pytest.mark.asyncio - async def test_financial_agent_initialization(self): + async def test_financial_agent_initialization( + self, mock_finnhub_client, mock_binance_client + ): """Test financial agent initialization.""" - agent = FinancialAnalysisAgent() + agent = FinancialAnalysisAgent( + finnhub_client=mock_finnhub_client, binance_client=mock_binance_client + ) assert agent.name == "FinancialAnalysisAgent" assert "financial" in agent.description.lower() @pytest.mark.asyncio - async def test_execute_financial_analysis(self, mock_yfinance_ticker): + async def test_execute_financial_analysis( + self, mock_finnhub_client, mock_binance_client + ): """Test financial analysis execution.""" - agent = FinancialAnalysisAgent() + agent = FinancialAnalysisAgent( + finnhub_client=mock_finnhub_client, binance_client=mock_binance_client + ) - with patch("yfinance.Ticker", return_value=mock_yfinance_ticker): - await agent.start() + await agent.start() - input_data = { - "symbols": ["AAPL"], - "include_financials": True, - "include_technical": True, - "include_market_data": True, - } + input_data = { + "symbols": ["AAPL"], + "include_financials": True, + "include_technical": True, + "include_market_data": True, + } - result = await agent.execute(input_data) + result = await agent.execute(input_data) - assert "analysis_results" in result - assert "AAPL" in result["analysis_results"] + assert "analysis_results" in result + assert "AAPL" in result["analysis_results"] - aapl_data = result["analysis_results"]["AAPL"] - assert "market_data" in aapl_data - assert "financials" in aapl_data - assert "technical_indicators" in aapl_data + aapl_data = result["analysis_results"]["AAPL"] + assert "market_data" in aapl_data + assert "financials" in aapl_data + assert "technical_indicators" in aapl_data - await agent.stop() + await agent.stop() @pytest.mark.asyncio - async def test_market_data_extraction(self, mock_yfinance_ticker): + async def test_market_data_extraction( + self, mock_finnhub_client, mock_binance_client + ): """Test market data extraction.""" - agent = FinancialAnalysisAgent() + agent = FinancialAnalysisAgent( + finnhub_client=mock_finnhub_client, binance_client=mock_binance_client + ) - with patch("yfinance.Ticker", return_value=mock_yfinance_ticker): - market_data = await agent._get_market_data("AAPL") + market_data = await agent._get_equity_market_data("AAPL") - assert market_data is not None - assert market_data.symbol == "AAPL" - assert market_data.current_price > 0 - assert market_data.volume > 0 + assert market_data is not None + assert market_data.symbol == "AAPL" + assert market_data.current_price > 0 + assert market_data.volume > 0 @pytest.mark.asyncio - async def test_technical_indicators_calculation(self, mock_yfinance_ticker): + async def test_technical_indicators_calculation( + self, mock_finnhub_client, mock_binance_client + ): """Test technical indicators calculation.""" - agent = FinancialAnalysisAgent() + agent = FinancialAnalysisAgent( + finnhub_client=mock_finnhub_client, binance_client=mock_binance_client + ) - with patch("yfinance.Ticker", return_value=mock_yfinance_ticker): - technical_data = await agent._get_technical_indicators("AAPL") + technical_data = await agent._get_equity_technical_indicators("AAPL") - assert technical_data is not None - assert technical_data.symbol == "AAPL" - # Check that some indicators are calculated - assert technical_data.sma_20 is not None or technical_data.rsi is not None + assert technical_data is not None + assert technical_data.symbol == "AAPL" + # Check that some indicators are calculated + assert technical_data.sma_20 is not None or technical_data.rsi is not None class TestReportAnalysisAgent: @@ -463,11 +476,13 @@ async def test_price_targets_calculation(self, mock_langchain_llm): @pytest.mark.asyncio -async def test_agents_health_checks(): +async def test_agents_health_checks(mock_finnhub_client, mock_binance_client): """Test health checks for all agents.""" agents_to_test = [ NewsReaderAgent(), - FinancialAnalysisAgent(), + FinancialAnalysisAgent( + finnhub_client=mock_finnhub_client, binance_client=mock_binance_client + ), ] for agent in agents_to_test: diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py new file mode 100644 index 0000000..004f899 --- /dev/null +++ b/tests/unit/test_channels.py @@ -0,0 +1,74 @@ +import os +import pandas as pd +import pytest + +from tradegraph_financial_advisor.services.channel_stream_service import ( + FinancialNewsChannelService, + ChannelType, +) +from tradegraph_financial_advisor.services.price_trend_service import PriceTrendService +from tradegraph_financial_advisor.agents.channel_report_agent import ChannelReportAgent +from tradegraph_financial_advisor.reporting import ChannelPDFReportWriter + + +class _DummyPriceService: + def __init__(self, payload): + self.payload = payload + + async def get_trends_for_symbols(self, symbols): + return {symbol: self.payload[next(iter(self.payload))] for symbol in symbols} + + +@pytest.mark.asyncio +async def test_channel_service_price_payload(sample_price_trends): + service = FinancialNewsChannelService(price_service=_DummyPriceService(sample_price_trends)) + payload = await service.fetch_channel_payload( + ChannelType.LIVE_PRICE_STREAM.value, symbols=["AAPL"] + ) + assert payload["items"][0]["symbol"] == "AAPL" + assert "trends" in payload["items"][0] + + +@pytest.mark.asyncio +async def test_channel_report_agent_fallback( + sample_channel_streams, sample_price_trends, sample_recommendations +): + agent = ChannelReportAgent(llm_client=None, enable_llm=False) + summary = await agent.execute( + { + "channel_payloads": sample_channel_streams, + "price_trends": sample_price_trends, + "recommendations": sample_recommendations, + } + ) + assert summary["news_takeaways"] + assert "summary_text" in summary + + +def test_pdf_report_writer(tmp_path, sample_channel_streams, sample_price_trends, sample_recommendations): + writer = ChannelPDFReportWriter() + output_file = tmp_path / "report.pdf" + summary_payload = { + "summary_text": "Markets steady amid mixed data.", + "news_takeaways": ["Headline one", "Headline two"], + "risk_assessment": "medium", + "buy_or_sell_view": "buy", + "trend_commentary": "AAPL: +5% YoY", + } + pdf_path = writer.build_report( + summary_payload=summary_payload, + channel_payloads=sample_channel_streams, + price_trends=sample_price_trends, + recommendations=sample_recommendations, + symbols=["AAPL"], + output_path=str(output_file), + ) + assert os.path.exists(pdf_path) + assert os.path.getsize(pdf_path) > 0 + + +def test_price_trend_service_summarize_series(): + series = pd.Series([100.0, 105.0, 110.0]) + summary = PriceTrendService._summarize_series(series) + assert summary["direction"] == "bullish" + assert pytest.approx(summary["percent_change"], rel=1e-3) == 10.0 diff --git a/tradegraph.duckdb b/tradegraph.duckdb new file mode 100644 index 0000000000000000000000000000000000000000..8c2725495a6faf9e7a93392b11ea5aeae8d72773 GIT binary patch literal 1585152 zcmeI*3z%J1eJJofnIRKGctpbcHN=2|fRT5twi6O6D8WdALZN12=Hvl+nMshK$RI(n zrB|S$@l{gIZB-D^Pml`MsUO+^L4m6fszUfKtzHY&eu6|0=dQJ9pJeuACYhCF!vuck zOV&Q;f7V%#-(Gv~vu5V3bJj!u@-NT)^M|jSboRt|v}S|5UG%17jz8wuf%Beta{Eaq zoN&Ud6HgvEk7o!FAV7cs0RjXF5FkK+0D+fI-~)$`xaRijFZ+v~`mb{D;Tz&jJ@-cR zY#KJi-624L009C72oNAZfB*pk1cti6_aFMfnOBe7y}Ov**PFXbac8fMKNm3nujAMf zAV7cs0RjXF5FkK+0D&D%;O2*(zvpjvIpUn|;?|xu&di3jXASMyuvuBt{26l>%+0d+ z=d#*up!G; zq4@Ufsy*`l4Q5X+?WirD{{E%4#dCZ1WK$RpbK7H|PmKKx4c@tyUEMG{o4z{ReRgfZ z^u;q4v@e`Led*Gr+E)Nel(`{MKB{4vCdChUUfRcCeG`fE>I)7ae|f0Pgdw|??(zWlXoL!jpt z=XB{6-Vt6uJKLqd8bkNiU9w_PXi`yQ2z1@Q?)M?EEj6wmzPTE+M}ASRv1QVmpWntB z?-;qc8oLg@zFcG5ua<3VA3O6Os|=)?Wvj|HcHXnis$HW8967hXxMt@K`><>E)qnTI zR*jkV?6~{@2tTZfRW>WHE{nx%=BU_g&f=)J zd(}VvufM$|9t6c0-598>&UoBs*}0|Tq6k!5XX{_~sWT5$TW8mM`qY^Rs;#rLuWcTv ztj=GCGj=h0;@z#Or#bwHZ@>#gzehtjl-jf7YuWMnPxr!k``Ud^JNJzInvxGM2>}8G z2n=0;K7S-UbQ`K=1a?4yVJ~@`8(;DoRY+h<0zEU5zEh0;e=1a-{|;uBG1RVNsmEaI z(i^GeZ)xwm+MiGLPd%$wy}wf1Uv+zm6=fs5DnpA1Qspq%U@tNwG+S#hh zPwj7N`%{mXs{5PTo~nJS_Dt<>YJ240z5-Q`x76~hJ|0ug&)Zjf+e@pu{gd`+nvn7@ zwMUzoUGx-H{%uUJ-vAG0c9A-IQuC+ww*S$;<$ajiuKw#uZSBDMx7^}^Upts*Qp->E zsXG5)`uI}jsd_xTlzmaxrUI$QOKSeqcX6r5S8Dm23cS3Ls{5N-eyYZf?w{Jit`F`KkLiwLPil!_@Yqj{qd#>t47L1KkB6%FSE^@fe|PlX0W=Q)w55KfIt@_G%N9-@cU$u4+mTeC)b{kP zZ%dD>K5wP=cT4T<`~1eQm+1RU|Br{-{z~<)y8Tt}um1PSz)|CWV4L_%>hHc&$J^#> zf9apCdVHpix2ippevuI}r|yw0%`*CYnx)q#y#_PONYzYj{m9f8B<4@$Ozm%K`H9UB ztNK)XtGfJZTc2Kf>e(mNCpCZ7<0n-pwf$9}JyXk1-M^_mRp+m|{i)-v>h@IanOgo( z>u>7uIB@$@=UJPVOFb?(U)s=rHdQZmzoiNcyr26osp|IhU+cip)b^y#np4MT>MSgE zJfyZKb^pHHvy48UU+=ZLLlv`(sz=YjI@s?VCYW`H8)b^)djZ(``?QhlP54HBBzB@^6|4_?c z^?0t@Gj)8X<{xVNE4BSq=TG%X9dD`E-_-u5w*TeKKhWO+#5N7JesAV?8NF7l>b;)Z zU}hJo?+#N(Pt|)jb@ZpUC-rQb+McRMS=IZi>i(vdpPD~)Jf!AN^&e{OPwj7N{?zfm zqx+=xH`O!Mr|R~kwm&t0YJWF(#qY1~)Z;hxdfH!|n~SCPcXMU+_grd!t8Pzf`Kj&i z&tQATsy@D|EGb$_MiPi;?X{;Jzk_5MozT}RKXBA+RQMQ7``xLkSoW~t8n8?ovn z?QzBPum*Z_dzODuR7k>0!dlLy#~pnhKN-Fbtd!y5u!4FiusW>PTncr>~A2;B}} zP!<;4>Tlued>yWD-td(x9{A}^554`0uYdFJue$4+YcCIZi$#T>{@i!&UGc58x4)2G zbLa9$9$fp6zxZt_M(UTf0FyGOO0GOfMQ)d))%ket#|L% z!gJI96$5-kX|eL2w#788W!~?yYFoEE^P?(!=UmvEp;T)+*Uv7t+fSf=a^8L=Dv7`= zSHSV|%H6lG#-3F@pLjKz>NA0tN}y-%(Rb3(`}@TDox7P@yi_|#J+B~vonJwH(G>zi zLco-2NbClU7(l@I8~{f6RUy#-6k{;IurbScRSXcFBtU?`D_5ZEm2?InSM?tT4x$OA zEPioN;_zUI37EAFu^qC7=JY?y*zl)W1OAnkImQ-XJxzcB0RjZJlfY2>kj!@KmSPAL z0zL0WySsNR5Obc*@2{S{n$H*p`mj>{#~S&EmBI%vnl^rhXLv2U>%iV0>Is2=J*ZFM zmV^5QRvr>ReG)$Qv-zfZmH+_)1PBlyK!5-N0t8-Cf%?3^J!_nq4GVwc)}9Rulfd~i z<}R2UCvn}~GxN{l=YRb+a>?Qu?X}s9XDphtG0*C3RL7EqGcTOJXz|>cwT`%ELvwpJ zyl1U}xS+s(`FwCr&q@$Y?K76tVsy_^bm5}ff*9SW6rD47_MAZx0 zzQ{pX$|I~qaYA_U>M?UeFixh!>J;tS>o;bpWvj>J*}{?z4Qre0*;a%peZ7=gwld7r zLrFV@EDvnRa#dIdq&>STUzKD~dva+nYYh4SwW@C~CUo_*F?r=B@&`n1z0pE`BggvnESm%Yd0TF0V= z3p#4kJC@F$KV$K-J~hOa#86PrZ=-A3ulJ3$H0D2^@B@mM^hBdF0t5&UAV7cs0RjYG zZh>9%d3{);GR*Vh+-j3W1ADEBc*?YiN1r`)QdVCyFf0*SpJIiXXkNx;v5a8@t`%6% zHmsJl#+<`TIah~9gdCl#KlRsNd*YffF)o&W?7a1pfAi(9T^j;Dw>YP(mo)ea@5pD` zyYyFM*a_<{S+OWIsi-joy6#{1`w-Zc8rO#(4b094sxf=y7v&mTCcXLjZLIN*umtD8 zHFh0*eYwV7-`>1oY0Jdj*qQ%WWxzgWtI9R@`X;xnt+8wLfFtMD7uSS!I*WbS`+FUd z>S_)@PYb`chNRbE)Gq)ibpx zRhOUI-_-V}9xqk*H?=)g`&8|j+TYan$iICBsvd7um!GPUdVWqVzv}!|w|~+eO-bK3 zm)hUW%rZhzoBJi#U}hPqXY6}P&Y$X8b^fZyL)GmM zXSk?VaEkD&~NB2+d?`7pITspBoR{i)@r=CAtvlj@n8fAh19&HZ89U}hPqnyLARnts*oNge&EqhYA! zuX;RGy}wdDQ~SH4{|=xz5h&FsvG~ffr;fMO{hR8O+MZOPs?S%c{Y`EE@WdBXy}$D2 z?2(0!il^OD_e0hFsCvJp>7;w6mY?dMT7K1@slWTKy1%JDsrjq+tory(?QhaAGD7Cm z@;5)r*xZL%1~bb@)l6-D)nA~J%9+~V)bdk(Qrk1s%1>=i)%jD;KB@kx`Kum3sh+9r zulj0~T7K&OP4%fdf7R_z9dA{)r)tmC@`qY~Q;)~0+n;(oruq-H{He3l)cuv(-_-qE zb$e3VpW2?(+45G7&(W!~u+(vp+OE|7{PNB+vdw&ey^2{z)uSkNRHt55Q_D}ypW2>O z&(!v$mY+KMQ~R6R{?zuQ9_>}PC$;?4{HZ>v<)`LP9dD`Sr{+)fNo{}X)hM<6)c#go z{!nXA>bsQG_7AoERgdSYJyXYLYW|_Nzf#*@b^cVJ)bW;DerkVH%O7g_hgyF(^E-?I z7poe~ydw2|Vb$;3QpZ8n<39B~o7$eL$JsXTuh$H2k5t|7)bdmF53ZM6pDT4g47K)e zea{E?%GBdvNB2qX@8A?S=1TRcx;-0PR?WTC{-*xzU+VFjdRf2HP6ZBJ_cs@qfb{!0CAN6-8spD~1GXY04P+->;!oX-4f zf9g$c&$4`l+UE8w|2CYEgs(3e(fiAL!{d%VkDmp%-_7>D_1=5)0-Z8`xRgR=HFj+*EQE(9`Y7T`ab=+ z@7%lMTWfEBA-m?z<&QkL_8))o<@_s|qC&AKbYA*pvGmQZLfzAdTKuM^P216Xz2CU} z^-(RSOslVX9o83)x2>zKt-soGlpDMM_?3^{5|_9xy84mF9`C*?59IZ2WrY{V3O8BO z`-5Y9zMJcwY()KTy?eJ7o(cVH>UT}Et=o|dvB`z) znfwYbATSt#SN3r;7y)@+Ndoov#jm73=?j5Zi$Kr3sPE)si*q+qj91G5(N6*d2n;=e z3Z_zHhF&kVXh#>QV4UvgB-;Nf66k-5F_>T2m}R_*hKa5cAV7e?b`_}lZ}Cza>lX&8 zV)9of(DUB3yL;0b%-S|J8Oo9VXBiv*G;6@W(lW;w%6nDY2oNAZ;FTmW)IOi`O0v}# z0;vM#I-3{knOo#Dg+YBjD17juY2$}^M%S{>@74RmJR$H8d-n-^VxK;N&VA#jPO|={ zrkii9X9porpZC{)8dd)(S^dh?J6=CJ;-_Zo&&O>&PrbVC=B~Wh`eR!H1PBlyK!5-N z0t5&UAVA>d5vb4m>$TVC^YxVu<6OV@e7`=&k3S!%-><(mTmN12{S6rI5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&U7+7GJ8Ld}edt3K0*Btc3{@?%X55N5MEur= zxoXuefAPL~-+k?k!>`!){wvl^7<>6Q-+4jy^KY!WckfW-k>P(+_`hrTA0JPcFy)M+ zT27fZv2-Uq5pFkr_s^GJeCqJ**$>@uY1|I`^>d$y+d~_&?DX*ez>udg{2vzn9})i7 zZ|@IxVxA9VW69iw3-VXgUmh}Tc=3#x@#z?uvv6K}EN!JT7O-y_Ey=@zq+&zW=nto0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1h#{~Yj1q-U;lK>9beofl=Y|K|G4nKG5jAE{+}IhU+uXa z86L-5Q}`eAo-%FX(PvMcG-1M&Gh#Sf(vWYm?CN#zT=`;mcRhbL>UU3n>eS)IlT&_r z(vNm-o&We-UYFf=>2E)u{rGqHcMO~IslykI+$5OiJomF*NA$}1z{$59GP1Q#uveM2 zxx@acr>0)PUKRCQ`^yk4G`3|kqGwlRcLn$HjU#^7`u^5Mt%U|CUB`#C<@|SsG&^Eip0>p_yEXi8J?{U8bmeFMHKgml zbX7<@$Gk7b?|e&0yB4kwY1><_jrZ?g7t-vc!$Z>c#fL)L`d{x3Y3JK^4e7ecdC6z~ zF-Cs*!H}*T`}LTf5aVs5T0)wAzmT@P`}B~m zyYSc;pLujhyWTZAq$^*)XGl9oPY!9zlMjTn?JqwZ(ypJxmSjJ_FC;Cu#5~*a*4=a4 zrzkS+zc)1w|6t3>Egx#Rq~%XqE^W!O)5DItZrmATZ@Vtb*4`d+Y`nGHd_Z{Ib?~L( z0PVcvzvJQf*>gvR^n1_7hOT@5z>to3|DWt1k z6I->%*n>jawdZZINw=(yO*-HM@rk!Q6>AuOeAK^dQ52tY_sKE6_MDKmjJ`djU7z`C zd_mV`v7Wd5HrDX&i(>0^u=Rv zeK_`h-`bd+{;6@N-#-7RfAQ~k9n)~}dD(R%^Y-M`x19g2uUrY~-^|oB`U`W@U7lcr9&2y;QrzXO!~Zm-D;rLX_djxFynjjb$(Ah)N!JsV z7r0Xu37t+qRzBk_ghxIZ3{53Ih&$^ht=dh4wzs>i+KJN*4 zSN<&Owmmd8q^+ale#jn+XVC1-SpK>j@-yg%508;I{2(M3|6LuD*2m5Y z>AG4xhO_((+PdV}aJS_x@r;q>d$MJ}cpS9OoE*}1ANom*|NDnS(sI{B@%~5S@z?s7 zaV%t?isNWI-uj$JC*^}OTz~%Txcpx35vp5v=){K3AALJ^>pR1PR7vtZ@*55M8eAMX8)KOFYG7mgqC z`@`;j=)&*)XZNKi?zQI$4;=H}LprW|?80w+=!6Sqyl>f2|9!xhX6}Fe#~=8~Yk!>m z=Ud)<>Cdhjx7Tfda(Mf(U#|V@eKTj>vvlcCE_`IfP2<4t009EqBG8acn|SouQzvDe!zYe>@YGe=&hh-(82-KW;T63vD^10N=JUR` zz7%ihdDOh@%5zHbVMYAY=igC^hqB|X`L^Tt?YY;JHWd{#@AmDJOYxn09yK3z?TS)- zcoBd5ob{!6a}odOK6jMjdB;M%b6U?S#YYtRAOFJIQha0)e{t!_rT8vI{D3o7l;Wd` z_`6>ym%nQfzxDEqOZj&z;y*lRZ7IHc5g*xHE`M|pe`@^6rTlvo@z*`Jz7&5=5x=~) zZz;ZK5uY&SjIh%;JXOEf7zaVGTho{#|E~|K4}m<;v{w-y^-O!O>{-*^Mf}M7?k>gm zDdMMnXaV6hE|xU-RUoQv9_={N_7X zmEz-y_?o|KD#Z^g;=j6}y%ayZh>w_XcPak5B7XROMTtF+n_geUC;n!6DgO~ge8#^$ zS&AQ7#4o#HQYrq1BL0PoR+ZvM74b(8YcIu*DdKmY*;I-jUBrL)hr3JhHx}_RKOR?# zk1yiK-nhIJe^U{k@{uP?@nehlZ?@&M>9qu2Wdg-@DQlcKshET12U1f^!+U`Gwz#~9 zz)t%gb3xCsmW69k{x%_8!}4vo2IbpuZD|-b;glj*mW69d9`7k~LpZN!+WKC8{Qk`+wfi}--a=k zZ^L_kCK-k-jn3p@E#=JhW8x#HoV8k zx8XfSz76jo@@;s}kZ;3#gnS#`6Xe^LVtZ(@4cGoWUaqz0(WpJfoqR^yl8=Ak>)(I$ z={_086|W2L1@hO2Yka;9*YtcFuHpGMT(k3SxJKvOa81s);ToK8!!@YGe=&hh-(82-KW z;T644^i9Qs=JUR`z7%ihdDOh@%5zHbVMYAY=igC^hqB|X`L^Tt?YY;JHWd{#@AmDJ zOYxn09yK3z?TS)-coBd5ob{!6a}odOK6jMjdB;M%b6U?S#YYtRAOFJIQha0)e{t!_ zrT8vI{D3o7l;Wd`_`6>ym%nQfzxDEqOZj&z;y*lRZ7IHc5g*xHE`M|pe`@^6rTlvo z@z*`Jz7&5=5x=~)Zz;ZK5uY&SjIh%;JXOEf7zaVGTho{#|E~|K4}m<;v{w-y^-O!O z>{-*^Mf}M7?k>gmDdMMnXaV6hE|x zU-RUoQv9_={N_7XmEz-y_?o|KD#Z^g;=j6}y%ayZh>w_XcPak5B7XROMTtF+n_geU zC;n!6DgO~ge8#^$S&AQ7#4o#HQYrq1BL0PoR+ZvM74b(8YcIu*DdKmY*;I-jUBrL) zhr3JhHx}_RKOR?#k1yiK-nhIJe^U{k@{uP?@nehlsZ*y-pL*`(a15M!@q_m~lpl5{ zW?93e-nql$g9!NP#*6oGRscb__$PWcrTLI7b?rQA<4J(a%>3@ zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ n009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjYGc>@0*xbc)( literal 0 HcmV?d00001 diff --git a/uv.lock b/uv.lock index ff70e46..25c34dd 100644 --- a/uv.lock +++ b/uv.lock @@ -787,27 +787,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 }, ] -[[package]] -name = "curl-cffi" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337 }, - { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613 }, - { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353 }, - { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378 }, - { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585 }, - { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831 }, - { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908 }, - { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510 }, - { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753 }, -] - [[package]] name = "ddgs" version = "9.9.3" @@ -948,6 +927,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457 }, ] +[[package]] +name = "feedparser" +version = "6.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sgmllib3k" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480 }, +] + [[package]] name = "filelock" version = "3.19.1" @@ -971,25 +962,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922 }, ] -[[package]] -name = "frozendict" -version = "2.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/59/19eb300ba28e7547538bdf603f1c6c34793240a90e1a7b61b65d8517e35e/frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e", size = 316416 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/7f/e80cdbe0db930b2ba9d46ca35a41b0150156da16dfb79edcc05642690c3b/frozendict-2.4.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3a05c0a50cab96b4bb0ea25aa752efbfceed5ccb24c007612bc63e51299336f", size = 37927 }, - { url = "https://files.pythonhosted.org/packages/29/98/27e145ff7e8e63caa95fb8ee4fc56c68acb208bef01a89c3678a66f9a34d/frozendict-2.4.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5b94d5b07c00986f9e37a38dd83c13f5fe3bf3f1ccc8e88edea8fe15d6cd88c", size = 37945 }, - { url = "https://files.pythonhosted.org/packages/ac/f1/a10be024a9d53441c997b3661ea80ecba6e3130adc53812a4b95b607cdd1/frozendict-2.4.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c789fd70879ccb6289a603cdebdc4953e7e5dea047d30c1b180529b28257b5", size = 117656 }, - { url = "https://files.pythonhosted.org/packages/46/a6/34c760975e6f1cb4db59a990d58dcf22287e10241c851804670c74c6a27a/frozendict-2.4.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da6a10164c8a50b34b9ab508a9420df38f4edf286b9ca7b7df8a91767baecb34", size = 117444 }, - { url = "https://files.pythonhosted.org/packages/62/dd/64bddd1ffa9617f50e7e63656b2a7ad7f0a46c86b5f4a3d2c714d0006277/frozendict-2.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a8a43036754a941601635ea9c788ebd7a7efbed2becba01b54a887b41b175b9", size = 116801 }, - { url = "https://files.pythonhosted.org/packages/45/ae/af06a8bde1947277aad895c2f26c3b8b8b6ee9c0c2ad988fb58a9d1dde3f/frozendict-2.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9905dcf7aa659e6a11b8051114c9fa76dfde3a6e50e6dc129d5aece75b449a2", size = 117329 }, - { url = "https://files.pythonhosted.org/packages/d2/df/be3fa0457ff661301228f4c59c630699568c8ed9b5480f113b3eea7d0cb3/frozendict-2.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:323f1b674a2cc18f86ab81698e22aba8145d7a755e0ac2cccf142ee2db58620d", size = 37522 }, - { url = "https://files.pythonhosted.org/packages/4a/6f/c22e0266b4c85f58b4613fec024e040e93753880527bf92b0c1bc228c27c/frozendict-2.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:eabd21d8e5db0c58b60d26b4bb9839cac13132e88277e1376970172a85ee04b3", size = 34056 }, - { url = "https://files.pythonhosted.org/packages/04/13/d9839089b900fa7b479cce495d62110cddc4bd5630a04d8469916c0e79c5/frozendict-2.4.6-py311-none-any.whl", hash = "sha256:d065db6a44db2e2375c23eac816f1a022feb2fa98cbb50df44a9e83700accbea", size = 16148 }, - { url = "https://files.pythonhosted.org/packages/ba/d0/d482c39cee2ab2978a892558cf130681d4574ea208e162da8958b31e9250/frozendict-2.4.6-py312-none-any.whl", hash = "sha256:49344abe90fb75f0f9fdefe6d4ef6d4894e640fadab71f11009d52ad97f370b9", size = 16146 }, - { url = "https://files.pythonhosted.org/packages/a5/8e/b6bf6a0de482d7d7d7a2aaac8fdc4a4d0bb24a809f5ddd422aa7060eb3d2/frozendict-2.4.6-py313-none-any.whl", hash = "sha256:7134a2bb95d4a16556bb5f2b9736dceb6ea848fa5b6f3f6c2d6dba93b44b4757", size = 16146 }, -] - [[package]] name = "frozenlist" version = "1.7.0" @@ -1994,12 +1966,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313 }, ] -[[package]] -name = "multitasking" -version = "0.0.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984 } - [[package]] name = "mypy" version = "1.18.2" @@ -2486,12 +2452,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, ] -[[package]] -name = "peewee" -version = "3.18.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220 } - [[package]] name = "pillow" version = "12.0.0" @@ -2761,20 +2721,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663 }, ] -[[package]] -name = "protobuf" -version = "6.32.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/98/645183ea03ab3995d29086b8bf4f7562ebd3d10c9a4b14ee3f20d47cfe50/protobuf-6.32.1-cp310-abi3-win32.whl", hash = "sha256:a8a32a84bc9f2aad712041b8b366190f71dde248926da517bde9e832e4412085", size = 424411 }, - { url = "https://files.pythonhosted.org/packages/8c/f3/6f58f841f6ebafe076cebeae33fc336e900619d34b1c93e4b5c97a81fdfa/protobuf-6.32.1-cp310-abi3-win_amd64.whl", hash = "sha256:b00a7d8c25fa471f16bc8153d0e53d6c9e827f0953f3c09aaa4331c718cae5e1", size = 435738 }, - { url = "https://files.pythonhosted.org/packages/10/56/a8a3f4e7190837139e68c7002ec749190a163af3e330f65d90309145a210/protobuf-6.32.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8c7e6eb619ffdf105ee4ab76af5a68b60a9d0f66da3ea12d1640e6d8dab7281", size = 426454 }, - { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874 }, - { url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013 }, - { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289 }, -] - [[package]] name = "psutil" version = "7.1.3" @@ -3292,6 +3238,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/a3/c64894858aaaa454caa7cc47e2f225b04d3ed08ad649eacf58d45817fad2/regex-2025.9.18-cp314-cp314t-win_arm64.whl", hash = "sha256:b7531a8ef61de2c647cdf68b3229b071e46ec326b3138b2180acb4275f470b01", size = 273034 }, ] +[[package]] +name = "reportlab" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/80/dfa85941e3c3800aa5cd2f940c1903358c1fb61149f5f91b62efa61e7d03/reportlab-4.4.5.tar.gz", hash = "sha256:0457d642aa76df7b36b0235349904c58d8f9c606a872456ed04436aafadc1510", size = 3910836 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/16/0c26a7bdfd20cba49a011b1095461be120c53df3926e9843fccfb9530e72/reportlab-4.4.5-py3-none-any.whl", hash = "sha256:849773d7cd5dde2072fedbac18c8bc909506c8befba8f088ba7b09243c6684cc", size = 1954256 }, +] + [[package]] name = "requests" version = "2.32.5" @@ -3618,6 +3577,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127 }, ] +[[package]] +name = "sgmllib3k" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750 } + [[package]] name = "shapely" version = "2.1.2" @@ -3956,6 +3921,7 @@ dependencies = [ { name = "crawl4ai" }, { name = "ddgs" }, { name = "fastapi" }, + { name = "feedparser" }, { name = "langchain" }, { name = "langchain-openai" }, { name = "langgraph" }, @@ -3969,8 +3935,9 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-dateutil" }, { name = "python-dotenv" }, + { name = "reportlab" }, { name = "requests" }, - { name = "yfinance" }, + { name = "uvicorn" }, ] [package.optional-dependencies] @@ -3995,6 +3962,7 @@ requires-dist = [ { name = "crawl4ai", specifier = "~=0.7" }, { name = "ddgs", specifier = "~=9.9" }, { name = "fastapi", specifier = ">=0.118.0" }, + { name = "feedparser", specifier = ">=6.0.0" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, { name = "langchain", specifier = ">=0.3.0" }, @@ -4014,8 +3982,9 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "python-dateutil", specifier = ">=2.8.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "reportlab", specifier = ">=4.0.0" }, { name = "requests", specifier = ">=2.31.0" }, - { name = "yfinance", specifier = ">=0.2.0" }, + { name = "uvicorn", specifier = ">=0.30.0" }, ] provides-extras = ["dev"] @@ -4113,65 +4082,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279 }, ] -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423 }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080 }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312 }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319 }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631 }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016 }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426 }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360 }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388 }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830 }, - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423 }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082 }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330 }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878 }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883 }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252 }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521 }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958 }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918 }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388 }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828 }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109 }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343 }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599 }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207 }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155 }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884 }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, -] - [[package]] name = "win32-setctime" version = "1.2.0" @@ -4353,30 +4263,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542 }, ] -[[package]] -name = "yfinance" -version = "0.2.66" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "curl-cffi" }, - { name = "frozendict" }, - { name = "multitasking" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pandas" }, - { name = "peewee" }, - { name = "platformdirs" }, - { name = "protobuf" }, - { name = "pytz" }, - { name = "requests" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/73/50450b9906c5137d2d02fde6f7360865366c72baea1f8d0550cc990829ce/yfinance-0.2.66.tar.gz", hash = "sha256:fae354cc1649109444b2c84194724afcc52c2a7799551ce44c739424ded6af9c", size = 132820 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/bf/7c0c89ff8ba53592b9cb5157f70e90d8bbb04d60094fc4f10035e158b981/yfinance-0.2.66-py2.py3-none-any.whl", hash = "sha256:511a1a40a687f277aae3a02543009a8aeaa292fce5509671f58915078aebb5c7", size = 123427 }, -] - [[package]] name = "zipp" version = "3.23.0" From d0176b74e3db469c79da5a55b1fcb220009cb646 Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Tue, 9 Dec 2025 22:26:15 +0100 Subject: [PATCH 02/11] Persist scraped news and refresh channel reporting --- pyproject.toml | 2 + requirements.txt | 2 + .../agents/channel_report_agent.py | 160 ++++++++++++++-- .../config/settings.py | 1 + .../reporting/__init__.py | 3 +- .../reporting/pdf_reporter.py | 171 ++++++++++++++++- .../repositories/__init__.py | 3 + .../repositories/news_repository.py | 172 ++++++++++++++++++ .../services/channel_stream_service.py | 17 +- .../services/local_scraping_service.py | 44 ++++- .../visualization/charts.py | 20 +- tests/unit/test_channels.py | 20 ++ tests/unit/test_news_repository.py | 45 +++++ tradegraph.duckdb | Bin 1585152 -> 1585152 bytes uv.lock | 157 ++++++++++++++++ 15 files changed, 789 insertions(+), 28 deletions(-) create mode 100644 src/tradegraph_financial_advisor/repositories/__init__.py create mode 100644 src/tradegraph_financial_advisor/repositories/news_repository.py create mode 100644 tests/unit/test_news_repository.py diff --git a/pyproject.toml b/pyproject.toml index b647f71..073973d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ dependencies = [ "alpha-vantage>=2.3.0", "python-dateutil>=2.8.0", "plotly>=5.15.0", + "kaleido>=0.2.1", + "duckdb>=0.10.0", "fastapi>=0.118.0", "reportlab>=4.0.0", "uvicorn>=0.30.0", diff --git a/requirements.txt b/requirements.txt index 4a398b8..f1e231c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,5 +19,7 @@ pytest-asyncio>=0.21.0 pytest-cov>=4.1.0 python-dateutil>=2.8.0 plotly>=5.15.0 +kaleido>=0.2.1 +duckdb>=0.10.0 reportlab>=4.0.0 uvicorn>=0.30.0 diff --git a/src/tradegraph_financial_advisor/agents/channel_report_agent.py b/src/tradegraph_financial_advisor/agents/channel_report_agent.py index 03ac33c..4bff574 100644 --- a/src/tradegraph_financial_advisor/agents/channel_report_agent.py +++ b/src/tradegraph_financial_advisor/agents/channel_report_agent.py @@ -39,7 +39,9 @@ def __init__( ) async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: - channel_payloads = input_data.get("channel_payloads", {}) + channel_payloads = self._filter_channel_payloads( + input_data.get("channel_payloads", {}) + ) price_trends = input_data.get("price_trends", {}) recommendations = input_data.get("recommendations", []) @@ -54,14 +56,18 @@ async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: prompt_payload = { "channel_payloads": channel_payloads, "price_trends": price_trends, - "recommendations": recommendations, + "recommendation_snapshot": self._summarize_recommendations( + recommendations + ), } prompt = ( - "You are TradeGraph's senior analyst. Combine the multichannel news feeds, " - "risk context, and trend data below into a concise JSON summary with " - "keys: news_takeaways (list of strings), risk_assessment (string), " - "buy_or_sell_view (string), trend_commentary (string), key_stats (object), " - "and summary_text (string)." + "You are TradeGraph's senior portfolio strategist. Blend the curated news " + "feeds, short-term price action, and active recommendations into guidance " + "for an informed investor. Respond in JSON with keys: summary_text (2 " + "advisor-style paragraphs), advisor_memo (actionable paragraph), " + "news_takeaways (list of 3 strings), guidance_points (list of next " + "actions), risk_assessment, buy_or_sell_view, trend_commentary, " + "price_action_notes (list), and key_stats (object describing counts)." ) response = await self.llm.ainvoke( [HumanMessage(content=f"{prompt}\nINPUT:\n{json.dumps(prompt_payload)[:6000]}")] @@ -89,22 +95,41 @@ def _build_fallback_summary( f"{payload.get('title', channel_id)} highlights: {titles}" ) + key_stats = self._build_key_stats(channel_payloads, recommendations) + risk_counts: Dict[str, int] = {} for rec in recommendations: risk = rec.get("risk_level", "unknown") risk_counts[risk] = risk_counts.get(risk, 0) + 1 buy_view = "hold" - buy_votes = sum(1 for rec in recommendations if "buy" in str(rec.get("recommendation", "")).lower()) - sell_votes = sum(1 for rec in recommendations if "sell" in str(rec.get("recommendation", "")).lower()) + buy_votes = sum( + 1 + for rec in recommendations + if "buy" in str(rec.get("recommendation", "")).lower() + ) + sell_votes = sum( + 1 + for rec in recommendations + if "sell" in str(rec.get("recommendation", "")).lower() + ) if buy_votes > sell_votes: buy_view = "buy" elif sell_votes > buy_votes: buy_view = "reduce" trend_commentary = self._summarize_trends(price_trends) - summary_text = generate_summary(" ".join(news_takeaways)) or ( - "Latest headlines aggregated across equity, crypto, and free news agencies." + price_action_notes = self._build_price_notes(price_trends) + guidance_points = self._build_guidance_points(recommendations, price_action_notes) + + narrative_seed = " ".join(news_takeaways[:3]) or "Mixed market color." + summary_text = ( + generate_summary(narrative_seed) + or "Fresh headlines suggest a balanced tape across equities and crypto." + ) + advisor_memo = ( + f"My read: {buy_view.upper()} bias while monitoring {risk_counts or {'unknown': 0}}. " + f"Trend check: {trend_commentary}." ) return { @@ -112,11 +137,116 @@ def _build_fallback_summary( "risk_assessment": f"Risk mix: {risk_counts or {'unknown': 0}}", "buy_or_sell_view": buy_view, "trend_commentary": trend_commentary, - "key_stats": { - "recommendation_count": len(recommendations), - "channels": list(channel_payloads.keys()), - }, + "key_stats": key_stats, "summary_text": summary_text, + "advisor_memo": advisor_memo, + "price_action_notes": price_action_notes, + "guidance_points": guidance_points, + } + + def _filter_channel_payloads( + self, channel_payloads: Dict[str, Any] + ) -> Dict[str, Any]: + return { + channel_id: payload + for channel_id, payload in channel_payloads.items() + if channel_id != "open_source_agencies" + } + + def _build_key_stats( + self, + channel_payloads: Dict[str, Any], + recommendations: List[Dict[str, Any]], + ) -> Dict[str, Any]: + total_items = sum(len(payload.get("items", [])) for payload in channel_payloads.values()) + covered_symbols = { + symbol + for payload in channel_payloads.values() + for item in payload.get("items", []) + for symbol in item.get("matched_symbols", []) + if symbol + } + return { + "channel_count": len(channel_payloads), + "headline_count": total_items, + "recommendation_count": len(recommendations), + "covered_symbols": sorted(covered_symbols), + } + + def _build_price_notes(self, price_trends: Dict[str, Any]) -> List[str]: + notes: List[str] = [] + for symbol, payload in price_trends.items(): + trends = payload.get("trends", {}) + day = trends.get("last_day", {}).get("percent_change") + week = trends.get("last_week", {}).get("percent_change") + hour = trends.get("last_hour", {}).get("percent_change") + pieces = [] + if week is not None: + pieces.append(f"{week:+.1f}% weekly") + if day is not None: + pieces.append(f"{day:+.1f}% daily") + if hour is not None: + pieces.append(f"{hour:+.1f}% hourly") + if pieces: + notes.append(f"{symbol}: {' / '.join(pieces)} post-close move") + return notes + + def _build_guidance_points( + self, + recommendations: List[Dict[str, Any]], + price_notes: List[str], + ) -> List[str]: + guidance: List[str] = [] + for rec in recommendations[:3]: + symbol = rec.get("symbol", "") + rec_text = rec.get("recommendation", "hold").replace("_", " ") + allocation = rec.get("recommended_allocation") + allocation_text = ( + f"targeting {allocation:.1%} weight" + if isinstance(allocation, (int, float)) + else "" + ) + note = rec.get("analyst_notes") or ", ".join(rec.get("key_factors", [])[:2]) + clause = f"{symbol}: {rec_text.title()} {allocation_text}".strip() + if note: + clause = f"{clause} — {note}" + guidance.append(clause) + + if price_notes: + guidance.append(f"Monitor price tape: {price_notes[0]}") + return guidance + + def _summarize_recommendations( + self, recommendations: List[Dict[str, Any]] + ) -> Dict[str, Any]: + if not recommendations: + return {"total": 0} + + counts: Dict[str, int] = {} + top_symbols: List[str] = [] + highest_conf = sorted( + recommendations, + key=lambda rec: rec.get("confidence_score", 0), + reverse=True, + )[:3] + for rec in recommendations: + name = str(rec.get("recommendation", "unknown")).lower() + counts[name] = counts.get(name, 0) + 1 + if rec.get("symbol"): + top_symbols.append(rec["symbol"]) + + return { + "total": len(recommendations), + "counts": counts, + "top_conviction": [ + { + "symbol": rec.get("symbol"), + "confidence_score": rec.get("confidence_score"), + "recommendation": rec.get("recommendation"), + } + for rec in highest_conf + ], + "symbols": top_symbols, } def _summarize_trends(self, price_trends: Dict[str, Any]) -> str: diff --git a/src/tradegraph_financial_advisor/config/settings.py b/src/tradegraph_financial_advisor/config/settings.py index b586145..1f3c0fa 100644 --- a/src/tradegraph_financial_advisor/config/settings.py +++ b/src/tradegraph_financial_advisor/config/settings.py @@ -30,6 +30,7 @@ class Settings(BaseSettings): ) analysis_depth: str = Field("detailed", env="ANALYSIS_DEPTH") default_portfolio_size: float = Field(100000.0, env="DEFAULT_PORTFOLIO_SIZE") + news_db_path: str = Field("tradegraph.duckdb", env="NEWS_DB_PATH") model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"} diff --git a/src/tradegraph_financial_advisor/reporting/__init__.py b/src/tradegraph_financial_advisor/reporting/__init__.py index ca30cf1..7c9c915 100644 --- a/src/tradegraph_financial_advisor/reporting/__init__.py +++ b/src/tradegraph_financial_advisor/reporting/__init__.py @@ -1,5 +1,6 @@ """Reporting utilities for TradeGraph.""" from .pdf_reporter import ChannelPDFReportWriter +from .multi_asset_reporter import MultiAssetPDFReportWriter -__all__ = ["ChannelPDFReportWriter"] +__all__ = ["ChannelPDFReportWriter", "MultiAssetPDFReportWriter"] diff --git a/src/tradegraph_financial_advisor/reporting/pdf_reporter.py b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py index 1156c5e..a57cd64 100644 --- a/src/tradegraph_financial_advisor/reporting/pdf_reporter.py +++ b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py @@ -9,6 +9,7 @@ from reportlab.lib.pagesizes import LETTER from reportlab.lib.units import inch +from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas @@ -28,6 +29,9 @@ def build_report( price_trends: Dict[str, Any], recommendations: List[Dict[str, Any]], symbols: List[str], + portfolio_recommendation: Optional[Dict[str, Any]] = None, + analysis_summary: Optional[Dict[str, Any]] = None, + allocation_chart_path: Optional[str] = None, output_path: Optional[str] = None, ) -> str: os.makedirs("results", exist_ok=True) @@ -47,12 +51,42 @@ def build_report( f"Symbols: {', '.join(symbols)} | Generated {datetime.now():%Y-%m-%d %H:%M UTC}", ) + cursor_y = self._draw_portfolio_overview( + doc, + cursor_y, + analysis_summary or {}, + portfolio_recommendation or {}, + allocation_chart_path, + ) + cursor_y = self._draw_section( doc, cursor_y, "Executive Summary", summary_payload.get("summary_text", "") ) - news_text = "\n".join(summary_payload.get("news_takeaways", [])) - cursor_y = self._draw_section(doc, cursor_y, "News Highlights", news_text) + cursor_y = self._draw_section( + doc, cursor_y, "Desk Memo", summary_payload.get("advisor_memo", "") + ) + + cursor_y = self._draw_bullet_section( + doc, + cursor_y, + "News Highlights", + summary_payload.get("news_takeaways", []), + ) + + cursor_y = self._draw_bullet_section( + doc, + cursor_y, + "Price & Trend Signals", + summary_payload.get("price_action_notes", []), + ) + + cursor_y = self._draw_bullet_section( + doc, + cursor_y, + "Actionable Guidance", + summary_payload.get("guidance_points", []), + ) cursor_y = self._draw_section( doc, @@ -63,6 +97,8 @@ def build_report( f"Trend Notes: {summary_payload.get('trend_commentary', 'n/a')}", ) + cursor_y = self._draw_key_stats(doc, cursor_y, summary_payload.get("key_stats", {})) + cursor_y = self._draw_channel_breakdown(doc, cursor_y, channel_payloads) cursor_y = self._draw_recommendations(doc, cursor_y, recommendations) cursor_y = self._draw_trends(doc, cursor_y, price_trends) @@ -94,6 +130,52 @@ def _draw_section( cursor_y -= self.line_height return cursor_y - 6 + def _draw_bullet_section( + self, + doc: canvas.Canvas, + cursor_y: float, + title: str, + items: List[str], + ) -> float: + if not items: + return cursor_y + cursor_y = self._ensure_space(doc, cursor_y, min_height=80) + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, title) + cursor_y -= 18 + doc.setFont("Helvetica", 11) + for item in items: + for line in self._wrap_text(item, 92): + doc.drawString(self.margin + 10, cursor_y, f"• {line}") + cursor_y -= self.line_height + return cursor_y - 6 + + def _draw_key_stats( + self, + doc: canvas.Canvas, + cursor_y: float, + stats: Dict[str, Any], + ) -> float: + if not stats: + return cursor_y + cursor_y = self._ensure_space(doc, cursor_y, min_height=70) + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, "Monitoring Stats") + cursor_y -= 18 + doc.setFont("Helvetica", 11) + lines = [ + f"Channels monitored: {stats.get('channel_count', 0)}", + f"Headlines ingested: {stats.get('headline_count', 0)}", + f"Recommendations referenced: {stats.get('recommendation_count', 0)}", + ] + covered = stats.get("covered_symbols") + if covered: + lines.append(f"Symbols highlighted: {', '.join(covered)}") + for line in lines: + doc.drawString(self.margin, cursor_y, line) + cursor_y -= self.line_height + return cursor_y - 6 + def _draw_channel_breakdown( self, doc: canvas.Canvas, cursor_y: float, channels: Dict[str, Any] ) -> float: @@ -184,6 +266,82 @@ def _draw_trends( cursor_y -= self.line_height return cursor_y + def _draw_portfolio_overview( + self, + doc: canvas.Canvas, + cursor_y: float, + analysis_summary: Dict[str, Any], + portfolio_recommendation: Dict[str, Any], + allocation_chart_path: Optional[str], + ) -> float: + cursor_y = self._ensure_space(doc, cursor_y, min_height=180) + section_top = cursor_y + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, "Portfolio Overview") + cursor_y -= 18 + doc.setFont("Helvetica", 11) + + portfolio_size = analysis_summary.get("portfolio_size") + risk_tolerance = analysis_summary.get("risk_tolerance", "-") + time_horizon = analysis_summary.get("time_horizon", "-") + symbols_line = ", ".join(analysis_summary.get("symbols_analyzed", [])) + text_lines = [ + f"Portfolio Size: {self._format_currency(portfolio_size)}", + f"Risk Tolerance: {risk_tolerance.title() if isinstance(risk_tolerance, str) else risk_tolerance}", + f"Time Horizon: {time_horizon.replace('_', ' ').title() if isinstance(time_horizon, str) else time_horizon}", + ] + if symbols_line: + text_lines.append(f"Focus Symbols: {symbols_line}") + + total_conf = portfolio_recommendation.get("total_confidence") + diversification = portfolio_recommendation.get("diversification_score") + expected_return = portfolio_recommendation.get("expected_return") + expected_vol = portfolio_recommendation.get("expected_volatility") + overall_risk = portfolio_recommendation.get("overall_risk_level") + if isinstance(total_conf, (int, float)): + text_lines.append(f"Portfolio Confidence: {total_conf:.0%}") + if isinstance(diversification, (int, float)): + text_lines.append(f"Diversification Score: {diversification:.0%}") + if isinstance(expected_return, (int, float)): + text_lines.append(f"Expected Return: {expected_return:.1%}") + elif expected_return: + text_lines.append(f"Expected Return: {expected_return}") + if isinstance(expected_vol, (int, float)): + text_lines.append(f"Expected Volatility: {expected_vol:.1%}") + elif expected_vol: + text_lines.append(f"Expected Volatility: {expected_vol}") + if overall_risk: + text_lines.append(f"Overall Risk: {str(overall_risk).title()}") + + for line in text_lines: + doc.drawString(self.margin, cursor_y, line) + cursor_y -= self.line_height + + cursor_after_text = cursor_y - 6 + + chart_bottom = cursor_after_text + if allocation_chart_path and os.path.exists(allocation_chart_path): + try: + image = ImageReader(allocation_chart_path) + chart_width = 2.8 * inch + chart_height = 2.8 * inch + chart_x = self.page_width - self.margin - chart_width + chart_y = section_top - chart_height + doc.drawImage( + image, + chart_x, + chart_y, + width=chart_width, + height=chart_height, + preserveAspectRatio=True, + mask="auto", + ) + chart_bottom = min(chart_y - 10, cursor_after_text) + except Exception: + chart_bottom = cursor_after_text + + return min(cursor_after_text, chart_bottom) + def _ensure_space( self, doc: canvas.Canvas, cursor_y: float, *, min_height: float ) -> float: @@ -206,5 +364,14 @@ def _format_pct(trend: Optional[Dict[str, Any]]) -> str: return " - " return f"{percent:+.1f}%" + @staticmethod + def _format_currency(value: Optional[float]) -> str: + if value is None: + return "n/a" + try: + return f"${float(value):,.0f}" + except (TypeError, ValueError): + return str(value) + __all__ = ["ChannelPDFReportWriter"] diff --git a/src/tradegraph_financial_advisor/repositories/__init__.py b/src/tradegraph_financial_advisor/repositories/__init__.py new file mode 100644 index 0000000..f20f5a3 --- /dev/null +++ b/src/tradegraph_financial_advisor/repositories/__init__.py @@ -0,0 +1,3 @@ +from .news_repository import NewsRepository + +__all__ = ["NewsRepository"] diff --git a/src/tradegraph_financial_advisor/repositories/news_repository.py b/src/tradegraph_financial_advisor/repositories/news_repository.py new file mode 100644 index 0000000..03d665f --- /dev/null +++ b/src/tradegraph_financial_advisor/repositories/news_repository.py @@ -0,0 +1,172 @@ +"""DuckDB-backed persistence for scraped news articles.""" + +from __future__ import annotations + +import threading +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Union + +import duckdb +from dateutil import parser as date_parser +from loguru import logger +from pydantic import ValidationError + +from ..config.settings import settings +from ..models.financial_data import NewsArticle + + +class NewsRepository: + """Simple repository that stores news articles inside DuckDB.""" + + def __init__(self, db_path: Optional[Union[str, Path]] = None) -> None: + path = Path(db_path or settings.news_db_path).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + path.parent.mkdir(parents=True, exist_ok=True) + self.db_path = path + self._schema_lock = threading.Lock() + self._write_lock = threading.Lock() + self._ensure_schema() + + def _ensure_schema(self) -> None: + with self._schema_lock: + with duckdb.connect(str(self.db_path)) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS news_articles ( + symbol TEXT, + title TEXT, + url TEXT, + summary TEXT, + content TEXT, + source TEXT, + published_at TIMESTAMP, + scraped_at TIMESTAMP, + symbols TEXT, + sentiment TEXT, + impact_score DOUBLE + ); + """ + ) + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_news_articles_symbol_title_url + ON news_articles(symbol, title, url); + """ + ) + + def record_articles( + self, articles: Sequence[Union[NewsArticle, Dict[str, Any]]] + ) -> int: + rows: List[tuple] = [] + scraped_at = datetime.utcnow() + for article in articles: + model = self._coerce_article(article) + if not model: + continue + primary_symbol = model.symbols[0] if model.symbols else None + rows.append( + ( + primary_symbol, + model.title.strip(), + model.url, + (model.summary or "").strip() or None, + model.content, + model.source, + self._normalize_datetime(model.published_at), + scraped_at, + ",".join(model.symbols), + self._normalize_sentiment(model.sentiment), + model.impact_score, + ) + ) + + if not rows: + return 0 + + insert_sql = """ + INSERT INTO news_articles ( + symbol, + title, + url, + summary, + content, + source, + published_at, + scraped_at, + symbols, + sentiment, + impact_score + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, title, url) DO UPDATE SET + summary=excluded.summary, + content=excluded.content, + source=excluded.source, + published_at=excluded.published_at, + scraped_at=excluded.scraped_at, + symbols=excluded.symbols, + sentiment=excluded.sentiment, + impact_score=excluded.impact_score; + """ + + with self._write_lock: + try: + with duckdb.connect(str(self.db_path)) as conn: + conn.executemany(insert_sql, rows) + except Exception as exc: # pragma: no cover - disk/config issues + logger.warning(f"Failed to persist news articles: {exc}") + return 0 + + return len(rows) + + def fetch_recent_articles(self, limit: int = 50) -> List[Dict[str, Any]]: + query = ( + "SELECT symbol, title, url, summary, source, published_at, scraped_at, symbols, sentiment, impact_score " + "FROM news_articles ORDER BY COALESCE(published_at, scraped_at) DESC LIMIT ?" + ) + with duckdb.connect(str(self.db_path)) as conn: + rows = conn.execute(query, [limit]).fetchall() + columns = [desc[0] for desc in conn.description] + + return [dict(zip(columns, row)) for row in rows] + + def _coerce_article( + self, article: Union[NewsArticle, Dict[str, Any], None] + ) -> Optional[NewsArticle]: + if article is None: + return None + if isinstance(article, NewsArticle): + return article + if isinstance(article, dict): + try: + return NewsArticle(**article) + except ValidationError as exc: + logger.warning(f"Invalid news article payload skipped: {exc}") + return None + logger.warning("Unsupported news article type {}", type(article)) + return None + + @staticmethod + def _normalize_datetime(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + return value + if not value: + return None + if isinstance(value, str): + try: + return date_parser.parse(value) + except (ValueError, TypeError): + return None + return None + + @staticmethod + def _normalize_sentiment(value: Any) -> Optional[str]: + if value is None: + return None + if hasattr(value, "value"): + return str(value.value) + return str(value) + + +__all__ = ["NewsRepository"] diff --git a/src/tradegraph_financial_advisor/services/channel_stream_service.py b/src/tradegraph_financial_advisor/services/channel_stream_service.py index b828d7f..5f388f5 100644 --- a/src/tradegraph_financial_advisor/services/channel_stream_service.py +++ b/src/tradegraph_financial_advisor/services/channel_stream_service.py @@ -203,10 +203,12 @@ def __init__( max_items_per_source: int = 5, max_items_per_channel: int = 25, price_service: Optional[PriceTrendService] = None, + include_open_agencies: bool = False, ) -> None: self.max_items_per_source = max_items_per_source self.max_items_per_channel = max_items_per_channel self.price_service = price_service or PriceTrendService() + self.include_open_agencies = include_open_agencies self._session_lock = asyncio.Lock() self._session: Optional[aiohttp.ClientSession] = None @@ -222,19 +224,19 @@ async def _get_session(self) -> aiohttp.ClientSession: return self._session def describe_channels(self) -> List[Dict[str, Any]]: - return [definition.metadata() for definition in CHANNEL_REGISTRY.values()] + return [CHANNEL_REGISTRY[channel].metadata() for channel in self._iter_channels()] async def collect_all_channels( self, symbols: Optional[Sequence[str]] = None ) -> Dict[str, Any]: tasks = [ self.fetch_channel_payload(channel.value, symbols) - for channel in ChannelType + for channel in self._iter_channels() ] payloads = await asyncio.gather(*tasks, return_exceptions=True) result: Dict[str, Any] = {} - for channel, payload in zip(ChannelType, payloads): + for channel, payload in zip(self._iter_channels(), payloads): if isinstance(payload, Exception): logger.warning( f"Failed to collect channel {channel.value}: {payload}" @@ -243,6 +245,15 @@ async def collect_all_channels( result[channel.value] = payload return result + def _iter_channels(self): + for channel in ChannelType: + if ( + not self.include_open_agencies + and channel == ChannelType.OPEN_SOURCE_AGENCIES + ): + continue + yield channel + async def fetch_channel_payload( self, channel_id: str, symbols: Optional[Sequence[str]] = None ) -> Dict[str, Any]: diff --git a/src/tradegraph_financial_advisor/services/local_scraping_service.py b/src/tradegraph_financial_advisor/services/local_scraping_service.py index 2ba7974..6141740 100644 --- a/src/tradegraph_financial_advisor/services/local_scraping_service.py +++ b/src/tradegraph_financial_advisor/services/local_scraping_service.py @@ -3,7 +3,8 @@ from __future__ import annotations import asyncio -from typing import Any, Dict, List, Sequence +from typing import Any, Dict, List, Sequence, Optional +from urllib.parse import urlparse from loguru import logger from ddgs import DDGS @@ -11,11 +12,23 @@ from ..models.financial_data import NewsArticle from ..utils.helpers import generate_summary +from ..repositories import NewsRepository class LocalScrapingService: - def __init__(self): + OPEN_AGENCY_DOMAINS = { + "theguardian.com", + "guardian.co.uk", + "bbc.co.uk", + "bbci.co.uk", + "aljazeera.com", + "npr.org", + "financialexpress.com", + } + + def __init__(self, news_repository: Optional[NewsRepository] = None): self.crawler = AsyncWebCrawler() + self.news_repository = news_repository or self._build_repository() async def search_and_scrape_news( self, symbols: Sequence[str], max_articles_per_symbol: int = 5 @@ -26,6 +39,8 @@ async def search_and_scrape_news( if not symbols: return all_articles + new_articles: List[NewsArticle] = [] + with DDGS() as ddgs: for symbol in symbols: query = f"{symbol} stock news" @@ -47,6 +62,12 @@ async def search_and_scrape_news( url = result.get("url") or result.get("href") if not url: continue + netloc = urlparse(url).netloc.lower() + if any( + netloc.endswith(domain) + for domain in self.OPEN_AGENCY_DOMAINS + ): + continue scraped_data = await self.crawler.arun(url) if not scraped_data or not scraped_data.markdown: continue @@ -60,9 +81,12 @@ async def search_and_scrape_news( symbols=[symbol], ) all_articles.append(article) + new_articles.append(article) except Exception as scrape_exc: logger.warning(f"Failed to scrape article {result.get('url')}: {scrape_exc}") + if new_articles: + await self._persist_articles(new_articles) return all_articles async def search_and_scrape_financial_reports( @@ -112,6 +136,7 @@ async def start(self): async def stop(self): logger.info("LocalScrapingService stopped.") await self.crawler.close() + self.news_repository = None async def health_check(self) -> bool: # For now, we assume the service is healthy if it can be instantiated. @@ -136,3 +161,18 @@ def _runner() -> List[Dict[str, Any]]: return list(ddgs.text(query, **kwargs)) return await asyncio.to_thread(_runner) + + def _build_repository(self) -> Optional[NewsRepository]: + try: + return NewsRepository() + except Exception as exc: + logger.warning(f"News repository initialization failed: {exc}") + return None + + async def _persist_articles(self, articles: List[NewsArticle]) -> None: + if not self.news_repository or not articles: + return + try: + await asyncio.to_thread(self.news_repository.record_articles, articles) + except Exception as exc: + logger.warning(f"Failed to write scraped news to DuckDB: {exc}") diff --git a/src/tradegraph_financial_advisor/visualization/charts.py b/src/tradegraph_financial_advisor/visualization/charts.py index c75fd53..e22794a 100644 --- a/src/tradegraph_financial_advisor/visualization/charts.py +++ b/src/tradegraph_financial_advisor/visualization/charts.py @@ -1,8 +1,10 @@ +from pathlib import Path + import plotly.graph_objects as go def create_portfolio_allocation_chart( - recommendations, output_path="portfolio_allocation.html" + recommendations, output_path="results/portfolio_allocation.png" ): """ Creates a pie chart showing portfolio allocation @@ -43,8 +45,16 @@ def create_portfolio_allocation_chart( fig.update_layout(title="Portfolio Allocation Recommendation", showlegend=True) - # Save to HTML file - fig.write_html(output_path) - print(f"Chart saved to: {output_path}") + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + + try: + fig.write_image(str(path)) + except ValueError as exc: + raise RuntimeError( + "Plotly static image export requires the kaleido package." + ) from exc + + print(f"Chart saved to: {path}") - return output_path + return str(path) diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py index 004f899..a18970f 100644 --- a/tests/unit/test_channels.py +++ b/tests/unit/test_channels.py @@ -43,6 +43,9 @@ async def test_channel_report_agent_fallback( ) assert summary["news_takeaways"] assert "summary_text" in summary + assert summary.get("advisor_memo") + assert summary.get("guidance_points") + assert summary.get("key_stats", {}).get("channel_count") == 1 def test_pdf_report_writer(tmp_path, sample_channel_streams, sample_price_trends, sample_recommendations): @@ -54,6 +57,10 @@ def test_pdf_report_writer(tmp_path, sample_channel_streams, sample_price_trends "risk_assessment": "medium", "buy_or_sell_view": "buy", "trend_commentary": "AAPL: +5% YoY", + "advisor_memo": "Maintain constructive stance with risk controls.", + "price_action_notes": ["AAPL: +2.5% weekly"], + "guidance_points": ["Add MSFT on earnings strength"], + "key_stats": {"channel_count": 1, "headline_count": 2, "recommendation_count": 2}, } pdf_path = writer.build_report( summary_payload=summary_payload, @@ -61,6 +68,19 @@ def test_pdf_report_writer(tmp_path, sample_channel_streams, sample_price_trends price_trends=sample_price_trends, recommendations=sample_recommendations, symbols=["AAPL"], + analysis_summary={ + "portfolio_size": 100000, + "risk_tolerance": "medium", + "time_horizon": "medium_term", + "symbols_analyzed": ["AAPL"], + }, + portfolio_recommendation={ + "total_confidence": 0.8, + "diversification_score": 0.7, + "expected_return": 0.12, + "expected_volatility": 0.2, + "overall_risk_level": "medium", + }, output_path=str(output_file), ) assert os.path.exists(pdf_path) diff --git a/tests/unit/test_news_repository.py b/tests/unit/test_news_repository.py new file mode 100644 index 0000000..a8c79c1 --- /dev/null +++ b/tests/unit/test_news_repository.py @@ -0,0 +1,45 @@ +from datetime import datetime, timezone + +import pytest + +from tradegraph_financial_advisor.models.financial_data import NewsArticle +from tradegraph_financial_advisor.repositories import NewsRepository + + +def _sample_article(**overrides): + base = { + "title": "Sample headline", + "url": "https://example.com/story", + "content": "Detailed article body", + "summary": "Summary", + "source": "ExampleWire", + "published_at": datetime(2024, 1, 1, tzinfo=timezone.utc), + "symbols": ["AAPL"], + } + base.update(overrides) + return NewsArticle(**base) + + +def test_news_repository_upsert(tmp_path): + repo = NewsRepository(db_path=tmp_path / "news.duckdb") + + first = _sample_article() + repo.record_articles([first]) + + # Update summary to ensure UPSERT semantics + updated = _sample_article(summary="Updated summary") + repo.record_articles([updated]) + + rows = repo.fetch_recent_articles(limit=10) + assert len(rows) == 1 + assert rows[0]["summary"] == "Updated summary" + assert rows[0]["symbol"] == "AAPL" + + +def test_news_repository_handles_invalid_articles(tmp_path): + repo = NewsRepository(db_path=tmp_path / "news.duckdb") + + inserted = repo.record_articles([None, {"title": "missing fields"}]) + + assert inserted == 0 + assert repo.fetch_recent_articles(limit=10) == [] diff --git a/tradegraph.duckdb b/tradegraph.duckdb index 8c2725495a6faf9e7a93392b11ea5aeae8d72773..e006d2ae0326a9251a4a98917a5bc046f012e486 100644 GIT binary patch delta 2647 zcmcImYiv|S6rS0;uXbB@`=n(l-Vk}1pbj=9NQ0KNOHvZr0Bu4HVYlqPrK^3>Z7rzr zE{VBBqiJ^!KlF#D#Xn%8p#-D-#r2%IyPLADN=-az zcW2I=^L^)>nK`qGxkzFz@}4YzvHkH&wTHg{-Ax`4Uv~D0DF^2K-aqP&K2u*aa@I`E z?xbdD>+ogj;-AkxT~XSnj>N;AgF~@sj~bsM3i0v7Y>nm`iVgLsD$Hvw>nI`JgjkkB zq~XDyB}z|xxVN`+@Wj%EXn)_33V($#?s)(3V00;b$-wZDo>;tF)s%3jaI09%UL21O zb`Ge^u6g1odXMz?#Fuat#UX-N?{Z<3#CiuhqeJ0%w0}?pvpUDdU;nv$Pl-yrmeoaw z$e$`?eM~tXexP7sn>8$|6^7z;5w}8Wuv82`Hsi+`%HO!Tjqha(u1b}ht~2*+{fKHo zm7FX?cnRPwD@vRS0S;#uarSn``eKAUPm;7)UPhYshjz7x`hJ1!;xc$Qk$0fH{#oN&kcI3X{D>X<2$=lr|J2S8(I~Y+Ws=B6b?8 zl0;_e*DDDS&0fou*32aLI*e1#(oGoiN%x8Y(Cwxxt+mnT96Xc!?0?Rdk6-LakA!K5 z1Bp)*iFnuBIo*NWvgu|O_E-KKL404)Dy-BtFHg<7`K@>B@+Z$s-6@y2)#NibG`-}c ztIe`|r=7-^%{20UCMg**qYR35-60sw)c!dqKzP%UQTnH!*6{I;ru1PaZFKSQv50Wv z*Sqb3vCcaC7;j}X0WdMj!;}Y5lm@IpWie>v_{z)`^ zaSRQ(KOO<&3~zjE4DmM7T0W7SF@z05ivb3N10)*5QN-rXvB2Sx`MG94-*MMx@)r&{ zjm9nXIO3br0~2l|!KjC>F^(}BHfKk?ULJ|;wvjW*jFG{3FnN&}VgfN_VRLeAn5Dra z0I%X}J0u!}iZ$4#7?$wCM6!Oi0;`jQ&8>)U3u=R$!VM_0RQZC5;$;gcjO;KG94eH9 zvbXYsu^BgA3*l_F;buqIzAaY@hcp!GhLbzb1vPk3ZU^v@!Apb!iW~5(CqW2hC!7nWP^=@| ziHO}E)7nA0;D$Lg6x85;0+PO%*}m)P@6az1y1`NT|cCp_|(OBd$=3UgI~V%%S#cn5YAeiWFrKIB3= z18#G`NNBXC$d!GO9LBgtt7xNn|EZ$VQ_yqZAA65`8TS7${!E7qA$ hXC*o-)!8bY`E?f1S((ntbylIXN}W~dtXgNQ{{n$f6y5*; delta 1765 zcmd^9&1+m$6u;-qM`n^tXObpO`k|AelDg^%LlGCn7`l?!7K$he&O;mc8H=G zs9d{i3I|~a*b$NVCE1)i3 z*{R7trhW*f_40s&YW+-AjZZez`V49uc96ZU@y?${icrRkOaE1_vni^0%H<#DyS7X5 zRF$o&v3zQA9c?AW#%wy$mf}3Tw7sIeR7Lx>mD#v6TIVJ z$igdNF`1O-$hayy>-r~ugbGl8%`oDdMQ7VaR4pURJUT#KuO}6*=2ZSbTE8_1JKNFv z^Bx@TS{g|}PTe^2k#}tLQlQJTGh*Cq%f6|hUC90`_;uLz#=}W1x$-SJ>>`1xgir)Jv>`=uo6LDS^-U%}R zJ7;8l?6A$8n|r~?txnY3JXH-Io&SjrrL-m#LebE5_J-49>bygSr}Z-7Xynb&h7Xba z>k}`wD{sebCP_-TUN8fozj^~k4X#KSGt!objmTe%&cn7 zdjpzVzdn)h>OVhD(!f5m%MkYY2Xrp)bt?` z@si`>whqVQP=}*JU$|)@JSe=&Xi{kiKdN!PJ@1UBW1$(-ON+2I9(2_L-_1MAS%x+2 z&n&{DtY2FM{Xf0asQ&yCwEW?3I|2xJ6=CVoqY8#D0leB@Rd& Jl( Date: Tue, 9 Dec 2025 22:26:46 +0100 Subject: [PATCH 03/11] News persistence + reporting refresh --- .../agents/__init__.py | 15 + .../agents/multi_asset_allocation_agent.py | 314 ++++++++++++++++++ src/tradegraph_financial_advisor/main.py | 124 ++++++- .../reporting/multi_asset_reporter.py | 162 +++++++++ tests/unit/test_multi_asset_agent.py | 57 ++++ 5 files changed, 669 insertions(+), 3 deletions(-) create mode 100644 src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py create mode 100644 src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py create mode 100644 tests/unit/test_multi_asset_agent.py diff --git a/src/tradegraph_financial_advisor/agents/__init__.py b/src/tradegraph_financial_advisor/agents/__init__.py index e69de29..ea22f0c 100644 --- a/src/tradegraph_financial_advisor/agents/__init__.py +++ b/src/tradegraph_financial_advisor/agents/__init__.py @@ -0,0 +1,15 @@ +from .channel_report_agent import ChannelReportAgent +from .financial_agent import FinancialAnalysisAgent +from .news_agent import NewsReaderAgent +from .recommendation_engine import TradingRecommendationEngine +from .report_analysis_agent import ReportAnalysisAgent +from .multi_asset_allocation_agent import MultiAssetAllocationAgent + +__all__ = [ + "ChannelReportAgent", + "FinancialAnalysisAgent", + "NewsReaderAgent", + "TradingRecommendationEngine", + "ReportAnalysisAgent", + "MultiAssetAllocationAgent", +] diff --git a/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py b/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py new file mode 100644 index 0000000..af1bb46 --- /dev/null +++ b/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py @@ -0,0 +1,314 @@ +"""Agent that creates allocation plans across stocks, ETFs, and crypto for various horizons.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from loguru import logger + +from .base_agent import BaseAgent + + +@dataclass +class AllocationSuggestion: + asset_class: str + weight: float + rationale: str + sample_assets: List[Dict[str, str]] + + +HORIZON_LABELS = { + "1w": "1-Week", + "1m": "1-Month", + "1y": "1-Year", +} + + +STRATEGY_LIBRARY: Dict[str, Dict[str, Dict[str, AllocationSuggestion]]] = {} + + +def _build_strategy_library() -> Dict[str, Dict[str, Dict[str, AllocationSuggestion]]]: + asset_pool = { + "stocks": [ + {"symbol": "AAPL", "thesis": "Cash-rich mega-cap"}, + {"symbol": "MSFT", "thesis": "Enterprise AI exposure"}, + {"symbol": "NVDA", "thesis": "GPU leadership"}, + {"symbol": "AMZN", "thesis": "Cloud + retail"}, + ], + "etfs": [ + {"symbol": "VOO", "thesis": "S&P 500 core"}, + {"symbol": "QQQ", "thesis": "Large-cap growth"}, + {"symbol": "ARKK", "thesis": "High beta innovation"}, + {"symbol": "TLT", "thesis": "Long-duration bonds"}, + ], + "crypto": [ + {"symbol": "BTC", "thesis": "Digital gold"}, + {"symbol": "ETH", "thesis": "Smart contracts"}, + {"symbol": "SOL", "thesis": "High throughput L1"}, + ], + } + + def suggestion(asset_class: str, weight: float, rationale: str) -> AllocationSuggestion: + return AllocationSuggestion( + asset_class=asset_class, + weight=weight, + rationale=rationale, + sample_assets=asset_pool[asset_class][:2], + ) + + strategies = { + "growth": { + "1w": { + "stocks": suggestion( + "stocks", 0.35, "Stay liquid in quality mega caps while watching catalysts." + ), + "etfs": suggestion( + "etfs", 0.15, "Use QQQ/ARKK for beta exposure without security selection." + ), + "crypto": suggestion( + "crypto", 0.50, "Lean into BTC/ETH momentum for tactical upside." + ), + }, + "1m": { + "stocks": suggestion( + "stocks", 0.45, "Compound AI and cloud tailwinds via mega caps." + ), + "etfs": suggestion( + "etfs", 0.20, "Blend sector ETFs for diversification." + ), + "crypto": suggestion( + "crypto", 0.35, "Maintain crypto beta for asymmetric upside." + ), + }, + "1y": { + "stocks": suggestion( + "stocks", 0.5, "Core equity growth allocation with reinvestment." + ), + "etfs": suggestion( + "etfs", 0.25, "Add thematic ETFs to capture innovation baskets." + ), + "crypto": suggestion( + "crypto", 0.25, "Long-term conviction in BTC/ETH network effects." + ), + }, + }, + "balanced": { + "1w": { + "stocks": suggestion( + "stocks", 0.3, "Blend defensives with growth to dampen volatility." + ), + "etfs": suggestion( + "etfs", 0.4, "VOO/TLT core to keep drawdowns manageable." + ), + "crypto": suggestion( + "crypto", 0.3, "Measured crypto sleeve for opportunistic moves." + ), + }, + "1m": { + "stocks": suggestion( + "stocks", 0.4, "Add cyclicals selectively as macro visibility improves." + ), + "etfs": suggestion( + "etfs", 0.4, "Core passive ETFs to anchor risk." + ), + "crypto": suggestion( + "crypto", 0.2, "Keep crypto beta but size for volatility." + ), + }, + "1y": { + "stocks": suggestion( + "stocks", 0.45, "Dividend growers + quality compounders." + ), + "etfs": suggestion( + "etfs", 0.4, "Broad equity and bond ETFs for balance." + ), + "crypto": suggestion( + "crypto", 0.15, "Smaller crypto sleeve for optionality." + ), + }, + }, + "defensive": { + "1w": { + "stocks": suggestion( + "stocks", 0.25, "Prefer healthcare and staples for stability." + ), + "etfs": suggestion( + "etfs", 0.6, "High-quality bond and minimum-vol ETFs." + ), + "crypto": suggestion( + "crypto", 0.15, "Tiny crypto exposure to stay engaged." + ), + }, + "1m": { + "stocks": suggestion( + "stocks", 0.3, "Income-oriented equities." + ), + "etfs": suggestion( + "etfs", 0.55, "Blend IG bonds with broad equity ETFs." + ), + "crypto": suggestion( + "crypto", 0.15, "Cap risk but allow for upside." + ), + }, + "1y": { + "stocks": suggestion( + "stocks", 0.35, "Quality and value tilt." + ), + "etfs": suggestion( + "etfs", 0.5, "VOO/TLT core plus dividend ETFs." + ), + "crypto": suggestion( + "crypto", 0.15, "Long-dated call option sized exposure." + ), + }, + }, + "income": { + "1w": { + "stocks": suggestion( + "stocks", 0.35, "Dividend aristocrats for short-term distributions." + ), + "etfs": suggestion( + "etfs", 0.55, "Covered-call and bond ETFs for yield." + ), + "crypto": suggestion( + "crypto", 0.10, "Stablecoin yield or staking." + ), + }, + "1m": { + "stocks": suggestion( + "stocks", 0.4, "REITs + utilities blend." + ), + "etfs": suggestion( + "etfs", 0.5, "Bond ladders and dividend ETFs." + ), + "crypto": suggestion( + "crypto", 0.1, "Select staking strategies." + ), + }, + "1y": { + "stocks": suggestion( + "stocks", 0.45, "Global dividend growth." + ), + "etfs": suggestion( + "etfs", 0.45, "Income ETFs and bond funds." + ), + "crypto": suggestion( + "crypto", 0.1, "Yield-focused crypto vehicles." + ), + }, + }, + } + return strategies + + +STRATEGY_LIBRARY = _build_strategy_library() + + +class MultiAssetAllocationAgent(BaseAgent): + def __init__(self, **kwargs): + super().__init__( + name="MultiAssetAllocationAgent", + description="Builds allocations across stocks, ETFs, and crypto for multiple horizons", + **kwargs, + ) + + async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: + budget = float(input_data.get("budget", 0)) + if budget <= 0: + raise ValueError("Budget must be greater than zero.") + strategies = input_data.get("strategies") or ["balanced"] + normalized = self._normalize_strategies(strategies) + logger.info( + "Running multi-asset allocation for budget %.2f with strategies %s", + budget, + normalized, + ) + + plans = [self._build_plan(strategy, budget) for strategy in normalized] + advisory_notes = [ + "Allocations are illustrative; rebalance as macro drivers evolve.", + "Size crypto sleeves according to volatility tolerance and access to custody.", + "ETFs offer quick diversification for both beta and fixed-income exposures.", + ] + + return { + "budget": budget, + "strategies": plans, + "notes": advisory_notes, + } + + def _normalize_strategies(self, strategies: List[str]) -> List[str]: + valid = [] + for strategy in strategies: + key = strategy.lower().strip() + if key in STRATEGY_LIBRARY: + valid.append(key) + if not valid: + valid = ["balanced"] + return valid + + def _build_plan(self, strategy: str, budget: float) -> Dict[str, Any]: + template = STRATEGY_LIBRARY[strategy] + horizons = {} + for horizon, allocations in template.items(): + horizons[horizon] = self._build_horizon_allocations( + horizon, allocations, budget + ) + return { + "strategy": strategy, + "description": self._describe_strategy(strategy), + "horizons": horizons, + } + + def _describe_strategy(self, strategy: str) -> str: + descriptions = { + "growth": "Aggressive mix leaning into innovation, AI, and crypto beta.", + "balanced": "Even-handed mix balancing upside with drawdown control.", + "defensive": "Capital preservation first with equity-light tilts.", + "income": "Yield-focused mix emphasizing distributions and defensives.", + } + return descriptions.get(strategy, strategy) + + def _build_horizon_allocations( + self, + horizon_key: str, + allocations: Dict[str, AllocationSuggestion], + budget: float, + ) -> Dict[str, Any]: + total_weight = sum(item.weight for item in allocations.values()) + results = [] + cumulative = 0.0 + for asset_class, suggestion in allocations.items(): + weight = suggestion.weight / total_weight + amount = round(budget * weight, 2) + cumulative += amount + results.append( + { + "asset_class": asset_class, + "weight": round(weight, 3), + "amount": amount, + "rationale": suggestion.rationale, + "sample_assets": suggestion.sample_assets, + } + ) + drift = round(budget - cumulative, 2) + if abs(drift) >= 0.01 and results: + results[0]["amount"] = round(results[0]["amount"] + drift, 2) + + return { + "label": HORIZON_LABELS.get(horizon_key, horizon_key), + "allocations": results, + "risk_focus": self._risk_focus(horizon_key), + } + + def _risk_focus(self, horizon: str) -> str: + focus_map = { + "1w": "Liquidity & catalyst trading", + "1m": "Trend capture with guardrails", + "1y": "Compounding and thematic positioning", + } + return focus_map.get(horizon, "Balanced risk") + + +__all__ = ["MultiAssetAllocationAgent"] diff --git a/src/tradegraph_financial_advisor/main.py b/src/tradegraph_financial_advisor/main.py index bac70dc..24bd26d 100644 --- a/src/tradegraph_financial_advisor/main.py +++ b/src/tradegraph_financial_advisor/main.py @@ -9,12 +9,13 @@ from .agents.recommendation_engine import TradingRecommendationEngine from .agents.report_analysis_agent import ReportAnalysisAgent from .agents.channel_report_agent import ChannelReportAgent +from .agents.multi_asset_allocation_agent import MultiAssetAllocationAgent from .config.settings import settings, refresh_openai_api_key from .utils.helpers import save_analysis_results from .visualization import charts from .services.channel_stream_service import FinancialNewsChannelService from .services.price_trend_service import PriceTrendService -from .reporting import ChannelPDFReportWriter +from .reporting import ChannelPDFReportWriter, MultiAssetPDFReportWriter class FinancialAdvisor: @@ -29,6 +30,8 @@ def __init__(self, llm_model_name: str = "gpt-5-nano"): self.channel_service = FinancialNewsChannelService() self.trend_service = PriceTrendService() self.pdf_report_writer = ChannelPDFReportWriter() + self.multi_asset_agent = MultiAssetAllocationAgent() + self.multi_asset_pdf_writer = MultiAssetPDFReportWriter() async def analyze_portfolio( self, @@ -113,8 +116,10 @@ async def analyze_portfolio( "portfolio_recommendation": ( portfolio_recommendation if portfolio_recommendation else None ), + "recommendations": workflow_results.get("recommendations", []), "sentiment_analysis": sentiment_analysis, "detailed_reports": report_analyses, + "channel_streams": workflow_results.get("channel_streams", {}), "analysis_metadata": { "workflow_version": "1.0.0", "agents_used": [ @@ -183,6 +188,24 @@ async def quick_analysis( logger.error(f"Quick analysis failed: {str(e)}") raise + async def plan_multi_asset_allocation( + self, *, budget: float, strategies: Optional[List[str]] = None + ) -> Dict[str, Any]: + if budget <= 0: + raise ValueError("Budget must be positive for allocation planning.") + payload = { + "budget": budget, + "strategies": strategies, + } + return await self.multi_asset_agent.execute(payload) + + def build_multi_asset_pdf( + self, plan: Dict[str, Any], output_path: Optional[str] = None + ) -> str: + return self.multi_asset_pdf_writer.build_report( + plan=plan, output_path=output_path + ) + async def generate_channel_pdf_report( self, symbols: List[str], @@ -221,12 +244,24 @@ async def generate_channel_pdf_report( } ) + recommendations = reference_results.get("recommendations", []) + portfolio_rec = reference_results.get("portfolio_recommendation") + allocation_chart_path = None + if recommendations: + allocation_chart_path = charts.create_portfolio_allocation_chart( + recommendations=recommendations, + output_path="results/portfolio_allocation.png", + ) + pdf_path = self.pdf_report_writer.build_report( summary_payload=summary_payload, channel_payloads=channel_streams, price_trends=price_trends, - recommendations=reference_results.get("recommendations", []), + recommendations=recommendations, symbols=symbols, + portfolio_recommendation=portfolio_rec, + analysis_summary=reference_results.get("analysis_summary", {}), + allocation_chart_path=allocation_chart_path, output_path=output_path, ) @@ -341,6 +376,46 @@ def print_recommendations(self, results: Dict[str, Any]) -> None: print("\n" + "=" * 80) + def print_multi_asset_plan(self, plan: Dict[str, Any]) -> None: + budget = plan.get("budget", 0) + print("\n" + "=" * 80) + print("TRADEGRAPH MULTI-ASSET ALLOCATION PLAN") + print("=" * 80) + print(f"Budget: ${budget:,.2f}") + + strategies = plan.get("strategies", []) + for strategy in strategies: + print( + f"\n📌 Strategy: {strategy.get('strategy', '').title()} - {strategy.get('description', '')}" + ) + horizons = strategy.get("horizons", {}) + for horizon_key, payload in horizons.items(): + label = payload.get("label", horizon_key) + print(f" ➤ {label}: {payload.get('risk_focus', 'N/A')}") + for allocation in payload.get("allocations", []): + percent = allocation.get("weight", 0) * 100 + amount = allocation.get("amount", 0) + rationale = allocation.get("rationale", "") + sample_assets = ", ".join( + f"{asset['symbol']} ({asset['thesis']})" + for asset in allocation.get("sample_assets", []) + ) + print( + f" - {allocation.get('asset_class').upper()}: {percent:.1f}% " + f"(${amount:,.2f})" + ) + if rationale: + print(f" Rationale: {rationale}") + if sample_assets: + print(f" Sample: {sample_assets}") + + notes = plan.get("notes") or [] + if notes: + print("\n🗒 Advisor Notes:") + for note in notes: + print(f" - {note}") + print("\n" + "=" * 80) + async def main(): """ @@ -395,6 +470,21 @@ async def main(): type=str, help="Optional output path for the PDF report", ) + parser.add_argument( + "--multi-asset-budget", + type=float, + help="USD budget for a quick stocks/ETFs/crypto allocation plan", + ) + parser.add_argument( + "--multi-asset-strategies", + type=str, + help="Comma-separated strategies (growth,balanced,defensive,income)", + ) + parser.add_argument( + "--multi-asset-pdf-path", + type=str, + help="Optional output path for the multi-asset PDF report", + ) args = parser.parse_args() @@ -412,6 +502,34 @@ async def main(): try: advisor = FinancialAdvisor() + if args.multi_asset_budget: + strategies = None + if args.multi_asset_strategies: + strategies = [ + item.strip() + for item in args.multi_asset_strategies.split(",") + if item.strip() + ] + plan = await advisor.plan_multi_asset_allocation( + budget=args.multi_asset_budget, + strategies=strategies, + ) + try: + pdf_path = advisor.build_multi_asset_pdf( + plan, output_path=args.multi_asset_pdf_path + ) + logger.info(f"Multi-asset PDF saved to: {pdf_path}") + plan["pdf_path"] = pdf_path + except Exception as pdf_exc: + logger.warning(f"Failed to create multi-asset PDF: {pdf_exc}") + if args.output_format == "json": + import json + + print(json.dumps(plan, indent=2, default=str)) + else: + advisor.print_multi_asset_plan(plan) + return + if args.alerts_only: # Generate alerts only alerts = await advisor.get_stock_alerts(args.symbols) @@ -478,7 +596,7 @@ async def main(): chart_path = charts.create_portfolio_allocation_chart( recommendations=recommendations, - output_path="results/portfolio_allocation.html", + output_path="results/portfolio_allocation.png", ) logger.info(f"Portfolio allocation chart saved to: {chart_path}") diff --git a/src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py b/src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py new file mode 100644 index 0000000..cde6dfe --- /dev/null +++ b/src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py @@ -0,0 +1,162 @@ +"""PDF builder for multi-asset allocation plans.""" + +from __future__ import annotations + +import os +from datetime import datetime +from typing import Any, Dict, List, Optional +import textwrap + +from reportlab.lib.pagesizes import LETTER +from reportlab.lib.units import inch +from reportlab.pdfgen import canvas + + +class MultiAssetPDFReportWriter: + """Renders allocation plans across strategies/horizons into a PDF.""" + + def __init__(self) -> None: + self.page_width, self.page_height = LETTER + self.margin = 0.75 * inch + self.line_height = 14 + + def build_report( + self, + *, + plan: Dict[str, Any], + output_path: Optional[str] = None, + ) -> str: + os.makedirs("results", exist_ok=True) + if not output_path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_path = os.path.join( + "results", f"tradegraph_multi_asset_{timestamp}.pdf" + ) + + doc = canvas.Canvas(output_path, pagesize=LETTER) + cursor_y = self.page_height - self.margin + + cursor_y = self._draw_title(doc, cursor_y, "Multi-Asset Allocation Blueprint") + cursor_y = self._draw_subtitle( + doc, + cursor_y, + f"Budget: ${plan.get('budget', 0):,.2f} | Generated {datetime.now():%Y-%m-%d %H:%M UTC}", + ) + + notes = plan.get("notes") or [] + if notes: + cursor_y = self._draw_bullet_section( + doc, cursor_y, "Advisor Notes", notes + ) + + for strategy in plan.get("strategies", []): + cursor_y = self._draw_strategy_section(doc, cursor_y, strategy) + + doc.save() + return output_path + + def _draw_title(self, doc: canvas.Canvas, cursor_y: float, text: str) -> float: + doc.setFont("Helvetica-Bold", 20) + doc.drawString(self.margin, cursor_y, text) + return cursor_y - 24 + + def _draw_subtitle(self, doc: canvas.Canvas, cursor_y: float, text: str) -> float: + doc.setFont("Helvetica", 11) + doc.drawString(self.margin, cursor_y, text) + return cursor_y - 18 + + def _draw_strategy_section( + self, + doc: canvas.Canvas, + cursor_y: float, + strategy: Dict[str, Any], + ) -> float: + cursor_y = self._ensure_space(doc, cursor_y, min_height=140) + title = ( + f"Strategy: {str(strategy.get('strategy', '')).title()} - " + f"{strategy.get('description', '')}" + ) + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, title) + cursor_y -= 18 + + horizons = strategy.get("horizons", {}) + for horizon_key, payload in horizons.items(): + cursor_y = self._draw_horizon(doc, cursor_y, horizon_key, payload) + return cursor_y + + def _draw_horizon( + self, + doc: canvas.Canvas, + cursor_y: float, + horizon_key: str, + payload: Dict[str, Any], + ) -> float: + cursor_y = self._ensure_space(doc, cursor_y, min_height=80) + label = payload.get("label", horizon_key) + doc.setFont("Helvetica-Bold", 12) + doc.drawString( + self.margin, + cursor_y, + f"{label}: {payload.get('risk_focus', 'Risk focus n/a')}", + ) + cursor_y -= 14 + doc.setFont("Helvetica", 10) + for allocation in payload.get("allocations", []): + amount = allocation.get("amount", 0) + weight = allocation.get("weight", 0) * 100 + doc.drawString( + self.margin + 10, + cursor_y, + f"- {allocation.get('asset_class', '').upper()}: {weight:.1f}% (${amount:,.2f})", + ) + cursor_y -= self.line_height + rationale = allocation.get("rationale") + if rationale: + for line in self._wrap_text(f"Rationale: {rationale}", 92): + doc.drawString(self.margin + 20, cursor_y, line) + cursor_y -= self.line_height + samples = allocation.get("sample_assets", []) + if samples: + sample_line = ", ".join( + f"{item.get('symbol')} ({item.get('thesis')})" for item in samples + ) + for line in self._wrap_text(f"Sample: {sample_line}", 92): + doc.drawString(self.margin + 20, cursor_y, line) + cursor_y -= self.line_height + cursor_y -= 4 + return cursor_y + + def _draw_bullet_section( + self, + doc: canvas.Canvas, + cursor_y: float, + title: str, + bullets: List[str], + ) -> float: + cursor_y = self._ensure_space(doc, cursor_y, min_height=70) + doc.setFont("Helvetica-Bold", 14) + doc.drawString(self.margin, cursor_y, title) + cursor_y -= 18 + doc.setFont("Helvetica", 11) + for bullet in bullets: + for line in self._wrap_text(bullet, 94): + doc.drawString(self.margin + 10, cursor_y, f"• {line}") + cursor_y -= self.line_height + return cursor_y - 6 + + def _ensure_space( + self, doc: canvas.Canvas, cursor_y: float, *, min_height: float + ) -> float: + if cursor_y - min_height <= self.margin: + doc.showPage() + cursor_y = self.page_height - self.margin + return cursor_y + + def _wrap_text(self, text: str, width: int) -> List[str]: + if not text: + return [""] + return textwrap.wrap(text, width=width) or [text] + + +__all__ = ["MultiAssetPDFReportWriter"] diff --git a/tests/unit/test_multi_asset_agent.py b/tests/unit/test_multi_asset_agent.py new file mode 100644 index 0000000..009de97 --- /dev/null +++ b/tests/unit/test_multi_asset_agent.py @@ -0,0 +1,57 @@ +import pytest + +from tradegraph_financial_advisor.agents.multi_asset_allocation_agent import ( + MultiAssetAllocationAgent, +) +from tradegraph_financial_advisor.reporting import MultiAssetPDFReportWriter + + +@pytest.mark.asyncio +async def test_multi_asset_agent_returns_balanced_plan(): + agent = MultiAssetAllocationAgent() + result = await agent.execute({"budget": 10000, "strategies": ["growth", "unknown"]}) + + assert result["budget"] == 10000.0 + assert result["strategies"], "Strategies should not be empty" + plan = result["strategies"][0] + assert plan["strategy"] == "growth" + horizon = plan["horizons"]["1w"] + weights = sum(item["weight"] for item in horizon["allocations"]) + amounts = sum(item["amount"] for item in horizon["allocations"]) + assert pytest.approx(weights, rel=1e-3) == 1.0 + assert pytest.approx(amounts, rel=1e-3) == 10000.0 + + +def test_multi_asset_pdf_writer(tmp_path): + writer = MultiAssetPDFReportWriter() + plan = { + "budget": 5000, + "strategies": [ + { + "strategy": "balanced", + "description": "Balanced mix", + "horizons": { + "1w": { + "label": "1-Week", + "risk_focus": "Liquidity", + "allocations": [ + { + "asset_class": "stocks", + "weight": 0.5, + "amount": 2500, + "rationale": "Test rationale", + "sample_assets": [ + {"symbol": "AAPL", "thesis": "Quality"} + ], + } + ], + } + }, + } + ], + "notes": ["Note"], + } + output_file = tmp_path / "multi_asset.pdf" + pdf_path = writer.build_report(plan=plan, output_path=str(output_file)) + assert output_file.exists() + assert pdf_path == str(output_file) From 92336317fc238e6c31050e6c7f5ee120fc3a2337 Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Wed, 10 Dec 2025 21:54:55 +0100 Subject: [PATCH 04/11] Remove Finnhub-based tests --- tests/conftest.py | 46 +------------------ tests/unit/test_agents.py | 96 --------------------------------------- 2 files changed, 2 insertions(+), 140 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2ab5f08..5116302 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import asyncio import pytest import os -from unittest.mock import Mock, AsyncMock +from unittest.mock import AsyncMock from datetime import datetime, timedelta from tradegraph_financial_advisor.models.financial_data import NewsArticle @@ -9,6 +9,7 @@ # Test configuration os.environ["OPENAI_API_KEY"] = "test-openai-key" os.environ["ALPHA_VANTAGE_API_KEY"] = "test-alpha-vantage-key" +os.environ["FINNHUB_API_KEY"] = "test-finnhub-key" os.environ["LOG_LEVEL"] = "DEBUG" @@ -262,49 +263,6 @@ def sample_price_trends(): } -@pytest.fixture -def mock_finnhub_client(): - """Mock Finnhub client for testing.""" - client = AsyncMock() - - client.get_quote.return_value = {"c": 195.5, "o": 193.0, "v": 25000000} - - async def get_candles(symbol, resolution, start, end): - base = 150.0 - closes = [base + i for i in range(160)] - return { - "s": "ok", - "c": closes, - "h": [value + 2 for value in closes], - "l": [value - 2 for value in closes], - } - - client.get_candles.side_effect = get_candles - client.get_company_profile.return_value = { - "name": "Apple Inc.", - "marketCapitalization": 3_000_000_000_000, - } - client.close.return_value = None - return client - - -@pytest.fixture -def mock_binance_client(): - """Mock Binance client for testing.""" - client = AsyncMock() - - async def get_klines(symbol, interval, limit=500, start=None, end=None): - return [ - [0, 100.0 + i, 102.0 + i, 98.0 + i, 101.0 + i, 1500 + i] - for i in range(limit) - ] - - client.get_klines.side_effect = get_klines - client.get_price.return_value = 101.0 - client.close.return_value = None - return client - - @pytest.fixture def sample_portfolio_recommendation(): """Sample portfolio recommendation for testing.""" diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py index 9c07634..6c168c2 100644 --- a/tests/unit/test_agents.py +++ b/tests/unit/test_agents.py @@ -4,7 +4,6 @@ from tradegraph_financial_advisor.agents.base_agent import BaseAgent from tradegraph_financial_advisor.agents.news_agent import NewsReaderAgent -from tradegraph_financial_advisor.agents.financial_agent import FinancialAnalysisAgent from tradegraph_financial_advisor.agents.report_analysis_agent import ( ReportAnalysisAgent, ) @@ -156,85 +155,6 @@ async def test_impact_score_calculation(self): assert 0.0 <= impact_score <= 1.0 assert impact_score > 0.5 # Should be higher due to symbol mention in title - - -class TestFinancialAnalysisAgent: - """Test FinancialAnalysisAgent functionality.""" - - @pytest.mark.asyncio - async def test_financial_agent_initialization( - self, mock_finnhub_client, mock_binance_client - ): - """Test financial agent initialization.""" - agent = FinancialAnalysisAgent( - finnhub_client=mock_finnhub_client, binance_client=mock_binance_client - ) - assert agent.name == "FinancialAnalysisAgent" - assert "financial" in agent.description.lower() - - @pytest.mark.asyncio - async def test_execute_financial_analysis( - self, mock_finnhub_client, mock_binance_client - ): - """Test financial analysis execution.""" - agent = FinancialAnalysisAgent( - finnhub_client=mock_finnhub_client, binance_client=mock_binance_client - ) - - await agent.start() - - input_data = { - "symbols": ["AAPL"], - "include_financials": True, - "include_technical": True, - "include_market_data": True, - } - - result = await agent.execute(input_data) - - assert "analysis_results" in result - assert "AAPL" in result["analysis_results"] - - aapl_data = result["analysis_results"]["AAPL"] - assert "market_data" in aapl_data - assert "financials" in aapl_data - assert "technical_indicators" in aapl_data - - await agent.stop() - - @pytest.mark.asyncio - async def test_market_data_extraction( - self, mock_finnhub_client, mock_binance_client - ): - """Test market data extraction.""" - agent = FinancialAnalysisAgent( - finnhub_client=mock_finnhub_client, binance_client=mock_binance_client - ) - - market_data = await agent._get_equity_market_data("AAPL") - - assert market_data is not None - assert market_data.symbol == "AAPL" - assert market_data.current_price > 0 - assert market_data.volume > 0 - - @pytest.mark.asyncio - async def test_technical_indicators_calculation( - self, mock_finnhub_client, mock_binance_client - ): - """Test technical indicators calculation.""" - agent = FinancialAnalysisAgent( - finnhub_client=mock_finnhub_client, binance_client=mock_binance_client - ) - - technical_data = await agent._get_equity_technical_indicators("AAPL") - - assert technical_data is not None - assert technical_data.symbol == "AAPL" - # Check that some indicators are calculated - assert technical_data.sma_20 is not None or technical_data.rsi is not None - - class TestReportAnalysisAgent: """Test ReportAnalysisAgent functionality.""" @@ -473,19 +393,3 @@ async def test_price_targets_calculation(self, mock_langchain_llm): assert target_price > current_price # Target should be higher for BUY if stop_loss: assert stop_loss < current_price # Stop loss should be lower for BUY - - -@pytest.mark.asyncio -async def test_agents_health_checks(mock_finnhub_client, mock_binance_client): - """Test health checks for all agents.""" - agents_to_test = [ - NewsReaderAgent(), - FinancialAnalysisAgent( - finnhub_client=mock_finnhub_client, binance_client=mock_binance_client - ), - ] - - for agent in agents_to_test: - health_ok = await agent.health_check() - # Health check should return a boolean - assert isinstance(health_ok, bool) From 0ae98c964358c304ded643da37e613800703ab8e Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Wed, 10 Dec 2025 22:02:19 +0100 Subject: [PATCH 05/11] Allow workflow dependency injection --- .../workflows/analysis_workflow.py | 19 ++- tests/conftest.py | 25 ++++ tests/unit/test_workflows.py | 127 ++++++++++++------ 3 files changed, 122 insertions(+), 49 deletions(-) diff --git a/src/tradegraph_financial_advisor/workflows/analysis_workflow.py b/src/tradegraph_financial_advisor/workflows/analysis_workflow.py index eaea34c..f2207fe 100644 --- a/src/tradegraph_financial_advisor/workflows/analysis_workflow.py +++ b/src/tradegraph_financial_advisor/workflows/analysis_workflow.py @@ -40,18 +40,23 @@ def __init__( self, scraping_service: Optional[LocalScrapingService] = None, llm_model_name: str = "gpt-5-nano", + news_agent: Optional[NewsReaderAgent] = None, + financial_agent: Optional[FinancialAnalysisAgent] = None, + recommendation_engine: Optional[TradingRecommendationEngine] = None, + channel_service: Optional[FinancialNewsChannelService] = None, + llm: Optional[ChatOpenAI] = None, ): self.llm_model_name = llm_model_name - self.llm = ChatOpenAI( + self.llm = llm or ChatOpenAI( model=self.llm_model_name, temperature=0.1, api_key=settings.openai_api_key ) - self.news_agent = NewsReaderAgent() - self.financial_agent = FinancialAnalysisAgent() - self.recommendation_engine = TradingRecommendationEngine( + self.news_agent = news_agent or NewsReaderAgent() + self.financial_agent = financial_agent or FinancialAnalysisAgent() + self.recommendation_engine = recommendation_engine or TradingRecommendationEngine( model_name=self.llm_model_name ) self.local_scraping_service = scraping_service or LocalScrapingService() - self.channel_service = FinancialNewsChannelService() + self.channel_service = channel_service or FinancialNewsChannelService() self.workflow = None self._build_workflow() @@ -415,7 +420,7 @@ async def _generate_recommendations(self, state: AnalysisState) -> AnalysisState recommendations, portfolio_constraints, ) - state["recommendations"] = [rec.dict() for rec in optimized_recs] + state["recommendations"] = [rec.model_dump() for rec in optimized_recs] else: state["recommendations"] = [] @@ -957,7 +962,7 @@ def _article_to_dict(self, article: Any) -> Dict[str, Any]: if hasattr(article, "model_dump"): return article.model_dump() if hasattr(article, "dict"): - return article.dict() + return article.model_dump() return { "title": self._get_article_value(article, "title", ""), "url": self._get_article_value(article, "url", ""), diff --git a/tests/conftest.py b/tests/conftest.py index 5116302..857f30e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -411,3 +411,28 @@ def mock_local_scraping_service(): ] mock_service.health_check.return_value = True return mock_service + + +class _MockFinancialAgent: + """Simple mock financial agent for workflow tests.""" + + name = "FinancialAnalysisAgent" + description = "Mock financial agent" + + async def start(self): + return None + + async def stop(self): + return None + + async def execute(self, input_data): + return {"analysis_results": {}} + + async def health_check(self): + return True + + +@pytest.fixture +def mock_financial_agent(): + """Provide a lightweight financial agent replacement.""" + return _MockFinancialAgent() diff --git a/tests/unit/test_workflows.py b/tests/unit/test_workflows.py index 2896002..ceb6a64 100644 --- a/tests/unit/test_workflows.py +++ b/tests/unit/test_workflows.py @@ -11,11 +11,24 @@ class TestFinancialAnalysisWorkflow: """Test FinancialAnalysisWorkflow functionality.""" + def _create_workflow( + self, + mock_local_scraping_service, + mock_financial_agent, + ) -> FinancialAnalysisWorkflow: + """Helper to create workflows with mocked dependencies.""" + return FinancialAnalysisWorkflow( + scraping_service=mock_local_scraping_service, + financial_agent=mock_financial_agent, + ) + @pytest.mark.asyncio - async def test_workflow_initialization(self, mock_local_scraping_service): + async def test_workflow_initialization( + self, mock_local_scraping_service, mock_financial_agent + ): """Test workflow initialization.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) assert workflow.news_agent is not None @@ -24,10 +37,12 @@ async def test_workflow_initialization(self, mock_local_scraping_service): assert workflow.workflow is not None @pytest.mark.asyncio - async def test_analyze_portfolio_basic(self, mock_local_scraping_service): + async def test_analyze_portfolio_basic( + self, mock_local_scraping_service, mock_financial_agent + ): """Test basic portfolio analysis.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.llm = AsyncMock() workflow.llm.ainvoke.return_value = Mock( @@ -49,11 +64,14 @@ async def test_analyze_portfolio_basic(self, mock_local_scraping_service): @pytest.mark.asyncio async def test_collect_news_step( - self, mock_local_scraping_service, sample_news_articles + self, + mock_local_scraping_service, + mock_financial_agent, + sample_news_articles, ): """Test news collection workflow step.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.news_agent.execute = AsyncMock( return_value={ @@ -84,11 +102,14 @@ async def test_collect_news_step( @pytest.mark.asyncio async def test_analyze_financials_step( - self, mock_local_scraping_service, sample_financial_data + self, + mock_local_scraping_service, + mock_financial_agent, + sample_financial_data, ): """Test financial analysis workflow step.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.financial_agent.execute = AsyncMock( return_value={"analysis_results": sample_financial_data} @@ -116,11 +137,14 @@ async def test_analyze_financials_step( @pytest.mark.asyncio async def test_analyze_sentiment_step( - self, mock_local_scraping_service, sample_news_articles + self, + mock_local_scraping_service, + mock_financial_agent, + sample_news_articles, ): """Test sentiment analysis workflow step.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.llm = AsyncMock() workflow.llm.ainvoke.return_value = Mock( @@ -148,11 +172,14 @@ async def test_analyze_sentiment_step( @pytest.mark.asyncio async def test_generate_recommendations_step( - self, mock_local_scraping_service, sample_recommendations + self, + mock_local_scraping_service, + mock_financial_agent, + sample_recommendations, ): """Test recommendation generation workflow step.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.llm = AsyncMock() workflow.llm.ainvoke.return_value = Mock( @@ -199,11 +226,14 @@ async def test_generate_recommendations_step( @pytest.mark.asyncio async def test_create_portfolio_step( - self, mock_local_scraping_service, sample_recommendations + self, + mock_local_scraping_service, + mock_financial_agent, + sample_recommendations, ): """Test portfolio creation workflow step.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.llm = AsyncMock() workflow.llm.ainvoke.return_value = Mock( @@ -240,11 +270,14 @@ async def test_create_portfolio_step( @pytest.mark.asyncio async def test_validate_recommendations_step( - self, mock_local_scraping_service, sample_portfolio_recommendation + self, + mock_local_scraping_service, + mock_financial_agent, + sample_portfolio_recommendation, ): """Test recommendation validation workflow step.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) initial_state = AnalysisState( @@ -267,10 +300,12 @@ async def test_validate_recommendations_step( assert len(result_state["messages"]) > 0 @pytest.mark.asyncio - async def test_workflow_error_handling(self, mock_local_scraping_service): + async def test_workflow_error_handling( + self, mock_local_scraping_service, mock_financial_agent + ): """Test workflow error handling.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.news_agent.execute = AsyncMock(side_effect=Exception("Test error")) @@ -281,11 +316,11 @@ async def test_workflow_error_handling(self, mock_local_scraping_service): @pytest.mark.asyncio async def test_workflow_with_different_risk_tolerances( - self, mock_local_scraping_service + self, mock_local_scraping_service, mock_financial_agent ): """Test workflow with different risk tolerance settings.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.llm = AsyncMock() workflow.llm.ainvoke.return_value = Mock( @@ -309,10 +344,12 @@ async def test_workflow_with_different_risk_tolerances( assert result.get("portfolio_recommendation") is not None @pytest.mark.asyncio - async def test_workflow_state_transitions(self, mock_local_scraping_service): + async def test_workflow_state_transitions( + self, mock_local_scraping_service, mock_financial_agent + ): """Test workflow state transitions.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) # Test that workflow has proper state transitions @@ -322,10 +359,12 @@ async def test_workflow_state_transitions(self, mock_local_scraping_service): assert workflow.workflow is not None @pytest.mark.asyncio - async def test_workflow_with_multiple_symbols(self, mock_local_scraping_service): + async def test_workflow_with_multiple_symbols( + self, mock_local_scraping_service, mock_financial_agent + ): """Test workflow with multiple symbols.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.llm = AsyncMock() workflow.llm.ainvoke.return_value = Mock( @@ -366,10 +405,12 @@ async def test_workflow_with_multiple_symbols(self, mock_local_scraping_service) assert len(result["recommendations"]) >= 0 @pytest.mark.asyncio - async def test_workflow_performance(self, mock_local_scraping_service): + async def test_workflow_performance( + self, mock_local_scraping_service, mock_financial_agent + ): """Test workflow performance characteristics.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.llm = AsyncMock() workflow.llm.ainvoke.return_value = Mock( @@ -392,10 +433,12 @@ async def test_workflow_performance(self, mock_local_scraping_service): assert isinstance(result, dict) @pytest.mark.asyncio - async def test_workflow_cleanup(self, mock_local_scraping_service): + async def test_workflow_cleanup( + self, mock_local_scraping_service, mock_financial_agent + ): """Test workflow cleanup and resource management.""" - workflow = FinancialAnalysisWorkflow( - scraping_service=mock_local_scraping_service + workflow = self._create_workflow( + mock_local_scraping_service, mock_financial_agent ) workflow.news_agent = AsyncMock() workflow.financial_agent = AsyncMock() From 9a565f8d23dc821926eb9f846342cb91b5f794d3 Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Wed, 10 Dec 2025 22:05:58 +0100 Subject: [PATCH 06/11] Refresh settings for test API keys --- tests/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 857f30e..abdf949 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from tradegraph_financial_advisor.models.financial_data import NewsArticle +from tradegraph_financial_advisor.config.settings import refresh_openai_api_key # Test configuration os.environ["OPENAI_API_KEY"] = "test-openai-key" @@ -12,6 +13,9 @@ os.environ["FINNHUB_API_KEY"] = "test-finnhub-key" os.environ["LOG_LEVEL"] = "DEBUG" +# Ensure global settings pick up the test keys +refresh_openai_api_key() + @pytest.fixture(scope="session") def event_loop(): From 0aeecde1a8c9ea6469494b9399f17a0f4bbcf64a Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Wed, 10 Dec 2025 22:08:38 +0100 Subject: [PATCH 07/11] Fix CI by setting Finnhub key --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25095a8..f399240 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: test: name: Unit Tests runs-on: ubuntu-latest + env: + FINNHUB_API_KEY: test-finnhub-key steps: - name: Checkout code uses: actions/checkout@v4 @@ -59,6 +61,8 @@ jobs: integration-tests: name: Integration Tests runs-on: ubuntu-latest + env: + FINNHUB_API_KEY: test-finnhub-key steps: - name: Checkout code uses: actions/checkout@v4 From 324a9fcdf5eaf6a3317a07cf9dd5f4c957c42d52 Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Wed, 10 Dec 2025 22:16:18 +0100 Subject: [PATCH 08/11] Pin Black version for CI --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 073973d..0e1ccc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dev = [ "pytest>=7.4.0", "pytest-asyncio>=0.21.0", "pytest-cov>=4.1.0", - "black>=23.0.0", + "black==23.3.0", "isort>=5.12.0", "flake8>=6.0.0", "mypy>=1.5.0", From 0c5c298799524ccb8f1444a19ea2aefb73f87ad4 Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Wed, 10 Dec 2025 22:22:15 +0100 Subject: [PATCH 09/11] Apply lint fixes --- examples/basic_usage.py | 4 +- .../agents/channel_report_agent.py | 14 +++- .../agents/financial_agent.py | 12 ++-- .../agents/multi_asset_allocation_agent.py | 66 +++++++------------ .../agents/recommendation_engine.py | 1 - .../agents/report_analysis_agent.py | 4 +- src/tradegraph_financial_advisor/main.py | 15 ++--- .../reporting/multi_asset_reporter.py | 4 +- .../reporting/pdf_reporter.py | 8 ++- .../services/channel_stream_service.py | 20 ++---- .../services/local_scraping_service.py | 4 +- .../services/price_trend_service.py | 8 ++- .../visualization/charts.py | 4 +- .../workflows/analysis_workflow.py | 16 ++--- tests/unit/test_agents.py | 2 + tests/unit/test_channels.py | 14 +++- tests/unit/test_news_repository.py | 2 - 17 files changed, 101 insertions(+), 97 deletions(-) diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 7832827..185846e 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -229,7 +229,9 @@ async def main(): # Check environment first if not check_environment(): - print("\n⚠️ Please configure your environment variables before running examples") + print( + "\n⚠️ Please configure your environment variables before running examples" + ) return # Run examples diff --git a/src/tradegraph_financial_advisor/agents/channel_report_agent.py b/src/tradegraph_financial_advisor/agents/channel_report_agent.py index 4bff574..b372818 100644 --- a/src/tradegraph_financial_advisor/agents/channel_report_agent.py +++ b/src/tradegraph_financial_advisor/agents/channel_report_agent.py @@ -70,7 +70,11 @@ async def execute(self, input_data: Dict[str, Any]) -> Dict[str, Any]: "price_action_notes (list), and key_stats (object describing counts)." ) response = await self.llm.ainvoke( - [HumanMessage(content=f"{prompt}\nINPUT:\n{json.dumps(prompt_payload)[:6000]}")] + [ + HumanMessage( + content=f"{prompt}\nINPUT:\n{json.dumps(prompt_payload)[:6000]}" + ) + ] ) data = json.loads(response.content) fallback.update({k: v for k, v in data.items() if v}) @@ -120,7 +124,9 @@ def _build_fallback_summary( trend_commentary = self._summarize_trends(price_trends) price_action_notes = self._build_price_notes(price_trends) - guidance_points = self._build_guidance_points(recommendations, price_action_notes) + guidance_points = self._build_guidance_points( + recommendations, price_action_notes + ) narrative_seed = " ".join(news_takeaways[:3]) or "Mixed market color." summary_text = ( @@ -158,7 +164,9 @@ def _build_key_stats( channel_payloads: Dict[str, Any], recommendations: List[Dict[str, Any]], ) -> Dict[str, Any]: - total_items = sum(len(payload.get("items", [])) for payload in channel_payloads.values()) + total_items = sum( + len(payload.get("items", [])) for payload in channel_payloads.values() + ) covered_symbols = { symbol for payload in channel_payloads.values() diff --git a/src/tradegraph_financial_advisor/agents/financial_agent.py b/src/tradegraph_financial_advisor/agents/financial_agent.py index 826904f..b6c26ed 100644 --- a/src/tradegraph_financial_advisor/agents/financial_agent.py +++ b/src/tradegraph_financial_advisor/agents/financial_agent.py @@ -108,9 +108,7 @@ async def _get_equity_market_data(self, symbol: str) -> Optional[MarketData]: return None change = float(current_price) - float(open_price) - change_percent = ( - (change / float(open_price)) * 100 if open_price else 0.0 - ) + change_percent = (change / float(open_price)) * 100 if open_price else 0.0 volume = int(quote.get("v") or 0) market_cap = await self._get_market_cap(symbol) @@ -213,7 +211,9 @@ async def _get_equity_technical_indicators( high_series = pd.Series([float(value) for value in highs]) low_series = pd.Series([float(value) for value in lows]) - return self._build_technical_indicators(symbol, close_prices, high_series, low_series) + return self._build_technical_indicators( + symbol, close_prices, high_series, low_series + ) except Exception as e: logger.error( @@ -237,7 +237,9 @@ async def _get_crypto_technical_indicators( high_series = pd.Series([float(item[2]) for item in klines]) low_series = pd.Series([float(item[3]) for item in klines]) - return self._build_technical_indicators(symbol, close_prices, high_series, low_series) + return self._build_technical_indicators( + symbol, close_prices, high_series, low_series + ) except Exception as e: logger.error( diff --git a/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py b/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py index af1bb46..6e0da15 100644 --- a/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py +++ b/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from loguru import logger @@ -49,7 +49,9 @@ def _build_strategy_library() -> Dict[str, Dict[str, Dict[str, AllocationSuggest ], } - def suggestion(asset_class: str, weight: float, rationale: str) -> AllocationSuggestion: + def suggestion( + asset_class: str, weight: float, rationale: str + ) -> AllocationSuggestion: return AllocationSuggestion( asset_class=asset_class, weight=weight, @@ -61,10 +63,14 @@ def suggestion(asset_class: str, weight: float, rationale: str) -> AllocationSug "growth": { "1w": { "stocks": suggestion( - "stocks", 0.35, "Stay liquid in quality mega caps while watching catalysts." + "stocks", + 0.35, + "Stay liquid in quality mega caps while watching catalysts.", ), "etfs": suggestion( - "etfs", 0.15, "Use QQQ/ARKK for beta exposure without security selection." + "etfs", + 0.15, + "Use QQQ/ARKK for beta exposure without security selection.", ), "crypto": suggestion( "crypto", 0.50, "Lean into BTC/ETH momentum for tactical upside." @@ -107,11 +113,11 @@ def suggestion(asset_class: str, weight: float, rationale: str) -> AllocationSug }, "1m": { "stocks": suggestion( - "stocks", 0.4, "Add cyclicals selectively as macro visibility improves." - ), - "etfs": suggestion( - "etfs", 0.4, "Core passive ETFs to anchor risk." + "stocks", + 0.4, + "Add cyclicals selectively as macro visibility improves.", ), + "etfs": suggestion("etfs", 0.4, "Core passive ETFs to anchor risk."), "crypto": suggestion( "crypto", 0.2, "Keep crypto beta but size for volatility." ), @@ -141,23 +147,15 @@ def suggestion(asset_class: str, weight: float, rationale: str) -> AllocationSug ), }, "1m": { - "stocks": suggestion( - "stocks", 0.3, "Income-oriented equities." - ), + "stocks": suggestion("stocks", 0.3, "Income-oriented equities."), "etfs": suggestion( "etfs", 0.55, "Blend IG bonds with broad equity ETFs." ), - "crypto": suggestion( - "crypto", 0.15, "Cap risk but allow for upside." - ), + "crypto": suggestion("crypto", 0.15, "Cap risk but allow for upside."), }, "1y": { - "stocks": suggestion( - "stocks", 0.35, "Quality and value tilt." - ), - "etfs": suggestion( - "etfs", 0.5, "VOO/TLT core plus dividend ETFs." - ), + "stocks": suggestion("stocks", 0.35, "Quality and value tilt."), + "etfs": suggestion("etfs", 0.5, "VOO/TLT core plus dividend ETFs."), "crypto": suggestion( "crypto", 0.15, "Long-dated call option sized exposure." ), @@ -171,31 +169,17 @@ def suggestion(asset_class: str, weight: float, rationale: str) -> AllocationSug "etfs": suggestion( "etfs", 0.55, "Covered-call and bond ETFs for yield." ), - "crypto": suggestion( - "crypto", 0.10, "Stablecoin yield or staking." - ), + "crypto": suggestion("crypto", 0.10, "Stablecoin yield or staking."), }, "1m": { - "stocks": suggestion( - "stocks", 0.4, "REITs + utilities blend." - ), - "etfs": suggestion( - "etfs", 0.5, "Bond ladders and dividend ETFs." - ), - "crypto": suggestion( - "crypto", 0.1, "Select staking strategies." - ), + "stocks": suggestion("stocks", 0.4, "REITs + utilities blend."), + "etfs": suggestion("etfs", 0.5, "Bond ladders and dividend ETFs."), + "crypto": suggestion("crypto", 0.1, "Select staking strategies."), }, "1y": { - "stocks": suggestion( - "stocks", 0.45, "Global dividend growth." - ), - "etfs": suggestion( - "etfs", 0.45, "Income ETFs and bond funds." - ), - "crypto": suggestion( - "crypto", 0.1, "Yield-focused crypto vehicles." - ), + "stocks": suggestion("stocks", 0.45, "Global dividend growth."), + "etfs": suggestion("etfs", 0.45, "Income ETFs and bond funds."), + "crypto": suggestion("crypto", 0.1, "Yield-focused crypto vehicles."), }, }, } diff --git a/src/tradegraph_financial_advisor/agents/recommendation_engine.py b/src/tradegraph_financial_advisor/agents/recommendation_engine.py index f3d303e..dde60f8 100644 --- a/src/tradegraph_financial_advisor/agents/recommendation_engine.py +++ b/src/tradegraph_financial_advisor/agents/recommendation_engine.py @@ -392,7 +392,6 @@ def _calculate_position_size( risk_preferences: Dict[str, Any], recommendation_type: RecommendationType, ) -> float: - RECOMMENDATION_WEIGHTS = { RecommendationType.STRONG_BUY: 2.0, RecommendationType.BUY: 1.5, diff --git a/src/tradegraph_financial_advisor/agents/report_analysis_agent.py b/src/tradegraph_financial_advisor/agents/report_analysis_agent.py index dfd86e9..421cb39 100644 --- a/src/tradegraph_financial_advisor/agents/report_analysis_agent.py +++ b/src/tradegraph_financial_advisor/agents/report_analysis_agent.py @@ -202,7 +202,9 @@ def _parse_json_response(self, payload: str) -> Dict[str, Any]: raise json.JSONDecodeError("Empty response", payload, 0) if "```" in cleaned: - segments = [segment.strip() for segment in cleaned.split("```") if segment.strip()] + segments = [ + segment.strip() for segment in cleaned.split("```") if segment.strip() + ] if segments: cleaned = segments[-1] diff --git a/src/tradegraph_financial_advisor/main.py b/src/tradegraph_financial_advisor/main.py index 24bd26d..5f0c30e 100644 --- a/src/tradegraph_financial_advisor/main.py +++ b/src/tradegraph_financial_advisor/main.py @@ -26,7 +26,9 @@ def __init__(self, llm_model_name: str = "gpt-5-nano"): model_name=self.llm_model_name ) self.report_analyzer = ReportAnalysisAgent(llm_model_name=self.llm_model_name) - self.channel_report_agent = ChannelReportAgent(llm_model_name=self.llm_model_name) + self.channel_report_agent = ChannelReportAgent( + llm_model_name=self.llm_model_name + ) self.channel_service = FinancialNewsChannelService() self.trend_service = PriceTrendService() self.pdf_report_writer = ChannelPDFReportWriter() @@ -614,8 +616,7 @@ async def main(): try: existing_reference = ( results - if isinstance(results, dict) - and results.get("channel_streams") + if isinstance(results, dict) and results.get("channel_streams") else None ) pdf_info = await advisor.generate_channel_pdf_report( @@ -627,13 +628,9 @@ async def main(): existing_results=existing_reference, output_path=args.pdf_path, ) - logger.info( - f"Channel PDF report saved to: {pdf_info['pdf_path']}" - ) + logger.info(f"Channel PDF report saved to: {pdf_info['pdf_path']}") except Exception as pdf_exc: - logger.warning( - f"Failed to create PDF channel report: {pdf_exc}" - ) + logger.warning(f"Failed to create PDF channel report: {pdf_exc}") # Display results based on output format if args.output_format == "json": diff --git a/src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py b/src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py index cde6dfe..e814ea9 100644 --- a/src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py +++ b/src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py @@ -45,9 +45,7 @@ def build_report( notes = plan.get("notes") or [] if notes: - cursor_y = self._draw_bullet_section( - doc, cursor_y, "Advisor Notes", notes - ) + cursor_y = self._draw_bullet_section(doc, cursor_y, "Advisor Notes", notes) for strategy in plan.get("strategies", []): cursor_y = self._draw_strategy_section(doc, cursor_y, strategy) diff --git a/src/tradegraph_financial_advisor/reporting/pdf_reporter.py b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py index a57cd64..dccdc73 100644 --- a/src/tradegraph_financial_advisor/reporting/pdf_reporter.py +++ b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py @@ -97,7 +97,9 @@ def build_report( f"Trend Notes: {summary_payload.get('trend_commentary', 'n/a')}", ) - cursor_y = self._draw_key_stats(doc, cursor_y, summary_payload.get("key_stats", {})) + cursor_y = self._draw_key_stats( + doc, cursor_y, summary_payload.get("key_stats", {}) + ) cursor_y = self._draw_channel_breakdown(doc, cursor_y, channel_payloads) cursor_y = self._draw_recommendations(doc, cursor_y, recommendations) @@ -191,7 +193,9 @@ def _draw_channel_breakdown( doc.drawString(self.margin, cursor_y, payload.get("title", channel_id)) cursor_y -= 14 doc.setFont("Helvetica", 10) - highlights = [item.get("title", "") for item in payload.get("items", [])[:3]] + highlights = [ + item.get("title", "") for item in payload.get("items", [])[:3] + ] body = "; ".join(highlights) or "No items collected" for line in self._wrap_text(body, 90): doc.drawString(self.margin + 10, cursor_y, f"- {line}") diff --git a/src/tradegraph_financial_advisor/services/channel_stream_service.py b/src/tradegraph_financial_advisor/services/channel_stream_service.py index 5f388f5..d2e2752 100644 --- a/src/tradegraph_financial_advisor/services/channel_stream_service.py +++ b/src/tradegraph_financial_advisor/services/channel_stream_service.py @@ -224,7 +224,9 @@ async def _get_session(self) -> aiohttp.ClientSession: return self._session def describe_channels(self) -> List[Dict[str, Any]]: - return [CHANNEL_REGISTRY[channel].metadata() for channel in self._iter_channels()] + return [ + CHANNEL_REGISTRY[channel].metadata() for channel in self._iter_channels() + ] async def collect_all_channels( self, symbols: Optional[Sequence[str]] = None @@ -238,9 +240,7 @@ async def collect_all_channels( result: Dict[str, Any] = {} for channel, payload in zip(self._iter_channels(), payloads): if isinstance(payload, Exception): - logger.warning( - f"Failed to collect channel {channel.value}: {payload}" - ) + logger.warning(f"Failed to collect channel {channel.value}: {payload}") continue result[channel.value] = payload return result @@ -291,9 +291,7 @@ async def _collect_news( collected: List[Dict[str, Any]] = [] for source, payload in zip(channel_definition.sources, results): if isinstance(payload, Exception): - logger.warning( - f"Failed to fetch feed from {source.name}: {payload}" - ) + logger.warning(f"Failed to fetch feed from {source.name}: {payload}") continue collected.extend(payload) @@ -358,9 +356,7 @@ def _normalize_entry( return None published = None - published_data = entry.get("published_parsed") or entry.get( - "updated_parsed" - ) + published_data = entry.get("published_parsed") or entry.get("updated_parsed") if published_data: published = datetime(*published_data[:6], tzinfo=timezone.utc) @@ -384,9 +380,7 @@ def _normalize_entry( "published_at": published.isoformat() if published else None, } - async def _build_price_items( - self, symbols: Sequence[str] - ) -> List[Dict[str, Any]]: + async def _build_price_items(self, symbols: Sequence[str]) -> List[Dict[str, Any]]: trends = await self.price_service.get_trends_for_symbols(list(symbols)) items: List[Dict[str, Any]] = [] for symbol in symbols: diff --git a/src/tradegraph_financial_advisor/services/local_scraping_service.py b/src/tradegraph_financial_advisor/services/local_scraping_service.py index 6141740..a1b924f 100644 --- a/src/tradegraph_financial_advisor/services/local_scraping_service.py +++ b/src/tradegraph_financial_advisor/services/local_scraping_service.py @@ -83,7 +83,9 @@ async def search_and_scrape_news( all_articles.append(article) new_articles.append(article) except Exception as scrape_exc: - logger.warning(f"Failed to scrape article {result.get('url')}: {scrape_exc}") + logger.warning( + f"Failed to scrape article {result.get('url')}: {scrape_exc}" + ) if new_articles: await self._persist_articles(new_articles) diff --git a/src/tradegraph_financial_advisor/services/price_trend_service.py b/src/tradegraph_financial_advisor/services/price_trend_service.py index 73838dc..90f9200 100644 --- a/src/tradegraph_financial_advisor/services/price_trend_service.py +++ b/src/tradegraph_financial_advisor/services/price_trend_service.py @@ -72,12 +72,16 @@ async def _run_symbol(self, symbol: str) -> Optional[Dict[str, Any]]: label: asyncio.create_task(self._fetch_trend(symbol, spec, now)) for label, spec in self._timeframes.items() } - results = await asyncio.gather(*trend_tasks.values(), return_exceptions=True) + results = await asyncio.gather( + *trend_tasks.values(), return_exceptions=True + ) trends: Dict[str, Any] = {} for label, result in zip(trend_tasks.keys(), results): if isinstance(result, Exception): - logger.warning(f"Trend window {label} failed for {symbol}: {result}") + logger.warning( + f"Trend window {label} failed for {symbol}: {result}" + ) continue if result: trends[label] = result diff --git a/src/tradegraph_financial_advisor/visualization/charts.py b/src/tradegraph_financial_advisor/visualization/charts.py index e22794a..2d86339 100644 --- a/src/tradegraph_financial_advisor/visualization/charts.py +++ b/src/tradegraph_financial_advisor/visualization/charts.py @@ -27,7 +27,9 @@ def create_portfolio_allocation_chart( portfolio_size = rec.get("portfolio_size") or 1 max_position = rec.get("max_position_size") allocation_value = ( - (max_position / portfolio_size) if max_position and portfolio_size else 0 + (max_position / portfolio_size) + if max_position and portfolio_size + else 0 ) allocations.append(float(allocation_value) * 100) diff --git a/src/tradegraph_financial_advisor/workflows/analysis_workflow.py b/src/tradegraph_financial_advisor/workflows/analysis_workflow.py index f2207fe..6d74da3 100644 --- a/src/tradegraph_financial_advisor/workflows/analysis_workflow.py +++ b/src/tradegraph_financial_advisor/workflows/analysis_workflow.py @@ -52,8 +52,9 @@ def __init__( ) self.news_agent = news_agent or NewsReaderAgent() self.financial_agent = financial_agent or FinancialAnalysisAgent() - self.recommendation_engine = recommendation_engine or TradingRecommendationEngine( - model_name=self.llm_model_name + self.recommendation_engine = ( + recommendation_engine + or TradingRecommendationEngine(model_name=self.llm_model_name) ) self.local_scraping_service = scraping_service or LocalScrapingService() self.channel_service = channel_service or FinancialNewsChannelService() @@ -89,7 +90,6 @@ async def analyze_portfolio( risk_tolerance: str = "medium", time_horizon: str = "medium_term", ) -> Dict[str, Any]: - if portfolio_size is None: portfolio_size = settings.default_portfolio_size @@ -190,9 +190,7 @@ async def _collect_news(self, state: AnalysisState) -> AnalysisState: ) state["channel_streams"] = channel_payloads except Exception as channel_exc: - logger.warning( - f"Failed to collect channel streams: {channel_exc}" - ) + logger.warning(f"Failed to collect channel streams: {channel_exc}") state["messages"].append( AIMessage(content=f"Collected {len(combined_news)} news articles") @@ -482,9 +480,9 @@ async def _create_portfolio(self, state: AnalysisState) -> AnalysisState: import json portfolio_data = json.loads(response.content) - portfolio_data["recommendations"] = ( - recommendations # Ensure recommendations are included - ) + portfolio_data[ + "recommendations" + ] = recommendations # Ensure recommendations are included state["portfolio_recommendation"] = portfolio_data except Exception as e: diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py index 6c168c2..aa2b5bf 100644 --- a/tests/unit/test_agents.py +++ b/tests/unit/test_agents.py @@ -155,6 +155,8 @@ async def test_impact_score_calculation(self): assert 0.0 <= impact_score <= 1.0 assert impact_score > 0.5 # Should be higher due to symbol mention in title + + class TestReportAnalysisAgent: """Test ReportAnalysisAgent functionality.""" diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py index a18970f..fe6fcfd 100644 --- a/tests/unit/test_channels.py +++ b/tests/unit/test_channels.py @@ -21,7 +21,9 @@ async def get_trends_for_symbols(self, symbols): @pytest.mark.asyncio async def test_channel_service_price_payload(sample_price_trends): - service = FinancialNewsChannelService(price_service=_DummyPriceService(sample_price_trends)) + service = FinancialNewsChannelService( + price_service=_DummyPriceService(sample_price_trends) + ) payload = await service.fetch_channel_payload( ChannelType.LIVE_PRICE_STREAM.value, symbols=["AAPL"] ) @@ -48,7 +50,9 @@ async def test_channel_report_agent_fallback( assert summary.get("key_stats", {}).get("channel_count") == 1 -def test_pdf_report_writer(tmp_path, sample_channel_streams, sample_price_trends, sample_recommendations): +def test_pdf_report_writer( + tmp_path, sample_channel_streams, sample_price_trends, sample_recommendations +): writer = ChannelPDFReportWriter() output_file = tmp_path / "report.pdf" summary_payload = { @@ -60,7 +64,11 @@ def test_pdf_report_writer(tmp_path, sample_channel_streams, sample_price_trends "advisor_memo": "Maintain constructive stance with risk controls.", "price_action_notes": ["AAPL: +2.5% weekly"], "guidance_points": ["Add MSFT on earnings strength"], - "key_stats": {"channel_count": 1, "headline_count": 2, "recommendation_count": 2}, + "key_stats": { + "channel_count": 1, + "headline_count": 2, + "recommendation_count": 2, + }, } pdf_path = writer.build_report( summary_payload=summary_payload, diff --git a/tests/unit/test_news_repository.py b/tests/unit/test_news_repository.py index a8c79c1..4146a8b 100644 --- a/tests/unit/test_news_repository.py +++ b/tests/unit/test_news_repository.py @@ -1,7 +1,5 @@ from datetime import datetime, timezone -import pytest - from tradegraph_financial_advisor.models.financial_data import NewsArticle from tradegraph_financial_advisor.repositories import NewsRepository From a897a15c9dd74c84ed3d7258a26d0a217f929b54 Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Fri, 12 Dec 2025 16:13:03 +0100 Subject: [PATCH 10/11] Improve basic analysis output and PDF text wrapping --- src/tradegraph_financial_advisor/main.py | 41 ++++++++++++++--- .../reporting/pdf_reporter.py | 37 +++++++++++++-- uv.lock | 45 ++++++------------- 3 files changed, 83 insertions(+), 40 deletions(-) diff --git a/src/tradegraph_financial_advisor/main.py b/src/tradegraph_financial_advisor/main.py index 5f0c30e..804d126 100644 --- a/src/tradegraph_financial_advisor/main.py +++ b/src/tradegraph_financial_advisor/main.py @@ -159,20 +159,49 @@ async def quick_analysis( if analysis_type == "basic": # Basic analysis - just market data and news - portfolio_rec = await self.workflow.analyze_portfolio( + workflow_results = await self.workflow.analyze_portfolio( symbols=symbols, portfolio_size=50000, # Default smaller size for quick analysis risk_tolerance="medium", ) + recommendations: List[Dict[str, Any]] = [] + portfolio_recommendation: Optional[Dict[str, Any]] = None + sentiment_analysis: Dict[str, Any] = {} + + if isinstance(workflow_results, dict): + raw_recommendations = workflow_results.get("recommendations", []) + recommendations = [ + rec.dict() if hasattr(rec, "dict") else rec + for rec in raw_recommendations + ] + portfolio_recommendation = workflow_results.get( + "portfolio_recommendation" + ) + sentiment_analysis = workflow_results.get( + "sentiment_analysis", {} + ) + elif workflow_results: + raw_recommendations = getattr( + workflow_results, "recommendations", [] + ) + recommendations = [ + rec.dict() if hasattr(rec, "dict") else rec + for rec in raw_recommendations + ] + portfolio_recommendation = getattr( + workflow_results, "portfolio_recommendation", None + ) + sentiment_analysis = getattr( + workflow_results, "sentiment_analysis", {} + ) + return { "analysis_type": "basic", "symbols": symbols, - "recommendations": ( - [rec.dict() for rec in portfolio_rec.recommendations] - if portfolio_rec - else [] - ), + "recommendations": recommendations, + "portfolio_recommendation": portfolio_recommendation, + "sentiment_analysis": sentiment_analysis, "analysis_timestamp": datetime.now().isoformat(), } diff --git a/src/tradegraph_financial_advisor/reporting/pdf_reporter.py b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py index dccdc73..eb427f6 100644 --- a/src/tradegraph_financial_advisor/reporting/pdf_reporter.py +++ b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py @@ -354,10 +354,41 @@ def _ensure_space( cursor_y = self.page_height - self.margin return cursor_y - def _wrap_text(self, text: str, width: int) -> List[str]: - if not text: + def _wrap_text(self, text: Any, width: int) -> List[str]: + """Safely wrap arbitrary content for drawing in the PDF.""" + if text is None: return ["n/a"] - return textwrap.wrap(text, width=width) or [text] + + normalized: str + if isinstance(text, str): + normalized = text.strip() + elif isinstance(text, (list, tuple)): + flattened = [] + for item in text: + if item is None: + continue + if isinstance(item, str): + flattened.append(item.strip()) + elif isinstance(item, dict): + flattened.append( + ", ".join(f"{k}: {v}" for k, v in item.items() if v is not None) + ) + else: + flattened.append(str(item)) + normalized = "; ".join(filter(None, flattened)) + elif isinstance(text, dict): + normalized = ", ".join( + f"{k}: {v}" for k, v in text.items() if v is not None + ) + else: + normalized = str(text) + + normalized = normalized.strip() + if not normalized: + return ["n/a"] + + wrapped = textwrap.wrap(normalized, width=width) + return wrapped or [normalized] @staticmethod def _format_pct(trend: Optional[Dict[str, Any]]) -> str: diff --git a/uv.lock b/uv.lock index 62da314..1abf450 100644 --- a/uv.lock +++ b/uv.lock @@ -247,7 +247,7 @@ wheels = [ [[package]] name = "black" -version = "25.9.0" +version = "23.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -255,29 +255,21 @@ dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, - { name = "pytokens" }, { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/36/66370f5017b100225ec4950a60caeef60201a10080da57ddb24124453fba/black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940", size = 582156 } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/40/dbe31fc56b218a858c8fc6f5d8d3ba61c1fa7e989d43d4a4574b8b992840/black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7", size = 1715605 }, - { url = "https://files.pythonhosted.org/packages/92/b2/f46800621200eab6479b1f4c0e3ede5b4c06b768e79ee228bc80270bcc74/black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92", size = 1571829 }, - { url = "https://files.pythonhosted.org/packages/4e/64/5c7f66bd65af5c19b4ea86062bb585adc28d51d37babf70969e804dbd5c2/black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713", size = 1631888 }, - { url = "https://files.pythonhosted.org/packages/3b/64/0b9e5bfcf67db25a6eef6d9be6726499a8a72ebab3888c2de135190853d3/black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1", size = 1327056 }, - { url = "https://files.pythonhosted.org/packages/b7/f4/7531d4a336d2d4ac6cc101662184c8e7d068b548d35d874415ed9f4116ef/black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa", size = 1698727 }, - { url = "https://files.pythonhosted.org/packages/28/f9/66f26bfbbf84b949cc77a41a43e138d83b109502cd9c52dfc94070ca51f2/black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d", size = 1555679 }, - { url = "https://files.pythonhosted.org/packages/bf/59/61475115906052f415f518a648a9ac679d7afbc8da1c16f8fdf68a8cebed/black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608", size = 1617453 }, - { url = "https://files.pythonhosted.org/packages/7f/5b/20fd5c884d14550c911e4fb1b0dae00d4abb60a4f3876b449c4d3a9141d5/black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f", size = 1333655 }, - { url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012 }, - { url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421 }, - { url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619 }, - { url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481 }, - { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165 }, - { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259 }, - { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583 }, - { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428 }, - { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363 }, + { url = "https://files.pythonhosted.org/packages/db/f4/7908f71cc71da08df1317a3619f002cbf91927fb5d3ffc7723905a2113f7/black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915", size = 1342273 }, + { url = "https://files.pythonhosted.org/packages/27/70/07aab2623cfd3789786f17e051487a41d5657258c7b1ef8f780512ffea9c/black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9", size = 2676721 }, + { url = "https://files.pythonhosted.org/packages/29/b1/b584fc863c155653963039664a592b3327b002405043b7e761b9b0212337/black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2", size = 1520336 }, + { url = "https://files.pythonhosted.org/packages/6d/b4/0f13ab7f5e364795ff82b76b0f9a4c9c50afda6f1e2feeb8b03fdd7ec57d/black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c", size = 1654611 }, + { url = "https://files.pythonhosted.org/packages/de/b4/76f152c5eb0be5471c22cd18380d31d188930377a1a57969073b89d6615d/black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c", size = 1286657 }, + { url = "https://files.pythonhosted.org/packages/d7/6f/d3832960a3b646b333b7f0d80d336a3c123012e9d9d5dba4a622b2b6181d/black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6", size = 1326112 }, + { url = "https://files.pythonhosted.org/packages/eb/a5/17b40bfd9b607b69fa726b0b3a473d14b093dcd5191ea1a1dd664eccfee3/black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b", size = 2643808 }, + { url = "https://files.pythonhosted.org/packages/69/49/7e1f0cf585b0d607aad3f971f95982cc4208fc77f92363d632d23021ee57/black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d", size = 1503287 }, + { url = "https://files.pythonhosted.org/packages/c0/53/42e312c17cfda5c8fc4b6b396a508218807a3fcbb963b318e49d3ddd11d5/black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70", size = 1638625 }, + { url = "https://files.pythonhosted.org/packages/3f/0d/81dd4194ce7057c199d4f28e4c2a885082d9d929e7a55c514b23784f7787/black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326", size = 1293585 }, + { url = "https://files.pythonhosted.org/packages/ad/e7/4642b7f462381799393fbad894ba4b32db00870a797f0616c197b07129a9/black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4", size = 180965 }, ] [[package]] @@ -3092,15 +3084,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, ] -[[package]] -name = "pytokens" -version = "0.1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/5f/e959a442435e24f6fb5a01aec6c657079ceaca1b3baf18561c3728d681da/pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044", size = 12171 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/e5/63bed382f6a7a5ba70e7e132b8b7b8abbcf4888ffa6be4877698dcfbed7d/pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b", size = 12046 }, -] - [[package]] name = "pytz" version = "2025.2" @@ -4113,7 +4096,7 @@ requires-dist = [ { name = "alpha-vantage", specifier = ">=2.3.0" }, { name = "asyncio", specifier = ">=3.4.3" }, { name = "beautifulsoup4", specifier = ">=4.12.0" }, - { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, + { name = "black", marker = "extra == 'dev'", specifier = "==23.3.0" }, { name = "crawl4ai", specifier = "~=0.7" }, { name = "ddgs", specifier = "~=9.9" }, { name = "duckdb", specifier = ">=0.10.0" }, From fe37902cf679ce996dd81eed106f47662fd81e4e Mon Sep 17 00:00:00 2001 From: Mehran Moazeni Date: Mon, 15 Dec 2025 18:49:06 +0100 Subject: [PATCH 11/11] Fix linting --- src/tradegraph_financial_advisor/main.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tradegraph_financial_advisor/main.py b/src/tradegraph_financial_advisor/main.py index 804d126..4588d8b 100644 --- a/src/tradegraph_financial_advisor/main.py +++ b/src/tradegraph_financial_advisor/main.py @@ -178,9 +178,7 @@ async def quick_analysis( portfolio_recommendation = workflow_results.get( "portfolio_recommendation" ) - sentiment_analysis = workflow_results.get( - "sentiment_analysis", {} - ) + sentiment_analysis = workflow_results.get("sentiment_analysis", {}) elif workflow_results: raw_recommendations = getattr( workflow_results, "recommendations", []