@drico2008/fincli 0.1.9 → 0.3.0

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -625
  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 +26 -14
  7. package/fincli/app/analysis/analyzer.py +107 -96
  8. package/fincli/app/analysis/assistant_context.py +187 -186
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/multi_timeframe.py +180 -0
  12. package/fincli/app/analysis/trading_methods.py +144 -0
  13. package/fincli/app/cli/commands.py +108 -81
  14. package/fincli/app/cli/router.py +2327 -1237
  15. package/fincli/app/connectors/__init__.py +5 -0
  16. package/fincli/app/connectors/catalog.py +148 -0
  17. package/fincli/app/connectors/news_connectors.py +412 -0
  18. package/fincli/app/modules/alerts.py +80 -0
  19. package/fincli/app/modules/economic_calendar.py +374 -1
  20. package/fincli/app/modules/portfolio_risk.py +305 -0
  21. package/fincli/app/modules/reports.py +151 -0
  22. package/fincli/app/modules/scanner.py +111 -93
  23. package/fincli/app/modules/transactions.py +84 -84
  24. package/fincli/app/modules/user_profile.py +84 -0
  25. package/fincli/app/plugins/loader.py +72 -0
  26. package/fincli/app/providers/ai/manager.py +60 -60
  27. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  28. package/fincli/app/providers/market/base.py +98 -77
  29. package/fincli/app/providers/market/custom_provider.py +186 -169
  30. package/fincli/app/providers/market/manager.py +84 -1
  31. package/fincli/app/providers/market/symbols.py +143 -0
  32. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  33. package/fincli/app/providers/reliability.py +86 -0
  34. package/fincli/app/research/__init__.py +8 -0
  35. package/fincli/app/research/engine.py +137 -0
  36. package/fincli/app/research/exporter.py +91 -0
  37. package/fincli/app/research/formatter.py +27 -0
  38. package/fincli/app/research/models.py +24 -0
  39. package/fincli/app/research/prompt_builder.py +54 -0
  40. package/fincli/app/services/macro_data.py +50 -0
  41. package/fincli/app/services/market_data.py +274 -169
  42. package/fincli/app/services/market_overview.py +42 -1
  43. package/fincli/app/services/news_aggregator.py +95 -0
  44. package/fincli/app/services/web_research.py +267 -267
  45. package/fincli/app/storage/config.py +122 -88
  46. package/fincli/app/storage/database.py +209 -99
  47. package/fincli/app/storage/provider_metrics.py +61 -0
  48. package/fincli/app/storage/secrets.py +26 -2
  49. package/fincli/app/tui/components.py +68 -50
  50. package/fincli/app/tui/layout.py +269 -258
  51. package/fincli/app/tui/market_provider_selector.py +3 -1
  52. package/fincli/app/tui/theme.py +134 -74
  53. package/fincli/app/utils/formatting.py +123 -60
  54. package/package.json +22 -20
  55. package/pyproject.toml +35 -35
@@ -1,203 +1,308 @@
1
- """Market data service with provider fallback chain."""
2
-
3
- from __future__ import annotations
4
-
1
+ """Market data service with provider fallback chain."""
2
+
3
+ from __future__ import annotations
4
+
5
5
  import asyncio
6
6
  from concurrent.futures import ThreadPoolExecutor
7
7
  from dataclasses import asdict
8
8
  from datetime import datetime
9
+ from time import perf_counter
9
10
  from typing import Any, Awaitable
10
11
 
11
12
  from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
13
+ from fincli.app.providers.reliability import ProviderResult, classify_payload, classify_provider_error
12
14
  from fincli.app.storage.market_cache import MarketCache
13
15
  from fincli.app.utils.errors import ProviderError
14
-
15
-
16
+
17
+
16
18
  class MarketDataService:
