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..0af259f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
# TradeGraph Financial Advisor
@@ -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/assets/TradeGraph.png b/assets/TradeGraph.png
new file mode 100644
index 0000000..d93399d
Binary files /dev/null and b/assets/TradeGraph.png differ
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..804d126 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,49 @@ async def quick_analysis(
if analysis_type == "basic":
# Basic analysis - just market data and news
- portfolio_rec = await self.workflow.analyze_portfolio(
+ workflow_results = await self.workflow.analyze_portfolio(
symbols=symbols,
portfolio_size=50000, # Default smaller size for quick analysis
risk_tolerance="medium",
)
+ recommendations: List[Dict[str, Any]] = []
+ portfolio_recommendation: Optional[Dict[str, Any]] = None
+ sentiment_analysis: Dict[str, Any] = {}
+
+ if isinstance(workflow_results, dict):
+ raw_recommendations = workflow_results.get("recommendations", [])
+ recommendations = [
+ rec.dict() if hasattr(rec, "dict") else rec
+ for rec in raw_recommendations
+ ]
+ portfolio_recommendation = workflow_results.get(
+ "portfolio_recommendation"
+ )
+ sentiment_analysis = workflow_results.get(
+ "sentiment_analysis", {}
+ )
+ elif workflow_results:
+ raw_recommendations = getattr(
+ workflow_results, "recommendations", []
+ )
+ recommendations = [
+ rec.dict() if hasattr(rec, "dict") else rec
+ for rec in raw_recommendations
+ ]
+ portfolio_recommendation = getattr(
+ workflow_results, "portfolio_recommendation", None
+ )
+ sentiment_analysis = getattr(
+ workflow_results, "sentiment_analysis", {}
+ )
+
return {
"analysis_type": "basic",
"symbols": symbols,
- "recommendations": (
- [rec.dict() for rec in portfolio_rec.recommendations]
- if portfolio_rec
- else []
- ),
+ "recommendations": recommendations,
+ "portfolio_recommendation": portfolio_recommendation,
+ "sentiment_analysis": sentiment_analysis,
"analysis_timestamp": datetime.now().isoformat(),
}
@@ -175,6 +219,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 +407,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 +491,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 +533,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 +627,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 +641,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"