@drico2008/fincli 0.1.3 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -684
  3. package/fincli/__init__.py +3 -3
  4. package/fincli/app/agents/__init__.py +5 -0
  5. package/fincli/app/agents/registry.py +76 -0
  6. package/fincli/app/analysis/ai_prompts.py +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  8. package/fincli/app/analysis/assistant_context.py +187 -160
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/indicators.py +1 -1
  12. package/fincli/app/analysis/market_structure.py +1 -1
  13. package/fincli/app/analysis/multi_timeframe.py +180 -0
  14. package/fincli/app/analysis/trading_methods.py +144 -0
  15. package/fincli/app/cli/commands.py +105 -77
  16. package/fincli/app/cli/router.py +2143 -1121
  17. package/fincli/app/connectors/__init__.py +5 -0
  18. package/fincli/app/connectors/catalog.py +148 -0
  19. package/fincli/app/connectors/news_connectors.py +412 -0
  20. package/fincli/app/modules/alerts.py +80 -0
  21. package/fincli/app/modules/economic_calendar.py +374 -1
  22. package/fincli/app/modules/reports.py +151 -0
  23. package/fincli/app/modules/scanner.py +111 -93
  24. package/fincli/app/modules/session_history.py +113 -0
  25. package/fincli/app/modules/transactions.py +84 -84
  26. package/fincli/app/modules/user_profile.py +84 -0
  27. package/fincli/app/plugins/loader.py +72 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +8 -7
  29. package/fincli/app/providers/ai/gemini_provider.py +8 -7
  30. package/fincli/app/providers/ai/groq_provider.py +8 -7
  31. package/fincli/app/providers/ai/huggingface_provider.py +8 -7
  32. package/fincli/app/providers/ai/manager.py +60 -60
  33. package/fincli/app/providers/ai/openai_provider.py +8 -7
  34. package/fincli/app/providers/ai/openrouter_provider.py +8 -7
  35. package/fincli/app/providers/ai/together_provider.py +8 -7
  36. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  37. package/fincli/app/providers/market/base.py +98 -77
  38. package/fincli/app/providers/market/custom_provider.py +186 -169
  39. package/fincli/app/providers/market/manager.py +85 -2
  40. package/fincli/app/providers/market/news_provider.py +4 -4
  41. package/fincli/app/providers/market/symbols.py +143 -0
  42. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  43. package/fincli/app/providers/market/yfinance_provider.py +1 -1
  44. package/fincli/app/research/__init__.py +7 -0
  45. package/fincli/app/research/engine.py +75 -0
  46. package/fincli/app/research/formatter.py +22 -0
  47. package/fincli/app/research/models.py +18 -0
  48. package/fincli/app/research/prompt_builder.py +47 -0
  49. package/fincli/app/services/macro_data.py +50 -0
  50. package/fincli/app/services/market_data.py +203 -203
  51. package/fincli/app/services/news_aggregator.py +90 -0
  52. package/fincli/app/services/web_research.py +267 -0
  53. package/fincli/app/storage/cache.py +2 -2
  54. package/fincli/app/storage/config.py +122 -88
  55. package/fincli/app/storage/database.py +201 -85
  56. package/fincli/app/storage/secrets.py +12 -3
  57. package/fincli/app/tui/components.py +68 -50
  58. package/fincli/app/tui/layout.py +270 -258
  59. package/fincli/app/tui/market_provider_selector.py +6 -1
  60. package/fincli/app/tui/model_selector.py +11 -3
  61. package/fincli/app/tui/theme.py +134 -74
  62. package/fincli/app/utils/formatting.py +125 -12
  63. package/npm/bin/fincli.js +9 -2
  64. package/package.json +23 -23
  65. package/pyproject.toml +35 -35
