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/.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 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/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/pyproject.toml b/pyproject.toml index 896602b..0e1ccc7 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,14 @@ 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", + "kaleido>=0.2.1", + "duckdb>=0.10.0", "fastapi>=0.118.0", + "reportlab>=4.0.0", + "uvicorn>=0.30.0", ] [project.optional-dependencies] @@ -49,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", diff --git a/requirements.txt b/requirements.txt index ef6730b..f1e231c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,15 +7,19 @@ 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 +kaleido>=0.2.1 +duckdb>=0.10.0 +reportlab>=4.0.0 +uvicorn>=0.30.0 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/channel_report_agent.py b/src/tradegraph_financial_advisor/agents/channel_report_agent.py new file mode 100644 index 0000000..b372818 --- /dev/null +++ b/src/tradegraph_financial_advisor/agents/channel_report_agent.py @@ -0,0 +1,284 @@ +"""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 = self._filter_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, + "recommendation_snapshot": self._summarize_recommendations( + recommendations + ), + } + prompt = ( + "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]}" + ) + ] + ) + 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}" + ) + + 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() + ) + if buy_votes > sell_votes: + buy_view = "buy" + elif sell_votes > buy_votes: + buy_view = "reduce" + + 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 + ) + + 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 { + "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": 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: + 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..b6c26ed 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,89 @@ 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 +187,33 @@ 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,139 @@ 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/multi_asset_allocation_agent.py b/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py new file mode 100644 index 0000000..6e0da15 --- /dev/null +++ b/src/tradegraph_financial_advisor/agents/multi_asset_allocation_agent.py @@ -0,0 +1,298 @@ +"""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 + +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/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 fa40417..421cb39 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,27 @@ 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..1f3c0fa 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") @@ -27,8 +30,9 @@ 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} + model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"} @classmethod def get_news_sources_list(cls, v: str) -> List[str]: @@ -38,3 +42,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..4588d8b 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,14 @@ 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 .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, MultiAssetPDFReportWriter class FinancialAdvisor: @@ -21,6 +26,14 @@ 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() + self.multi_asset_agent = MultiAssetAllocationAgent() + self.multi_asset_pdf_writer = MultiAssetPDFReportWriter() async def analyze_portfolio( self, @@ -105,8 +118,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": [ @@ -144,20 +159,47 @@ 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(), } @@ -175,6 +217,85 @@ 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], + *, + 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", []), + } + ) + + 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=recommendations, + symbols=symbols, + portfolio_recommendation=portfolio_rec, + analysis_summary=reference_results.get("analysis_summary", {}), + allocation_chart_path=allocation_chart_path, + 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. @@ -284,6 +405,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(): """ @@ -328,9 +489,37 @@ 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", + ) + 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() + # 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( @@ -342,6 +531,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) @@ -408,7 +625,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}") @@ -422,6 +639,26 @@ 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..7c9c915 --- /dev/null +++ b/src/tradegraph_financial_advisor/reporting/__init__.py @@ -0,0 +1,6 @@ +"""Reporting utilities for TradeGraph.""" + +from .pdf_reporter import ChannelPDFReportWriter +from .multi_asset_reporter import MultiAssetPDFReportWriter + +__all__ = ["ChannelPDFReportWriter", "MultiAssetPDFReportWriter"] 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..e814ea9 --- /dev/null +++ b/src/tradegraph_financial_advisor/reporting/multi_asset_reporter.py @@ -0,0 +1,160 @@ +"""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/src/tradegraph_financial_advisor/reporting/pdf_reporter.py b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py new file mode 100644 index 0000000..eb427f6 --- /dev/null +++ b/src/tradegraph_financial_advisor/reporting/pdf_reporter.py @@ -0,0 +1,412 @@ +"""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.lib.utils import ImageReader +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], + 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) + 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_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", "") + ) + + 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, + 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_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) + + 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_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: + 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 _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: + if cursor_y - min_height <= self.margin: + doc.showPage() + cursor_y = self.page_height - self.margin + return cursor_y + + 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"] + + 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: + if not trend: + return " - " + percent = trend.get("percent_change") + if percent is None: + 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/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..d2e2752 --- /dev/null +++ b/src/tradegraph_financial_advisor/services/channel_stream_service.py @@ -0,0 +1,413 @@ +"""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, + 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 + + 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 [ + 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 self._iter_channels() + ] + payloads = await asyncio.gather(*tasks, return_exceptions=True) + + 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}") + continue + 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]: + 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..a1b924f 100644 --- a/src/tradegraph_financial_advisor/services/local_scraping_service.py +++ b/src/tradegraph_financial_advisor/services/local_scraping_service.py @@ -1,86 +1,134 @@ -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, Optional +from urllib.parse import urlparse + from loguru import logger from ddgs import DDGS from crawl4ai import AsyncWebCrawler + 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: 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 + + new_articles: List[NewsArticle] = [] + + 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 + 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 + 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) + 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( 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): @@ -90,7 +138,43 @@ 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. 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) + + 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/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..90f9200 --- /dev/null +++ b/src/tradegraph_financial_advisor/services/price_trend_service.py @@ -0,0 +1,184 @@ +"""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..2d86339 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 @@ -11,8 +13,25 @@ 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=[ @@ -28,8 +47,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/src/tradegraph_financial_advisor/workflows/analysis_workflow.py b/src/tradegraph_financial_advisor/workflows/analysis_workflow.py index 215b4c6..6d74da3 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: @@ -38,17 +40,24 @@ 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( - model_name=self.llm_model_name + 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 = channel_service or FinancialNewsChannelService() self.workflow = None self._build_workflow() @@ -81,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 @@ -102,6 +110,7 @@ async def analyze_portfolio( messages=[], next_step="collect_news", error_messages=[], + channel_streams={}, ) try: @@ -121,6 +130,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 +143,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 +183,15 @@ 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") ) @@ -398,7 +418,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"] = [] @@ -460,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: @@ -940,7 +960,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 014b136..abdf949 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,21 @@ 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 +from tradegraph_financial_advisor.config.settings import refresh_openai_api_key # 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" +# Ensure global settings pick up the test keys +refresh_openai_api_key() + @pytest.fixture(scope="session") def event_loop(): @@ -186,51 +191,80 @@ 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, - } - - # 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) - - 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), +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(), + }, + ], }, - index=dates, - ) + "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_ticker.history.return_value = mock_history - mock_ticker.quarterly_financials = pd.DataFrame() - mock_ticker.financials = pd.DataFrame() - return mock_ticker +@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", + }, + }, + } + } @pytest.fixture @@ -381,3 +415,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_agents.py b/tests/unit/test_agents.py index 4316a70..aa2b5bf 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, ) @@ -158,70 +157,6 @@ async def test_impact_score_calculation(self): 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): - """Test financial agent initialization.""" - agent = FinancialAnalysisAgent() - assert agent.name == "FinancialAnalysisAgent" - assert "financial" in agent.description.lower() - - @pytest.mark.asyncio - async def test_execute_financial_analysis(self, mock_yfinance_ticker): - """Test financial analysis execution.""" - agent = FinancialAnalysisAgent() - - with patch("yfinance.Ticker", return_value=mock_yfinance_ticker): - 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_yfinance_ticker): - """Test market data extraction.""" - agent = FinancialAnalysisAgent() - - with patch("yfinance.Ticker", return_value=mock_yfinance_ticker): - market_data = await agent._get_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_yfinance_ticker): - """Test technical indicators calculation.""" - agent = FinancialAnalysisAgent() - - with patch("yfinance.Ticker", return_value=mock_yfinance_ticker): - technical_data = await agent._get_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.""" @@ -460,17 +395,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(): - """Test health checks for all agents.""" - agents_to_test = [ - NewsReaderAgent(), - FinancialAnalysisAgent(), - ] - - for agent in agents_to_test: - health_ok = await agent.health_check() - # Health check should return a boolean - assert isinstance(health_ok, bool) diff --git a/tests/unit/test_channels.py b/tests/unit/test_channels.py new file mode 100644 index 0000000..fe6fcfd --- /dev/null +++ b/tests/unit/test_channels.py @@ -0,0 +1,102 @@ +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 + 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 +): + 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", + "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, + channel_payloads=sample_channel_streams, + 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) + 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/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) diff --git a/tests/unit/test_news_repository.py b/tests/unit/test_news_repository.py new file mode 100644 index 0000000..4146a8b --- /dev/null +++ b/tests/unit/test_news_repository.py @@ -0,0 +1,43 @@ +from datetime import datetime, timezone + +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/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() diff --git a/tradegraph.duckdb b/tradegraph.duckdb new file mode 100644 index 0000000..e006d2a Binary files /dev/null and b/tradegraph.duckdb differ diff --git a/uv.lock b/uv.lock index ff70e46..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]] @@ -531,6 +523,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, ] +[[package]] +name = "choreographer" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "logistro" }, + { name = "simplejson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/47/64a035c6f764450ea9f902cbeba14c8c70316c2641125510066d8f912bfa/choreographer-1.2.1.tar.gz", hash = "sha256:022afd72b1e9b0bcb950420b134e70055a294c791b6f36cfb47d89745b701b5f", size = 43399 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/9f/d73dfb85d7a5b1a56a99adc50f2074029468168c970ff5daeade4ad819e4/choreographer-1.2.1-py3-none-any.whl", hash = "sha256:9af5385effa3c204dbc337abf7ac74fd8908ced326a15645dc31dde75718c77e", size = 49338 }, +] + [[package]] name = "click" version = "8.3.0" @@ -787,27 +792,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" @@ -842,6 +826,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, ] +[[package]] +name = "duckdb" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/da/17c3eb5458af69d54dedc8d18e4a32ceaa8ce4d4c699d45d6d8287e790c3/duckdb-1.4.3.tar.gz", hash = "sha256:fea43e03604c713e25a25211ada87d30cd2a044d8f27afab5deba26ac49e5268", size = 18478418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/3a/ea8e237e1ba40203dea4ed6a8798ea51e66a4c4f34605697025e5fa06fdd/duckdb-1.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:efa7f1191c59e34b688fcd4e588c1b903a4e4e1f4804945902cf0b20e08a9001", size = 29016021 }, + { url = "https://files.pythonhosted.org/packages/48/88/07615298a2871362b454237b6a2d7724e6ba0afba2bddedddde5bbf129d5/duckdb-1.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4fef6a053a1c485292000bf0c338bba60f89d334f6a06fc76ba4085a5a322b76", size = 15405906 }, + { url = "https://files.pythonhosted.org/packages/fa/66/b407ab3cd4822191aa5defb27522213b6ba670437c7da09a062d8b75b0a4/duckdb-1.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:702dabbc22b27dc5b73e7599c60deef3d8c59968527c36b391773efddd8f4cf1", size = 13732991 }, + { url = "https://files.pythonhosted.org/packages/33/f0/e8edab80446d87b4e0faf3aaa440f9cfd9d0609c21a4be56174c8ba7d23c/duckdb-1.4.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854b79375fa618f6ffa8d84fb45cbc9db887f6c4834076ea10d20bc106f1fd90", size = 18471503 }, + { url = "https://files.pythonhosted.org/packages/8c/7a/8d257bc847f0ac6a6639ae0a6e7f35f0b5bfbae472ee4846ee32404670a6/duckdb-1.4.3-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bb8bd5a3dd205983726185b280a211eacc9f5bc0c4d4505bec8c87ac33a8ccb", size = 20466012 }, + { url = "https://files.pythonhosted.org/packages/cf/d1/8f6bdaf2da6a076dd63c84ed87fb82d0741c9f4acb3dd476d73ca0a08ffe/duckdb-1.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:d0ff08388ef8b1d1a4c95c321d6c5fa11201b241036b1ee740f9d841df3d6ba2", size = 12328392 }, + { url = "https://files.pythonhosted.org/packages/ec/bc/7c5e50e440c8629495678bc57bdfc1bb8e62f61090f2d5441e2bd0a0ed96/duckdb-1.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:366bf607088053dce845c9d24c202c04d78022436cc5d8e4c9f0492de04afbe7", size = 29019361 }, + { url = "https://files.pythonhosted.org/packages/26/15/c04a4faf0dfddad2259cab72bf0bd4b3d010f2347642541bd254d516bf93/duckdb-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d080e8d1bf2d226423ec781f539c8f6b6ef3fd42a9a58a7160de0a00877a21f", size = 15407465 }, + { url = "https://files.pythonhosted.org/packages/cb/54/a049490187c9529932fc153f7e1b92a9e145586281fe4e03ce0535a0497c/duckdb-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9dc049ba7e906cb49ca2b6d4fbf7b6615ec3883193e8abb93f0bef2652e42dda", size = 13735781 }, + { url = "https://files.pythonhosted.org/packages/14/b7/ee594dcecbc9469ec3cd1fb1f81cb5fa289ab444b80cfb5640c8f467f75f/duckdb-1.4.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b30245375ea94ab528c87c61fc3ab3e36331180b16af92ee3a37b810a745d24", size = 18470729 }, + { url = "https://files.pythonhosted.org/packages/df/5f/a6c1862ed8a96d8d930feb6af5e55aadd983310aab75142468c2cb32a2a3/duckdb-1.4.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7c864df027da1ee95f0c32def67e15d02cd4a906c9c1cbae82c09c5112f526b", size = 20471399 }, + { url = "https://files.pythonhosted.org/packages/5b/80/c05c0b6a6107b618927b7dcabe3bba6a7eecd951f25c9dbcd9c1f9577cc8/duckdb-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:813f189039b46877b5517f1909c7b94a8fe01b4bde2640ab217537ea0fe9b59b", size = 12329359 }, + { url = "https://files.pythonhosted.org/packages/b0/83/9d8fc3413f854effa680dcad1781f68f3ada8679863c0c94ba3b36bae6ff/duckdb-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:fbc63ffdd03835f660155b37a1b6db2005bcd46e5ad398b8cac141eb305d2a3d", size = 13070898 }, + { url = "https://files.pythonhosted.org/packages/5a/d7/fdc2139b94297fc5659110a38adde293d025e320673ae5e472b95d323c50/duckdb-1.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6302452e57aef29aae3977063810ed7b2927967b97912947b9cca45c1c21955f", size = 29033112 }, + { url = "https://files.pythonhosted.org/packages/eb/d9/ca93df1ce19aef8f799e3aaacf754a4dde7e9169c0b333557752d21d076a/duckdb-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:deab351ac43b6282a3270e3d40e3d57b3b50f472d9fd8c30975d88a31be41231", size = 15414646 }, + { url = "https://files.pythonhosted.org/packages/16/90/9f2748e740f5fc05b739e7c5c25aab6ab4363e5da4c3c70419c7121dc806/duckdb-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5634e40e1e2d972e4f75bced1fbdd9e9e90faa26445c1052b27de97ee546944a", size = 13740477 }, + { url = "https://files.pythonhosted.org/packages/5f/ec/279723615b4fb454efd823b7efe97cf2504569e2e74d15defbbd6b027901/duckdb-1.4.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:274d4a31aba63115f23e7e7b401e3e3a937f3626dc9dea820a9c7d3073f450d2", size = 18483715 }, + { url = "https://files.pythonhosted.org/packages/10/63/af20cd20fd7fd6565ea5a1578c16157b6a6e07923e459a6f9b0dc9ada308/duckdb-1.4.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f868a7e6d9b37274a1aa34849ea92aa964e9bd59a5237d6c17e8540533a1e4f", size = 20495188 }, + { url = "https://files.pythonhosted.org/packages/8c/ab/0acb4b64afb2cc6c1d458a391c64e36be40137460f176c04686c965ce0e0/duckdb-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:ef7ef15347ce97201b1b5182a5697682679b04c3374d5a01ac10ba31cf791b95", size = 12335622 }, + { url = "https://files.pythonhosted.org/packages/50/d5/2a795745f6597a5e65770141da6efdc4fd754e5ee6d652f74bcb7f9c7759/duckdb-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:1b9b445970fd18274d5ac07a0b24c032e228f967332fb5ebab3d7db27738c0e4", size = 13075834 }, + { url = "https://files.pythonhosted.org/packages/fd/76/288cca43a10ddd082788e1a71f1dc68d9130b5d078c3ffd0edf2f3a8719f/duckdb-1.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16952ac05bd7e7b39946695452bf450db1ebbe387e1e7178e10f593f2ea7b9a8", size = 29033392 }, + { url = "https://files.pythonhosted.org/packages/64/07/cbad3d3da24af4d1add9bccb5fb390fac726ffa0c0cebd29bf5591cef334/duckdb-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de984cd24a6cbefdd6d4a349f7b9a46e583ca3e58ce10d8def0b20a6e5fcbe78", size = 15414567 }, + { url = "https://files.pythonhosted.org/packages/c4/19/57af0cc66ba2ffb8900f567c9aec188c6ab2a7b3f2260e9c6c3c5f9b57b1/duckdb-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e5457dda91b67258aae30fb1a0df84183a9f6cd27abac1d5536c0d876c6dfa1", size = 13740960 }, + { url = "https://files.pythonhosted.org/packages/73/dd/23152458cf5fd51e813fadda60b9b5f011517634aa4bb9301f5f3aa951d8/duckdb-1.4.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:006aca6a6d6736c441b02ff5c7600b099bb8b7f4de094b8b062137efddce42df", size = 18484312 }, + { url = "https://files.pythonhosted.org/packages/1a/7b/adf3f611f11997fc429d4b00a730604b65d952417f36a10c4be6e38e064d/duckdb-1.4.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2813f4635f4d6681cc3304020374c46aca82758c6740d7edbc237fe3aae2744", size = 20495571 }, + { url = "https://files.pythonhosted.org/packages/40/d5/6b7ddda7713a788ab2d622c7267ec317718f2bdc746ce1fca49b7ff0e50f/duckdb-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:6db124f53a3edcb32b0a896ad3519e37477f7e67bf4811cb41ab60c1ef74e4c8", size = 12335680 }, + { url = "https://files.pythonhosted.org/packages/e8/28/0670135cf54525081fded9bac1254f78984e3b96a6059cd15aca262e3430/duckdb-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:a8b0a8764e1b5dd043d168c8f749314f7a1252b5a260fa415adaa26fa3b958fd", size = 13075161 }, + { url = "https://files.pythonhosted.org/packages/b6/f4/a38651e478fa41eeb8e43a0a9c0d4cd8633adea856e3ac5ac95124b0fdbf/duckdb-1.4.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:316711a9e852bcfe1ed6241a5f654983f67e909e290495f3562cccdf43be8180", size = 29042272 }, + { url = "https://files.pythonhosted.org/packages/16/de/2cf171a66098ce5aeeb7371511bd2b3d7b73a2090603b0b9df39f8aaf814/duckdb-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9e625b2b4d52bafa1fd0ebdb0990c3961dac8bb00e30d327185de95b68202131", size = 15419343 }, + { url = "https://files.pythonhosted.org/packages/35/28/6b0a7830828d4e9a37420d87e80fe6171d2869a9d3d960bf5d7c3b8c7ee4/duckdb-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:130c6760f6c573f9c9fe9aba56adba0fab48811a4871b7b8fd667318b4a3e8da", size = 13748905 }, + { url = "https://files.pythonhosted.org/packages/15/4d/778628e194d63967870873b9581c8a6b4626974aa4fbe09f32708a2d3d3a/duckdb-1.4.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c88effaa557a11267706b01419c542fe42f893dee66e5a6daa5974ea2d4a46", size = 18487261 }, + { url = "https://files.pythonhosted.org/packages/c6/5f/87e43af2e4a0135f9675449563e7c2f9b6f1fe6a2d1691c96b091f3904dd/duckdb-1.4.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b35491db98ccd11d151165497c084a9d29d3dc42fc80abea2715a6c861ca43d", size = 20497138 }, + { url = "https://files.pythonhosted.org/packages/94/41/abec537cc7c519121a2a83b9a6f180af8915fabb433777dc147744513e74/duckdb-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:23b12854032c1a58d0452e2b212afa908d4ce64171862f3792ba9a596ba7c765", size = 12836056 }, + { url = "https://files.pythonhosted.org/packages/b1/5a/8af5b96ce5622b6168854f479ce846cf7fb589813dcc7d8724233c37ded3/duckdb-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:90f241f25cffe7241bf9f376754a5845c74775e00e1c5731119dc88cd71e0cb2", size = 13527759 }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -948,6 +974,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 +1009,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" @@ -1491,6 +1510,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, ] +[[package]] +name = "kaleido" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "choreographer" }, + { name = "logistro" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pytest-timeout" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/76eec859b71eda803a88ea50ed3f270281254656bb23d19eb0a39aa706a0/kaleido-1.2.0.tar.gz", hash = "sha256:fa621a14423e8effa2895a2526be00af0cf21655be1b74b7e382c171d12e71ef", size = 64160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997 }, +] + [[package]] name = "langchain" version = "0.3.27" @@ -1660,6 +1695,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/53/aa31e4d057b3746b3c323ca993003d6cf15ef987e7fe7ceb53681695ae87/litellm-1.80.0-py3-none-any.whl", hash = "sha256:fd0009758f4772257048d74bf79bb64318859adb4ea49a8b66fdbc718cd80b6e", size = 10492975 }, ] +[[package]] +name = "logistro" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555 }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -1994,12 +2038,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 +2524,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 +2793,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" @@ -3024,6 +3042,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3054,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" @@ -3292,6 +3313,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 +3652,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" @@ -3695,6 +3735,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] +[[package]] +name = "simplejson" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/09/2bf3761de89ea2d91bdce6cf107dcd858892d0adc22c995684878826cc6b/simplejson-3.20.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d7286dc11af60a2f76eafb0c2acde2d997e87890e37e24590bb513bec9f1bc5", size = 94039 }, + { url = "https://files.pythonhosted.org/packages/0f/33/c3277db8931f0ae9e54b9292668863365672d90fb0f632f4cf9829cb7d68/simplejson-3.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c01379b4861c3b0aa40cba8d44f2b448f5743999aa68aaa5d3ef7049d4a28a2d", size = 75894 }, + { url = "https://files.pythonhosted.org/packages/fa/ea/ae47b04d03c7c8a7b7b1a8b39a6e27c3bd424e52f4988d70aca6293ff5e5/simplejson-3.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a16b029ca25645b3bc44e84a4f941efa51bf93c180b31bd704ce6349d1fc77c1", size = 76116 }, + { url = "https://files.pythonhosted.org/packages/4b/42/6c9af551e5a8d0f171d6dce3d9d1260068927f7b80f1f09834e07887c8c4/simplejson-3.20.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e22a5fb7b1437ffb057e02e1936a3bfb19084ae9d221ec5e9f4cf85f69946b6", size = 138827 }, + { url = "https://files.pythonhosted.org/packages/2b/22/5e268bbcbe9f75577491e406ec0a5536f5b2fa91a3b52031fea51cd83e1d/simplejson-3.20.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8b6ff02fc7b8555c906c24735908854819b0d0dc85883d453e23ca4c0445d01", size = 146772 }, + { url = "https://files.pythonhosted.org/packages/71/b4/800f14728e2ad666f420dfdb57697ca128aeae7f991b35759c09356b829a/simplejson-3.20.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bfc1c396ad972ba4431130b42307b2321dba14d988580c1ac421ec6a6b7cee3", size = 134497 }, + { url = "https://files.pythonhosted.org/packages/c1/b9/c54eef4226c6ac8e9a389bbe5b21fef116768f97a2dc1a683c716ffe66ef/simplejson-3.20.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a97249ee1aee005d891b5a211faf58092a309f3d9d440bc269043b08f662eda", size = 138172 }, + { url = "https://files.pythonhosted.org/packages/09/36/4e282f5211b34620f1b2e4b51d9ddaab5af82219b9b7b78360a33f7e5387/simplejson-3.20.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1036be00b5edaddbddbb89c0f80ed229714a941cfd21e51386dc69c237201c2", size = 140272 }, + { url = "https://files.pythonhosted.org/packages/aa/b0/94ad2cf32f477c449e1f63c863d8a513e2408d651c4e58fe4b6a7434e168/simplejson-3.20.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5d6f5bacb8cdee64946b45f2680afa3f54cd38e62471ceda89f777693aeca4e4", size = 140468 }, + { url = "https://files.pythonhosted.org/packages/e5/46/827731e4163be3f987cb8ee90f5d444161db8f540b5e735355faa098d9bc/simplejson-3.20.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8db6841fb796ec5af632f677abf21c6425a1ebea0d9ac3ef1a340b8dc69f52b8", size = 148700 }, + { url = "https://files.pythonhosted.org/packages/c7/28/c32121064b1ec2fb7b5d872d9a1abda62df064d35e0160eddfa907118343/simplejson-3.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c0a341f7cc2aae82ee2b31f8a827fd2e51d09626f8b3accc441a6907c88aedb7", size = 141323 }, + { url = "https://files.pythonhosted.org/packages/46/b6/c897c54326fe86dd12d101981171a49361949f4728294f418c3b86a1af77/simplejson-3.20.2-cp310-cp310-win32.whl", hash = "sha256:27f9c01a6bc581d32ab026f515226864576da05ef322d7fc141cd8a15a95ce53", size = 74377 }, + { url = "https://files.pythonhosted.org/packages/ad/87/a6e03d4d80cca99c1fee4e960f3440e2f21be9470e537970f960ca5547f1/simplejson-3.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0a63ec98a4547ff366871bf832a7367ee43d047bcec0b07b66c794e2137b476", size = 76081 }, + { url = "https://files.pythonhosted.org/packages/b9/3e/96898c6c66d9dca3f9bd14d7487bf783b4acc77471b42f979babbb68d4ca/simplejson-3.20.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06190b33cd7849efc413a5738d3da00b90e4a5382fd3d584c841ac20fb828c6f", size = 92633 }, + { url = "https://files.pythonhosted.org/packages/6b/a2/cd2e10b880368305d89dd540685b8bdcc136df2b3c76b5ddd72596254539/simplejson-3.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ad4eac7d858947a30d2c404e61f16b84d16be79eb6fb316341885bdde864fa8", size = 75309 }, + { url = "https://files.pythonhosted.org/packages/5d/02/290f7282eaa6ebe945d35c47e6534348af97472446951dce0d144e013f4c/simplejson-3.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b392e11c6165d4a0fde41754a0e13e1d88a5ad782b245a973dd4b2bdb4e5076a", size = 75308 }, + { url = "https://files.pythonhosted.org/packages/43/91/43695f17b69e70c4b0b03247aa47fb3989d338a70c4b726bbdc2da184160/simplejson-3.20.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51eccc4e353eed3c50e0ea2326173acdc05e58f0c110405920b989d481287e51", size = 143733 }, + { url = "https://files.pythonhosted.org/packages/9b/4b/fdcaf444ac1c3cbf1c52bf00320c499e1cf05d373a58a3731ae627ba5e2d/simplejson-3.20.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306e83d7c331ad833d2d43c76a67f476c4b80c4a13334f6e34bb110e6105b3bd", size = 153397 }, + { url = "https://files.pythonhosted.org/packages/c4/83/21550f81a50cd03599f048a2d588ffb7f4c4d8064ae091511e8e5848eeaa/simplejson-3.20.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f820a6ac2ef0bc338ae4963f4f82ccebdb0824fe9caf6d660670c578abe01013", size = 141654 }, + { url = "https://files.pythonhosted.org/packages/cf/54/d76c0e72ad02450a3e723b65b04f49001d0e73218ef6a220b158a64639cb/simplejson-3.20.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e7a066528a5451433eb3418184f05682ea0493d14e9aae690499b7e1eb6b81", size = 144913 }, + { url = "https://files.pythonhosted.org/packages/3f/49/976f59b42a6956d4aeb075ada16ad64448a985704bc69cd427a2245ce835/simplejson-3.20.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:438680ddde57ea87161a4824e8de04387b328ad51cfdf1eaf723623a3014b7aa", size = 144568 }, + { url = "https://files.pythonhosted.org/packages/60/c7/30bae30424ace8cd791ca660fed454ed9479233810fe25c3f3eab3d9dc7b/simplejson-3.20.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cac78470ae68b8d8c41b6fca97f5bf8e024ca80d5878c7724e024540f5cdaadb", size = 146239 }, + { url = "https://files.pythonhosted.org/packages/79/3e/7f3b7b97351c53746e7b996fcd106986cda1954ab556fd665314756618d2/simplejson-3.20.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7524e19c2da5ef281860a3d74668050c6986be15c9dd99966034ba47c68828c2", size = 154497 }, + { url = "https://files.pythonhosted.org/packages/1d/48/7241daa91d0bf19126589f6a8dcbe8287f4ed3d734e76fd4a092708947be/simplejson-3.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e9b6d845a603b2eef3394eb5e21edb8626cd9ae9a8361d14e267eb969dbe413", size = 148069 }, + { url = "https://files.pythonhosted.org/packages/e6/f4/ef18d2962fe53e7be5123d3784e623859eec7ed97060c9c8536c69d34836/simplejson-3.20.2-cp311-cp311-win32.whl", hash = "sha256:47d8927e5ac927fdd34c99cc617938abb3624b06ff86e8e219740a86507eb961", size = 74158 }, + { url = "https://files.pythonhosted.org/packages/35/fd/3d1158ecdc573fdad81bf3cc78df04522bf3959758bba6597ba4c956c74d/simplejson-3.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba4edf3be8e97e4713d06c3d302cba1ff5c49d16e9d24c209884ac1b8455520c", size = 75911 }, + { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523 }, + { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844 }, + { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655 }, + { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335 }, + { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519 }, + { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571 }, + { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367 }, + { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205 }, + { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823 }, + { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997 }, + { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367 }, + { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285 }, + { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969 }, + { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530 }, + { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846 }, + { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661 }, + { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579 }, + { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797 }, + { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851 }, + { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598 }, + { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498 }, + { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129 }, + { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359 }, + { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717 }, + { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289 }, + { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972 }, + { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309 }, +] + [[package]] name = "six" version = "1.17.0" @@ -3955,7 +4056,10 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "crawl4ai" }, { name = "ddgs" }, + { name = "duckdb" }, { name = "fastapi" }, + { name = "feedparser" }, + { name = "kaleido" }, { name = "langchain" }, { name = "langchain-openai" }, { name = "langgraph" }, @@ -3969,8 +4073,9 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-dateutil" }, { name = "python-dotenv" }, + { name = "reportlab" }, { name = "requests" }, - { name = "yfinance" }, + { name = "uvicorn" }, ] [package.optional-dependencies] @@ -3991,12 +4096,15 @@ 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" }, { 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 = "kaleido", specifier = ">=0.2.1" }, { name = "langchain", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=0.2.0" }, { name = "langgraph", specifier = ">=0.2.0" }, @@ -4014,8 +4122,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 +4222,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 +4403,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"