17
- """Fetch market data through a prioritized provider chain."""
18
-
19
- def __init__(
20
- self,
19
+ """Fetch market data through a prioritized provider chain."""
20
+
21
+ def __init__(
22
+ self,
21
23
  providers: list[BaseMarketProvider],
22
24
  cache: MarketCache | None = None,
23
25
  cache_ttl_seconds: int = 300,
26
+ metrics_store: Any | None = None,
24
27
  ) -> None:
25
- if not providers:
26
- raise ProviderError("MarketDataService membutuhkan minimal satu provider.")
27
- self.providers = providers
28
+ if not providers:
29
+ raise ProviderError("MarketDataService membutuhkan minimal satu provider.")
30
+ self.providers = providers
28
31
  self.cache = cache
29
32
  self.cache_ttl_seconds = cache_ttl_seconds
33
+ self.metrics_store = metrics_store
30
34
  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
-
35
+ self.provider_results: list[ProviderResult] = []
36
+ self.provider_metrics: dict[str, ProviderRuntimeMetrics] = {
37
+ getattr(provider, "name", "unknown"): ProviderRuntimeMetrics(getattr(provider, "name", "unknown"))
38
+ for provider in providers
39
+ }
40
+
41
+ @property
42
+ def primary_provider(self) -> BaseMarketProvider:
43
+ return self.providers[0]
44
+
45
+ async def quote(self, symbol: str) -> Quote:
46
+ cache_key = self._cache_key(symbol)
47
+ cached = self._cache_get("quote", cache_key)
48
+ if isinstance(cached, dict):
49
+ return _quote_from_payload(cached)
50
+ quote = await self._with_fallback("quote", symbol)
51
+ self._cache_set("quote", cache_key, _quote_to_payload(quote))
52
+ return quote
53
+
54
+ async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
55
+ cache_key = self._cache_key(symbol, period, interval)
56
+ cached = self._cache_get("history", cache_key)
57
+ if isinstance(cached, list):
58
+ return [_candle_from_payload(item) for item in cached if isinstance(item, dict)]
59
+ candles = await self._with_fallback("history", symbol, period, interval)
60
+ if candles:
61
+ self._cache_set("history", cache_key, [_candle_to_payload(candle) for candle in candles])
62
+ return candles
63
+
64
+ async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
65
+ cache_key = self._cache_key(symbol, str(limit))
66
+ cached = self._cache_get("news", cache_key)
67
+ if isinstance(cached, list):
68
+ return [_news_from_payload(item) for item in cached if isinstance(item, dict)]
69
+ items = await self._with_fallback("news", symbol, limit)
70
+ self._cache_set("news", cache_key, [_news_to_payload(item) for item in items])
71
+ return items
72
+
73
+ async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
74
+ cache_key = self._cache_key(symbol)
75
+ cached = self._cache_get("fundamentals", cache_key)
76
+ if isinstance(cached, dict):
77
+ return _fundamentals_from_payload(cached)
78
+ snapshot = await self._with_fallback("fundamentals", symbol)
79
+ self._cache_set("fundamentals", cache_key, _fundamentals_to_payload(snapshot))
80
+ return snapshot
81
+
82
+ async def status(self) -> ProviderStatus:
83
+ provider = self.primary_provider
84
+ try:
85
+ return await provider.status()
86
+ except Exception as exc: # noqa: BLE001
87
+ return ProviderStatus(
88
+ name=getattr(provider, "name", "unknown"),
89
+ realtime=False,
90
+ status="unavailable",
91
+ message=str(exc),
92
+ )
93
+
85
94
  async def _with_fallback(self, method_name: str, *args: object) -> Any:
86
95
  errors: list[str] = []
87
96
  for provider in self.providers:
97
+ provider_name = getattr(provider, "name", "unknown")
98
+ started = perf_counter()
88
99
  try:
89
100
  method = getattr(provider, method_name)
90
- return await method(*args)
101
+ payload = await method(*args)
102
+ latency_ms = (perf_counter() - started) * 1000
103
+ status, missing = classify_payload(method_name, payload)
104
+ self._record_provider_metric(provider_name, success=status != "partial_data", latency_ms=latency_ms)
105
+ self._record_provider_result(
106
+ provider=provider_name,
107
+ operation=method_name,
108
+ status=status,
109
+ missing_fields=missing,
110
+ message="ok" if not missing else f"partial payload: {', '.join(missing)}",
111
+ )
112
+ return payload
91
113
  except Exception as exc: # noqa: BLE001
92
- errors.append(f"{getattr(provider, 'name', 'unknown')}: {exc}")
114
+ latency_ms = (perf_counter() - started) * 1000
115
+ errors.append(f"{provider_name}: {exc}")
116
+ self._record_provider_metric(provider_name, success=False, latency_ms=latency_ms, fallback=True)
117
+ self._record_provider_result(
118
+ provider=provider_name,
119
+ operation=method_name,
120
+ status=classify_provider_error(exc),
121
+ message=str(exc),
122
+ )
93
123
  self.last_errors = errors
94
124
  raise ProviderError(
95
125
  f"Semua provider gagal untuk {method_name}.",
96
126
  "\n".join(errors),
97
127
  )
98
128
 
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
-
129
+ def _record_provider_result(
130
+ self,
131
+ provider: str,
132
+ operation: str,
133
+ status: str,
134
+ missing_fields: tuple[str, ...] = (),
135
+ message: str = "",
136
+ ) -> None:
137
+ self.provider_results.append(
138
+ ProviderResult(
139
+ provider=provider,
140
+ operation=operation,
141
+ status=status,
142
+ realtime_label="unknown",
143
+ source=provider,
144
+ data_quality=status,
145
+ missing_fields=missing_fields,
146
+ message=message,
147
+ )
148
+ )
149
+ if len(self.provider_results) > 50:
150
+ self.provider_results = self.provider_results[-50:]
151
+
152
+ def _record_provider_metric(self, provider: str, success: bool, latency_ms: float, fallback: bool = False) -> None:
153
+ metric = self.provider_metrics.setdefault(provider, ProviderRuntimeMetrics(provider))
154
+ metric.record(success=success, latency_ms=latency_ms, fallback=fallback)
155
+ if self.metrics_store is not None:
156
+ self.metrics_store.record(provider, success=success, latency_ms=latency_ms, fallback=fallback)
157
+
158
+ def provider_metrics_snapshot(self) -> dict[str, "ProviderRuntimeMetrics"]:
159
+ return {provider: metric.copy() for provider, metric in self.provider_metrics.items()}
160
+
161
+ def _cache_key(self, symbol: str, *parts: object) -> str:
162
+ provider_chain = ",".join(provider.name for provider in self.providers)
163
+ normalized = [symbol.upper(), *(str(part).lower() for part in parts), f"providers={provider_chain}"]
164
+ return "|".join(normalized)
165
+
166
+ def _cache_get(self, namespace: str, cache_key: str) -> dict[str, Any] | list[Any] | None:
167
+ if self.cache is None:
168
+ return None
169
+ return self.cache.get(namespace, cache_key)
170
+
171
+ def _cache_set(self, namespace: str, cache_key: str, payload: dict[str, Any] | list[Any]) -> None:
172
+ if self.cache is None:
173
+ return
174
+ self.cache.set(namespace, cache_key, payload, self.cache_ttl_seconds)
175
+
176
+ def run(self, awaitable: Awaitable[Any]) -> Any:
177
+ try:
178
+ asyncio.get_running_loop()
179
+ except RuntimeError:
180
+ return asyncio.run(awaitable)
181
+ with ThreadPoolExecutor(max_workers=1) as executor:
182
+ future = executor.submit(asyncio.run, awaitable)
183
+ return future.result()
184
+
185
+
186
+ def _quote_to_payload(quote: Quote) -> dict[str, Any]:
187
+ payload = asdict(quote)
188
+ payload["timestamp"] = quote.timestamp.isoformat()
189
+ return payload
190
+
191
+
192
+ def _quote_from_payload(payload: dict[str, Any]) -> Quote:
193
+ return Quote(
194
+ symbol=str(payload["symbol"]),
195
+ price=None if payload.get("price") is None else float(payload["price"]),
196
+ currency=str(payload.get("currency", "USD")),
197
+ provider=str(payload.get("provider", "cache")),
198
+ timestamp=_parse_datetime(payload.get("timestamp")),
199
+ status=str(payload.get("status", "cached")),
200
+ )
201
+
202
+
203
+ def _candle_to_payload(candle: Candle) -> dict[str, Any]:
204
+ payload = asdict(candle)
205
+ payload["timestamp"] = candle.timestamp.isoformat()
206
+ return payload
207
+
208
+
209
+ def _candle_from_payload(payload: dict[str, Any]) -> Candle:
210
+ return Candle(
211
+ timestamp=_parse_datetime(payload.get("timestamp")),
212
+ open=float(payload["open"]),
213
+ high=float(payload["high"]),
214
+ low=float(payload["low"]),
215
+ close=float(payload["close"]),
216
+ volume=float(payload.get("volume", 0)),
217
+ )
218
+
219
+
220
+ def _news_to_payload(item: NewsItem) -> dict[str, Any]:
221
+ payload = asdict(item)
222
+ payload["published_at"] = item.published_at.isoformat() if item.published_at else None
223
+ return payload
224
+
225
+
226
+ def _news_from_payload(payload: dict[str, Any]) -> NewsItem:
227
+ published_at = payload.get("published_at")
228
+ return NewsItem(
229
+ title=str(payload.get("title", "")),
230
+ source=str(payload.get("source", "")),
231
+ url=None if payload.get("url") is None else str(payload.get("url")),
232
+ published_at=None if published_at is None else _parse_datetime(published_at),
233
+ summary=str(payload.get("summary", "")),
234
+ )
235
+
236
+
237
+ def _fundamentals_to_payload(snapshot: FundamentalSnapshot) -> dict[str, Any]:
238
+ return asdict(snapshot)
239
+
240
+
241
+ def _fundamentals_from_payload(payload: dict[str, Any]) -> FundamentalSnapshot:
242
+ return FundamentalSnapshot(
243
+ symbol=str(payload["symbol"]),
244
+ provider=str(payload.get("provider", "cache")),
245
+ currency=str(payload.get("currency", "USD")),
246
+ market_cap=_optional_float(payload.get("market_cap")),
247
+ pe_ratio=_optional_float(payload.get("pe_ratio")),
248
+ eps=_optional_float(payload.get("eps")),
249
+ revenue=_optional_float(payload.get("revenue")),
250
+ beta=_optional_float(payload.get("beta")),
251
+ sector=None if payload.get("sector") is None else str(payload.get("sector")),
252
+ industry=None if payload.get("industry") is None else str(payload.get("industry")),
253
+ )
254
+
255
+
256
+ def _parse_datetime(value: object) -> datetime:
257
+ if isinstance(value, datetime):
258
+ return value
259
+ return datetime.fromisoformat(str(value))
260
+
261
+
262
+ def _optional_float(value: object) -> float | None:
263
+ if value is None:
264
+ return None
265
+ return float(value)
178
266
 
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
267
 
268
+ class ProviderRuntimeMetrics:
269
+ """Runtime metrics for one market provider."""
193
270
 
194
- def _parse_datetime(value: object) -> datetime:
195
- if isinstance(value, datetime):
196
- return value
197
- return datetime.fromisoformat(str(value))
271
+ def __init__(self, provider: str) -> None:
272
+ self.provider = provider
273
+ self.calls = 0
274
+ self.successes = 0
275
+ self.errors = 0
276
+ self.fallbacks = 0
277
+ self.total_latency_ms = 0.0
278
+ self.last_status = "not_called"
198
279
 
280
+ @property
281
+ def success_rate(self) -> float:
282
+ return (self.successes / self.calls * 100) if self.calls else 0.0
199
283
 
200
- def _optional_float(value: object) -> float | None:
201
- if value is None:
202
- return None
203
- return float(value)
284
+ @property
285
+ def avg_latency_ms(self) -> float:
286
+ return (self.total_latency_ms / self.calls) if self.calls else 0.0
287
+
288
+ def record(self, success: bool, latency_ms: float, fallback: bool = False) -> None:
289
+ self.calls += 1
290
+ self.total_latency_ms += max(latency_ms, 0.0)
291
+ if success:
292
+ self.successes += 1
293
+ self.last_status = "success"
294
+ else:
295
+ self.errors += 1
296
+ self.last_status = "error"
297
+ if fallback:
298
+ self.fallbacks += 1
299
+
300
+ def copy(self) -> "ProviderRuntimeMetrics":
301
+ duplicate = ProviderRuntimeMetrics(self.provider)
302
+ duplicate.calls = self.calls
303
+ duplicate.successes = self.successes
304
+ duplicate.errors = self.errors
305
+ duplicate.fallbacks = self.fallbacks
306
+ duplicate.total_latency_ms = self.total_latency_ms
307
+ duplicate.last_status = self.last_status
308
+ return duplicate
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
  from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
8
8
  from fincli.app.analysis.market_structure import MarketStructureSummary, analyze_market_structure
9
9
  from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, Quote
10
+ from fincli.app.providers.reliability import STATUS_OK, STATUS_PARTIAL_DATA, STATUS_UNAVAILABLE
10
11
  from fincli.app.services.market_data import MarketDataService
11
12
  from fincli.app.utils.errors import FinCLIError
12
13
 
@@ -19,6 +20,11 @@ class DataQuality:
19
20
  news: str
20
21
  fundamentals: str
21
22
  provider: str
23
+ tier: str
24
+ freshness: str
25
+ reliability_status: str
26
+ missing_fields: tuple[str, ...]
27
+ label: str
22
28
 
23
29
 
24
30
  @dataclass(frozen=True, slots=True)
@@ -73,9 +79,12 @@ def _score_data_quality(
73
79
  fundamentals: FundamentalSnapshot | None,
74
80
  ) -> DataQuality:
75
81
  score = 0
82
+ missing: list[str] = []
76
83
  quote_status = "ok" if quote.price is not None else "missing"
77
84
  if quote.price is not None:
78
85
  score += 25
86
+ else:
87
+ missing.append("quote")
79
88
 
80
89
  candle_count = len(candles)
81
90
  if candle_count >= 120:
@@ -89,23 +98,55 @@ def _score_data_quality(
89
98
  score += 10
90
99
  else:
91
100
  ohlcv_status = "missing"
101
+ missing.append("ohlcv")
92
102
 
93
103
  news_status = f"{len(news)} item(s)" if news else "missing"
94
104
  if news:
95
105
  score += 15
106
+ else:
107
+ missing.append("news")
96
108
 
97
109
  fundamentals_status = "ok" if fundamentals is not None else "missing"
98
110
  if fundamentals is not None:
99
111
  score += 20
112
+ else:
113
+ missing.append("fundamentals")
100
114
 
101
115
  if quote.status == "realtime":
102
116
  score += 5
103
117
 
118
+ normalized_score = min(score, 100)
119
+ tier = _quality_tier(normalized_score)
120
+ freshness = quote.status or "unknown"
121
+ reliability_status = _reliability_status(missing, normalized_score)
104
122
  return DataQuality(
105
- score=min(score, 100),
123
+ score=normalized_score,
106
124
  quote=quote_status,
107
125
  ohlcv=ohlcv_status,
108
126
  news=news_status,
109
127
  fundamentals=fundamentals_status,
110
128
  provider=f"{quote.provider} ({quote.status})",
129
+ tier=tier,
130
+ freshness=freshness,
131
+ reliability_status=reliability_status,
132
+ missing_fields=tuple(missing),
133
+ label=f"{tier} | {reliability_status} | freshness={freshness}",
111
134
  )
135
+
136
+
137
+ def _quality_tier(score: int) -> str:
138
+ if score >= 85:
139
+ return "strong"
140
+ if score >= 65:
141
+ return "usable"
142
+ if score >= 40:
143
+ return "partial"
144
+ return "weak"
145
+
146
+
147
+ def _reliability_status(missing: list[str], score: int) -> str:
148
+ if "quote" in missing or "ohlcv" in missing or score < 25:
149
+ return STATUS_UNAVAILABLE
150
+ if missing:
151
+ return STATUS_PARTIAL_DATA
152
+ return STATUS_OK
@@ -0,0 +1,95 @@
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.providers.reliability import STATUS_OK, STATUS_PARTIAL_DATA, STATUS_UNAVAILABLE, classify_provider_error
11
+ from fincli.app.services.market_data import MarketDataService
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class NewsDesk:
16
+ symbol: str
17
+ provider_chain: tuple[str, ...]
18
+ items: list[NewsItem]
19
+ note: str
20
+ errors: tuple[str, ...] = ()
21
+ lookback_days: int | None = None
22
+ reliability_status: str = STATUS_UNAVAILABLE
23
+
24
+
25
+ class NewsAggregator:
26
+ def __init__(
27
+ self,
28
+ market_service: MarketDataService,
29
+ news_connectors: NewsConnectorManager | None = None,
30
+ priority: list[str] | None = None,
31
+ ) -> None:
32
+ self.market_service = market_service
33
+ self.news_connectors = news_connectors or NewsConnectorManager()
34
+ self.priority = priority or ["yfinance", "google_news_rss", "yahoo_finance_rss"]
35
+
36
+ async def latest(self, symbol: str, limit: int = 12, lookback_days: int | None = None) -> NewsDesk:
37
+ normalized = symbol.upper()
38
+ items: list[NewsItem] = []
39
+ errors: list[str] = []
40
+ seen: set[str] = set()
41
+ provider_chain = tuple(_dedupe(self.priority))
42
+
43
+ for provider in provider_chain:
44
+ try:
45
+ fetched = await self._fetch_provider(provider, normalized, max(limit - len(items), 1))
46
+ except Exception as exc: # noqa: BLE001 - fallback chain should continue
47
+ errors.append(f"{provider}: {classify_provider_error(exc)} ({exc})")
48
+ continue
49
+ for item in fetched:
50
+ if lookback_days is not None and not _within_lookback(item, lookback_days):
51
+ continue
52
+ key = (item.url or item.title).strip().lower()
53
+ if key and key not in seen:
54
+ seen.add(key)
55
+ items.append(item)
56
+ if len(items) >= limit:
57
+ break
58
+ if len(items) >= limit:
59
+ break
60
+
61
+ note = "Provider-backed news. Realtime/delayed status depends on provider entitlement."
62
+ reliability_status = STATUS_OK
63
+ if not items:
64
+ note = "No news returned by active providers. Try /research <symbol> --deep or configure /news_model priority."
65
+ reliability_status = STATUS_UNAVAILABLE if errors else STATUS_PARTIAL_DATA
66
+ elif errors:
67
+ note = f"{note} Fallback used after {len(errors)} provider error(s)."
68
+ reliability_status = STATUS_PARTIAL_DATA
69
+ return NewsDesk(normalized, provider_chain, items, note, tuple(errors), lookback_days, reliability_status)
70
+
71
+ async def _fetch_provider(self, provider: str, symbol: str, limit: int) -> list[NewsItem]:
72
+ if provider == "yfinance" or any(item.name == provider for item in self.market_service.providers):
73
+ return await self.market_service.news(symbol, limit=limit)
74
+ return await self.news_connectors.fetch(provider, symbol, limit=limit)
75
+
76
+
77
+ def _dedupe(values: list[str]) -> list[str]:
78
+ seen: set[str] = set()
79
+ result: list[str] = []
80
+ for value in values:
81
+ normalized = value.strip().lower()
82
+ if normalized and normalized not in seen:
83
+ seen.add(normalized)
84
+ result.append(normalized)
85
+ return result
86
+
87
+
88
+ def _within_lookback(item: NewsItem, lookback_days: int) -> bool:
89
+ if item.published_at is None:
90
+ return True
91
+ published = item.published_at
92
+ if published.tzinfo is None:
93
+ published = published.replace(tzinfo=timezone.utc)
94
+ cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days)
95
+ return published >= cutoff