@@ -0,0 +1,47 @@
1
+ """Prompt builder for deep research mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.research.models import ResearchBrief
6
+
7
+
8
+ RESEARCH_WORKSPACE_PROMPT = """
9
+ You are FinCLI Research Workspace.
10
+
11
+ Rules:
12
+ - Build a concise investment/trading research note from the provided data only.
13
+ - Do not invent price, news, fundamentals, or certainty.
14
+ - Do not copy the opening summary as the final summary.
15
+ - Prioritize decision-useful points over long explanation.
16
+ - Separate facts, interpretation, and risk.
17
+ - Keep output short: max 8 bullets plus final summary.
18
+ - Use slash-command context correctly: FinCLI commands start with "/", not "fincli".
19
+ - This is educational market research, not financial advice.
20
+ """.strip()
21
+
22
+
23
+ def build_research_prompt(brief: ResearchBrief) -> str:
24
+ overview = brief.overview
25
+ news = "\n".join(f"- {item.title} ({item.source}) {item.summary}" for item in overview.news) or "- No news."
26
+ fundamentals = overview.fundamentals
27
+ fundamentals_text = (
28
+ "No fundamentals."
29
+ if fundamentals is None
30
+ else (
31
+ f"market_cap={fundamentals.market_cap}; pe={fundamentals.pe_ratio}; eps={fundamentals.eps}; "
32
+ f"revenue={fundamentals.revenue}; sector={fundamentals.sector}; industry={fundamentals.industry}"
33
+ )
34
+ )
35
+ return (
36
+ f"{RESEARCH_WORKSPACE_PROMPT}\n\n"
37
+ f"Symbol: {brief.symbol}\n"
38
+ f"Mode: {brief.mode}\n"
39
+ f"Quote: {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status})\n"
40
+ f"Data Quality: {overview.data_quality.score}/100; OHLCV={overview.data_quality.ohlcv}; News={overview.data_quality.news}; Fundamentals={overview.data_quality.fundamentals}\n"
41
+ f"Technical: trend={overview.technical.trend_bias}; rsi={overview.technical.rsi}; macd={overview.technical.macd}; support={overview.technical.support}; resistance={overview.technical.resistance}; atr={overview.technical.atr}\n"
42
+ f"Structure: trend={overview.structure.trend}; pattern={overview.structure.latest_pattern}; bos={overview.structure.break_of_structure}; choch={overview.structure.change_of_character}\n"
43
+ f"Decision Points:\n{chr(10).join(f'- {point}' for point in brief.decision_points)}\n"
44
+ f"Risks:\n{chr(10).join(f'- {risk}' for risk in brief.risks)}\n"
45
+ f"News:\n{news}\n"
46
+ f"Fundamentals: {fundamentals_text}\n"
47
+ )
@@ -0,0 +1,50 @@
1
+ """Macro data service with offline-first fallback rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class MacroIndicator:
11
+ name: str
12
+ region: str
13
+ value: str
14
+ period: str
15
+ source: str
16
+ note: str
17
+
18
+
19
+ class MacroDataService:
20
+ """Return macro context from free fallback datasets.
21
+
22
+ v0.2.2 keeps this deterministic/offline so /macro remains usable without API keys.
23
+ Provider-backed DBnomics/FRED/World Bank adapters can hydrate this shape later.
24
+ """
25
+
26
+ def indicators(self, query: str = "") -> list[MacroIndicator]:
27
+ normalized = query.strip().lower()
28
+ rows = _fallback_rows()
29
+ if not normalized:
30
+ return rows
31
+ filtered = [
32
+ row
33
+ for row in rows
34
+ if normalized in row.region.lower()
35
+ or normalized in row.name.lower()
36
+ or normalized in row.note.lower()
37
+ ]
38
+ return filtered or rows[:5]
39
+
40
+
41
+ def _fallback_rows() -> list[MacroIndicator]:
42
+ period = date.today().strftime("%Y")
43
+ return [
44
+ MacroIndicator("Policy Rate", "United States", "provider required", period, "Fallback", "Watch FRED/Fed data for rate path."),
45
+ MacroIndicator("Inflation", "United States", "provider required", period, "Fallback", "CPI trend drives USD, yields, and risk assets."),
46
+ MacroIndicator("GDP Growth", "World", "provider required", period, "Fallback", "Use World Bank/IMF for actual country values."),
47
+ MacroIndicator("Policy Rate", "Indonesia", "provider required", period, "Fallback", "BI rate, USD strength, and capital flow affect IDR."),
48
+ MacroIndicator("Inflation", "Indonesia", "provider required", period, "Fallback", "Inflation surprise can affect BI policy expectations."),
49
+ MacroIndicator("PMI", "Euro Area", "provider required", period, "Fallback", "Growth momentum proxy for EUR and European equities."),
50
+ ]
@@ -1,203 +1,203 @@
1
- """Market data service with provider fallback chain."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- from concurrent.futures import ThreadPoolExecutor
7
- from dataclasses import asdict
8
- from datetime import datetime
9
- from typing import Any, Awaitable
10
-
11
- from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
12
- from fincli.app.storage.market_cache import MarketCache
13
- from fincli.app.utils.errors import ProviderError
14
-
15
-
16
- class MarketDataService:
17
- """Fetch market data through a prioritized provider chain."""
18
-
19
- def __init__(
20
- self,
21
- providers: list[BaseMarketProvider],
22
- cache: MarketCache | None = None,
23
- cache_ttl_seconds: int = 300,
24
- ) -> None:
25
- if not providers:
26
- raise ProviderError("MarketDataService membutuhkan minimal satu provider.")
27
- self.providers = providers
28
- self.cache = cache
29
- self.cache_ttl_seconds = cache_ttl_seconds
30
- self.last_errors: list[str] = []
31
-
32
- @property
33
- def primary_provider(self) -> BaseMarketProvider:
34
- return self.providers[0]
35
-
36
- async def quote(self, symbol: str) -> Quote:
37
- cache_key = self._cache_key(symbol)
38
- cached = self._cache_get("quote", cache_key)
39
- if isinstance(cached, dict):
40
- return _quote_from_payload(cached)
41
- quote = await self._with_fallback("quote", symbol)
42
- self._cache_set("quote", cache_key, _quote_to_payload(quote))
43
- return quote
44
-
45
- async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
46
- cache_key = self._cache_key(symbol, period, interval)
47
- cached = self._cache_get("history", cache_key)
48
- if isinstance(cached, list):
49
- return [_candle_from_payload(item) for item in cached if isinstance(item, dict)]
50
- candles = await self._with_fallback("history", symbol, period, interval)
51
- if candles:
52
- self._cache_set("history", cache_key, [_candle_to_payload(candle) for candle in candles])
53
- return candles
54
-
55
- async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
56
- cache_key = self._cache_key(symbol, str(limit))
57
- cached = self._cache_get("news", cache_key)
58
- if isinstance(cached, list):
59
- return [_news_from_payload(item) for item in cached if isinstance(item, dict)]
60
- items = await self._with_fallback("news", symbol, limit)
61
- self._cache_set("news", cache_key, [_news_to_payload(item) for item in items])
62
- return items
63
-
64
- async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
65
- cache_key = self._cache_key(symbol)
66
- cached = self._cache_get("fundamentals", cache_key)
67
- if isinstance(cached, dict):
68
- return _fundamentals_from_payload(cached)
69
- snapshot = await self._with_fallback("fundamentals", symbol)
70
- self._cache_set("fundamentals", cache_key, _fundamentals_to_payload(snapshot))
71
- return snapshot
72
-
73
- async def status(self) -> ProviderStatus:
74
- provider = self.primary_provider
75
- try:
76
- return await provider.status()
77
- except Exception as exc: # noqa: BLE001
78
- return ProviderStatus(
79
- name=getattr(provider, "name", "unknown"),
80
- realtime=False,
81
- status="unavailable",
82
- message=str(exc),
83
- )
84
-
85
- async def _with_fallback(self, method_name: str, *args: object) -> Any:
86
- errors: list[str] = []
87
- for provider in self.providers:
88
- try:
89
- method = getattr(provider, method_name)
90
- return await method(*args)
91
- except Exception as exc: # noqa: BLE001
92
- errors.append(f"{getattr(provider, 'name', 'unknown')}: {exc}")
93
- self.last_errors = errors
94
- raise ProviderError(
95
- f"Semua provider gagal untuk {method_name}.",
96
- "\n".join(errors),
97
- )
98
-
99
- def _cache_key(self, symbol: str, *parts: object) -> str:
100
- provider_chain = ",".join(provider.name for provider in self.providers)
101
- normalized = [symbol.upper(), *(str(part).lower() for part in parts), f"providers={provider_chain}"]
102
- return "|".join(normalized)
103
-
104
- def _cache_get(self, namespace: str, cache_key: str) -> dict[str, Any] | list[Any] | None:
105
- if self.cache is None:
106
- return None
107
- return self.cache.get(namespace, cache_key)
108
-
109
- def _cache_set(self, namespace: str, cache_key: str, payload: dict[str, Any] | list[Any]) -> None:
110
- if self.cache is None:
111
- return
112
- self.cache.set(namespace, cache_key, payload, self.cache_ttl_seconds)
113
-
114
- def run(self, awaitable: Awaitable[Any]) -> Any:
115
- try:
116
- asyncio.get_running_loop()
117
- except RuntimeError:
118
- return asyncio.run(awaitable)
119
- with ThreadPoolExecutor(max_workers=1) as executor:
120
- future = executor.submit(asyncio.run, awaitable)
121
- return future.result()
122
-
123
-
124
- def _quote_to_payload(quote: Quote) -> dict[str, Any]:
125
- payload = asdict(quote)
126
- payload["timestamp"] = quote.timestamp.isoformat()
127
- return payload
128
-
129
-
130
- def _quote_from_payload(payload: dict[str, Any]) -> Quote:
131
- return Quote(
132
- symbol=str(payload["symbol"]),
133
- price=None if payload.get("price") is None else float(payload["price"]),
134
- currency=str(payload.get("currency", "USD")),
135
- provider=str(payload.get("provider", "cache")),
136
- timestamp=_parse_datetime(payload.get("timestamp")),
137
- status=str(payload.get("status", "cached")),
138
- )
139
-
140
-
141
- def _candle_to_payload(candle: Candle) -> dict[str, Any]:
142
- payload = asdict(candle)
143
- payload["timestamp"] = candle.timestamp.isoformat()
144
- return payload
145
-
146
-
147
- def _candle_from_payload(payload: dict[str, Any]) -> Candle:
148
- return Candle(
149
- timestamp=_parse_datetime(payload.get("timestamp")),
150
- open=float(payload["open"]),
151
- high=float(payload["high"]),
152
- low=float(payload["low"]),
153
- close=float(payload["close"]),
154
- volume=float(payload.get("volume", 0)),
155
- )
156
-
157
-
158
- def _news_to_payload(item: NewsItem) -> dict[str, Any]:
159
- payload = asdict(item)
160
- payload["published_at"] = item.published_at.isoformat() if item.published_at else None
161
- return payload
162
-
163
-
164
- def _news_from_payload(payload: dict[str, Any]) -> NewsItem:
165
- published_at = payload.get("published_at")
166
- return NewsItem(
167
- title=str(payload.get("title", "")),
168
- source=str(payload.get("source", "")),
169
- url=None if payload.get("url") is None else str(payload.get("url")),
170
- published_at=None if published_at is None else _parse_datetime(published_at),
171
- summary=str(payload.get("summary", "")),
172
- )
173
-
174
-
175
- def _fundamentals_to_payload(snapshot: FundamentalSnapshot) -> dict[str, Any]:
176
- return asdict(snapshot)
177
-
178
-
179
- def _fundamentals_from_payload(payload: dict[str, Any]) -> FundamentalSnapshot:
180
- return FundamentalSnapshot(
181
- symbol=str(payload["symbol"]),
182
- provider=str(payload.get("provider", "cache")),
183
- currency=str(payload.get("currency", "USD")),
184
- market_cap=_optional_float(payload.get("market_cap")),
185
- pe_ratio=_optional_float(payload.get("pe_ratio")),
186
- eps=_optional_float(payload.get("eps")),
187
- revenue=_optional_float(payload.get("revenue")),
188
- beta=_optional_float(payload.get("beta")),
189
- sector=None if payload.get("sector") is None else str(payload.get("sector")),
190
- industry=None if payload.get("industry") is None else str(payload.get("industry")),
191
- )
192
-
193
-
194
- def _parse_datetime(value: object) -> datetime:
195
- if isinstance(value, datetime):
196
- return value
197
- return datetime.fromisoformat(str(value))
198
-
199
-
200
- def _optional_float(value: object) -> float | None:
201
- if value is None:
202
- return None
203
- return float(value)
1
+ """Market data service with provider fallback chain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import asdict
8
+ from datetime import datetime
9
+ from typing import Any, Awaitable
10
+
11
+ from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
12
+ from fincli.app.storage.market_cache import MarketCache
13
+ from fincli.app.utils.errors import ProviderError
14
+
15
+
16
+ class MarketDataService:
17
+ """Fetch market data through a prioritized provider chain."""
18
+
19
+ def __init__(
20
+ self,
21
+ providers: list[BaseMarketProvider],
22
+ cache: MarketCache | None = None,
23
+ cache_ttl_seconds: int = 300,
24
+ ) -> None:
25
+ if not providers:
26
+ raise ProviderError("MarketDataService membutuhkan minimal satu provider.")
27
+ self.providers = providers
28
+ self.cache = cache
29
+ self.cache_ttl_seconds = cache_ttl_seconds
30
+ self.last_errors: list[str] = []
31
+
32
+ @property
33
+ def primary_provider(self) -> BaseMarketProvider:
34
+ return self.providers[0]
35
+
36
+ async def quote(self, symbol: str) -> Quote:
37
+ cache_key = self._cache_key(symbol)
38
+ cached = self._cache_get("quote", cache_key)
39
+ if isinstance(cached, dict):
40
+ return _quote_from_payload(cached)
41
+ quote = await self._with_fallback("quote", symbol)
42
+ self._cache_set("quote", cache_key, _quote_to_payload(quote))
43
+ return quote
44
+
45
+ async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
46
+ cache_key = self._cache_key(symbol, period, interval)
47
+ cached = self._cache_get("history", cache_key)
48
+ if isinstance(cached, list):
49
+ return [_candle_from_payload(item) for item in cached if isinstance(item, dict)]
50
+ candles = await self._with_fallback("history", symbol, period, interval)
51
+ if candles:
52
+ self._cache_set("history", cache_key, [_candle_to_payload(candle) for candle in candles])
53
+ return candles
54
+
55
+ async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
56
+ cache_key = self._cache_key(symbol, str(limit))
57
+ cached = self._cache_get("news", cache_key)
58
+ if isinstance(cached, list):
59
+ return [_news_from_payload(item) for item in cached if isinstance(item, dict)]
60
+ items = await self._with_fallback("news", symbol, limit)
61
+ self._cache_set("news", cache_key, [_news_to_payload(item) for item in items])
62
+ return items
63
+
64
+ async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
65
+ cache_key = self._cache_key(symbol)
66
+ cached = self._cache_get("fundamentals", cache_key)
67
+ if isinstance(cached, dict):
68
+ return _fundamentals_from_payload(cached)
69
+ snapshot = await self._with_fallback("fundamentals", symbol)
70
+ self._cache_set("fundamentals", cache_key, _fundamentals_to_payload(snapshot))
71
+ return snapshot
72
+
73
+ async def status(self) -> ProviderStatus:
74
+ provider = self.primary_provider
75
+ try:
76
+ return await provider.status()
77
+ except Exception as exc: # noqa: BLE001
78
+ return ProviderStatus(
79
+ name=getattr(provider, "name", "unknown"),
80
+ realtime=False,
81
+ status="unavailable",
82
+ message=str(exc),
83
+ )
84
+
85
+ async def _with_fallback(self, method_name: str, *args: object) -> Any:
86
+ errors: list[str] = []
87
+ for provider in self.providers:
88
+ try:
89
+ method = getattr(provider, method_name)
90
+ return await method(*args)
91
+ except Exception as exc: # noqa: BLE001
92
+ errors.append(f"{getattr(provider, 'name', 'unknown')}: {exc}")
93
+ self.last_errors = errors
94
+ raise ProviderError(
95
+ f"Semua provider gagal untuk {method_name}.",
96
+ "\n".join(errors),
97
+ )
98
+
99
+ def _cache_key(self, symbol: str, *parts: object) -> str:
100
+ provider_chain = ",".join(provider.name for provider in self.providers)
101
+ normalized = [symbol.upper(), *(str(part).lower() for part in parts), f"providers={provider_chain}"]
102
+ return "|".join(normalized)
103
+
104
+ def _cache_get(self, namespace: str, cache_key: str) -> dict[str, Any] | list[Any] | None:
105
+ if self.cache is None:
106
+ return None
107
+ return self.cache.get(namespace, cache_key)
108
+
109
+ def _cache_set(self, namespace: str, cache_key: str, payload: dict[str, Any] | list[Any]) -> None:
110
+ if self.cache is None:
111
+ return
112
+ self.cache.set(namespace, cache_key, payload, self.cache_ttl_seconds)
113
+
114
+ def run(self, awaitable: Awaitable[Any]) -> Any:
115
+ try:
116
+ asyncio.get_running_loop()
117
+ except RuntimeError:
118
+ return asyncio.run(awaitable)
119
+ with ThreadPoolExecutor(max_workers=1) as executor:
120
+ future = executor.submit(asyncio.run, awaitable)
121
+ return future.result()
122
+
123
+
124
+ def _quote_to_payload(quote: Quote) -> dict[str, Any]:
125
+ payload = asdict(quote)
126
+ payload["timestamp"] = quote.timestamp.isoformat()
127
+ return payload
128
+
129
+
130
+ def _quote_from_payload(payload: dict[str, Any]) -> Quote:
131
+ return Quote(
132
+ symbol=str(payload["symbol"]),
133
+ price=None if payload.get("price") is None else float(payload["price"]),
134
+ currency=str(payload.get("currency", "USD")),
135
+ provider=str(payload.get("provider", "cache")),
136
+ timestamp=_parse_datetime(payload.get("timestamp")),
137
+ status=str(payload.get("status", "cached")),
138
+ )
139
+
140
+
141
+ def _candle_to_payload(candle: Candle) -> dict[str, Any]:
142
+ payload = asdict(candle)
143
+ payload["timestamp"] = candle.timestamp.isoformat()
144
+ return payload
145
+
146
+
147
+ def _candle_from_payload(payload: dict[str, Any]) -> Candle:
148
+ return Candle(
149
+ timestamp=_parse_datetime(payload.get("timestamp")),
150
+ open=float(payload["open"]),
151
+ high=float(payload["high"]),
152
+ low=float(payload["low"]),
153
+ close=float(payload["close"]),
154
+ volume=float(payload.get("volume", 0)),
155
+ )
156
+
157
+
158
+ def _news_to_payload(item: NewsItem) -> dict[str, Any]:
159
+ payload = asdict(item)
160
+ payload["published_at"] = item.published_at.isoformat() if item.published_at else None
161
+ return payload
162
+
163
+
164
+ def _news_from_payload(payload: dict[str, Any]) -> NewsItem:
165
+ published_at = payload.get("published_at")
166
+ return NewsItem(
167
+ title=str(payload.get("title", "")),
168
+ source=str(payload.get("source", "")),
169
+ url=None if payload.get("url") is None else str(payload.get("url")),
170
+ published_at=None if published_at is None else _parse_datetime(published_at),
171
+ summary=str(payload.get("summary", "")),
172
+ )
173
+
174
+
175
+ def _fundamentals_to_payload(snapshot: FundamentalSnapshot) -> dict[str, Any]:
176
+ return asdict(snapshot)
177
+
178
+
179
+ def _fundamentals_from_payload(payload: dict[str, Any]) -> FundamentalSnapshot:
180
+ return FundamentalSnapshot(
181
+ symbol=str(payload["symbol"]),
182
+ provider=str(payload.get("provider", "cache")),
183
+ currency=str(payload.get("currency", "USD")),
184
+ market_cap=_optional_float(payload.get("market_cap")),
185
+ pe_ratio=_optional_float(payload.get("pe_ratio")),
186
+ eps=_optional_float(payload.get("eps")),
187
+ revenue=_optional_float(payload.get("revenue")),
188
+ beta=_optional_float(payload.get("beta")),
189
+ sector=None if payload.get("sector") is None else str(payload.get("sector")),
190
+ industry=None if payload.get("industry") is None else str(payload.get("industry")),
191
+ )
192
+
193
+
194
+ def _parse_datetime(value: object) -> datetime:
195
+ if isinstance(value, datetime):
196
+ return value
197
+ return datetime.fromisoformat(str(value))
198
+
199
+
200
+ def _optional_float(value: object) -> float | None:
201
+ if value is None:
202
+ return None
203
+ return float(value)
@@ -0,0 +1,90 @@
1
+ """News aggregation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ from fincli.app.connectors.news_connectors import NewsConnectorManager
9
+ from fincli.app.providers.market.base import NewsItem
10
+ from fincli.app.services.market_data import MarketDataService
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class NewsDesk:
15
+ symbol: str
16
+ provider_chain: tuple[str, ...]
17
+ items: list[NewsItem]
18
+ note: str
19
+ errors: tuple[str, ...] = ()
20
+ lookback_days: int | None = None
21
+
22
+
23
+ class NewsAggregator:
24
+ def __init__(
25
+ self,
26
+ market_service: MarketDataService,
27
+ news_connectors: NewsConnectorManager | None = None,
28
+ priority: list[str] | None = None,
29
+ ) -> None:
30
+ self.market_service = market_service
31
+ self.news_connectors = news_connectors or NewsConnectorManager()
32
+ self.priority = priority or ["yfinance", "google_news_rss", "yahoo_finance_rss"]
33
+
34
+ async def latest(self, symbol: str, limit: int = 12, lookback_days: int | None = None) -> NewsDesk:
35
+ normalized = symbol.upper()
36
+ items: list[NewsItem] = []
37
+ errors: list[str] = []
38
+ seen: set[str] = set()
39
+ provider_chain = tuple(_dedupe(self.priority))
40
+
41
+ for provider in provider_chain:
42
+ try:
43
+ fetched = await self._fetch_provider(provider, normalized, max(limit - len(items), 1))
44
+ except Exception as exc: # noqa: BLE001 - fallback chain should continue
45
+ errors.append(f"{provider}: {exc}")
46
+ continue
47
+ for item in fetched:
48
+ if lookback_days is not None and not _within_lookback(item, lookback_days):
49
+ continue
50
+ key = (item.url or item.title).strip().lower()
51
+ if key and key not in seen:
52
+ seen.add(key)
53
+ items.append(item)
54
+ if len(items) >= limit:
55
+ break
56
+ if len(items) >= limit:
57
+ break
58
+
59
+ note = "Provider-backed news. Realtime/delayed status depends on provider entitlement."
60
+ if not items:
61
+ note = "No news returned by active providers. Try /research <symbol> --deep or configure /news_model priority."
62
+ elif errors:
63
+ note = f"{note} Fallback used after {len(errors)} provider error(s)."
64
+ return NewsDesk(normalized, provider_chain, items, note, tuple(errors), lookback_days)
65
+
66
+ async def _fetch_provider(self, provider: str, symbol: str, limit: int) -> list[NewsItem]:
67
+ if provider == "yfinance" or any(item.name == provider for item in self.market_service.providers):
68
+ return await self.market_service.news(symbol, limit=limit)
69
+ return await self.news_connectors.fetch(provider, symbol, limit=limit)
70
+
71
+
72
+ def _dedupe(values: list[str]) -> list[str]:
73
+ seen: set[str] = set()
74
+ result: list[str] = []
75
+ for value in values:
76
+ normalized = value.strip().lower()
77
+ if normalized and normalized not in seen:
78
+ seen.add(normalized)
79
+ result.append(normalized)
80
+ return result
81
+
82
+
83
+ def _within_lookback(item: NewsItem, lookback_days: int) -> bool:
84
+ if item.published_at is None:
85
+ return True
86
+ published = item.published_at
87
+ if published.tzinfo is None:
88
+ published = published.replace(tzinfo=timezone.utc)
89
+ cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days)
90
+ return published >= cutoff