@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.
- package/LICENSE +21 -0
- package/README.md +124 -625
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +26 -14
- package/fincli/app/analysis/analyzer.py +107 -96
- package/fincli/app/analysis/assistant_context.py +187 -186
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +108 -81
- package/fincli/app/cli/router.py +2327 -1237
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/portfolio_risk.py +305 -0
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/transactions.py +84 -84
- package/fincli/app/modules/user_profile.py +84 -0
- package/fincli/app/plugins/loader.py +72 -0
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/market/alphavantage_provider.py +194 -0
- package/fincli/app/providers/market/base.py +98 -77
- package/fincli/app/providers/market/custom_provider.py +186 -169
- package/fincli/app/providers/market/manager.py +84 -1
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/reliability.py +86 -0
- package/fincli/app/research/__init__.py +8 -0
- package/fincli/app/research/engine.py +137 -0
- package/fincli/app/research/exporter.py +91 -0
- package/fincli/app/research/formatter.py +27 -0
- package/fincli/app/research/models.py +24 -0
- package/fincli/app/research/prompt_builder.py +54 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +274 -169
- package/fincli/app/services/market_overview.py +42 -1
- package/fincli/app/services/news_aggregator.py +95 -0
- package/fincli/app/services/web_research.py +267 -267
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +209 -99
- package/fincli/app/storage/provider_metrics.py +61 -0
- package/fincli/app/storage/secrets.py +26 -2
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +269 -258
- package/fincli/app/tui/market_provider_selector.py +3 -1
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +123 -60
- package/package.json +22 -20
- 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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
self.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
self.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
self.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
payload
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
return
|
|
203
|
-
|
|
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=
|
|
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
|