@drico2008/fincli 0.3.1 → 0.4.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 (37) hide show
  1. package/README.md +217 -217
  2. package/fincli/__init__.py +1 -1
  3. package/fincli/app/analysis/ai_prompts.py +29 -27
  4. package/fincli/app/analysis/analyzer.py +34 -34
  5. package/fincli/app/analysis/assistant_context.py +3 -3
  6. package/fincli/app/cli/commands.py +33 -27
  7. package/fincli/app/cli/router.py +1633 -1105
  8. package/fincli/app/diagnostics/__init__.py +2 -0
  9. package/fincli/app/diagnostics/capabilities.py +44 -0
  10. package/fincli/app/diagnostics/runtime.py +106 -0
  11. package/fincli/app/main.py +6 -1
  12. package/fincli/app/modules/economic_calendar.py +512 -512
  13. package/fincli/app/modules/portfolio_risk.py +305 -305
  14. package/fincli/app/modules/trading.py +142 -0
  15. package/fincli/app/plugins/loader.py +72 -72
  16. package/fincli/app/providers/market/finnhub_provider.py +51 -2
  17. package/fincli/app/providers/market/symbols.py +95 -2
  18. package/fincli/app/providers/reliability.py +82 -65
  19. package/fincli/app/research/__init__.py +8 -8
  20. package/fincli/app/research/engine.py +119 -112
  21. package/fincli/app/research/exporter.py +91 -91
  22. package/fincli/app/research/formatter.py +25 -24
  23. package/fincli/app/research/models.py +22 -21
  24. package/fincli/app/research/prompt_builder.py +53 -51
  25. package/fincli/app/services/data_quality.py +27 -0
  26. package/fincli/app/services/data_trust.py +117 -0
  27. package/fincli/app/services/macro_data.py +158 -50
  28. package/fincli/app/services/market_data.py +183 -79
  29. package/fincli/app/services/market_overview.py +131 -142
  30. package/fincli/app/services/news_aggregator.py +95 -95
  31. package/fincli/app/storage/config.py +6 -3
  32. package/fincli/app/storage/database.py +130 -117
  33. package/fincli/app/storage/provider_metrics.py +61 -61
  34. package/fincli/app/storage/secrets.py +128 -128
  35. package/npm/bin/fincli.js +65 -65
  36. package/package.json +7 -7
  37. package/pyproject.toml +1 -1
@@ -6,37 +6,53 @@ 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
+ from time import monotonic, perf_counter
10
10
  from typing import Any, Awaitable
11
11
 
12
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
14
- from fincli.app.storage.market_cache import MarketCache
15
- from fincli.app.utils.errors import ProviderError
16
-
17
-
18
- class MarketDataService:
13
+ from fincli.app.providers.market.symbols import SymbolResolver
14
+ from fincli.app.providers.reliability import (
15
+ STATUS_CIRCUIT_OPEN,
16
+ STATUS_NETWORK_ERROR,
17
+ STATUS_OK,
18
+ ProviderResult,
19
+ classify_payload,
20
+ classify_provider_error,
21
+ )
22
+ from fincli.app.storage.market_cache import MarketCache
23
+ from fincli.app.utils.errors import ProviderError
24
+
25
+
26
+ class MarketDataService:
19
27
  """Fetch market data through a prioritized provider chain."""
20
28
 
21
29
  def __init__(
22
30
  self,
23
- providers: list[BaseMarketProvider],
31
+ providers: list[BaseMarketProvider],
24
32
  cache: MarketCache | None = None,
25
33
  cache_ttl_seconds: int = 300,
34
+ provider_timeout_seconds: float = 12.0,
26
35
  metrics_store: Any | None = None,
36
+ symbol_resolver: SymbolResolver | None = None,
37
+ circuit_breaker_failure_threshold: int = 3,
38
+ circuit_breaker_cooldown_seconds: float = 60.0,
27
39
  ) -> None:
28
40
  if not providers:
29
41
  raise ProviderError("MarketDataService membutuhkan minimal satu provider.")
30
42
  self.providers = providers
31
43
  self.cache = cache
32
44
  self.cache_ttl_seconds = cache_ttl_seconds
45
+ self.provider_timeout_seconds = max(0.05, float(provider_timeout_seconds))
33
46
  self.metrics_store = metrics_store
34
- self.last_errors: list[str] = []
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
- }
47
+ self.symbol_resolver = symbol_resolver or SymbolResolver()
48
+ self.circuit_breaker_failure_threshold = max(1, int(circuit_breaker_failure_threshold))
49
+ self.circuit_breaker_cooldown_seconds = max(0.0, float(circuit_breaker_cooldown_seconds))
50
+ self.last_errors: list[str] = []
51
+ self.provider_results: list[ProviderResult] = []
52
+ self.provider_metrics: dict[str, ProviderRuntimeMetrics] = {
53
+ getattr(provider, "name", "unknown"): ProviderRuntimeMetrics(getattr(provider, "name", "unknown"))
54
+ for provider in providers
55
+ }
40
56
 
41
57
  @property
42
58
  def primary_provider(self) -> BaseMarketProvider:
@@ -91,72 +107,145 @@ class MarketDataService:
91
107
  message=str(exc),
92
108
  )
93
109
 
94
- async def _with_fallback(self, method_name: str, *args: object) -> Any:
95
- errors: list[str] = []
110
+ async def _with_fallback(self, method_name: str, *args: object) -> Any:
111
+ errors: list[str] = []
96
112
  for provider in self.providers:
97
113
  provider_name = getattr(provider, "name", "unknown")
114
+ if self._is_circuit_open(provider_name):
115
+ message = (
116
+ f"{provider_name}: circuit open; skipped {method_name} for "
117
+ f"{self.circuit_breaker_cooldown_seconds:.0f}s cooldown"
118
+ )
119
+ errors.append(message)
120
+ self._record_provider_result(
121
+ provider=provider_name,
122
+ operation=method_name,
123
+ status=STATUS_CIRCUIT_OPEN,
124
+ realtime_label=_provider_realtime_label(provider),
125
+ message=message,
126
+ )
127
+ continue
98
128
  started = perf_counter()
99
129
  try:
100
130
  method = getattr(provider, method_name)
101
- payload = await method(*args)
102
- latency_ms = (perf_counter() - started) * 1000
131
+ provider_args = self._normalize_provider_args(provider_name, method_name, args)
132
+ payload = await asyncio.wait_for(method(*provider_args), timeout=self.provider_timeout_seconds)
133
+ latency_ms = (perf_counter() - started) * 1000
103
134
  status, missing = classify_payload(method_name, payload)
104
- self._record_provider_metric(provider_name, success=status != "partial_data", latency_ms=latency_ms)
135
+ self._record_provider_metric(provider_name, success=status == STATUS_OK, latency_ms=latency_ms)
136
+ self._record_circuit_success(provider_name)
105
137
  self._record_provider_result(
106
138
  provider=provider_name,
107
139
  operation=method_name,
108
140
  status=status,
141
+ realtime_label=_provider_realtime_label(provider),
109
142
  missing_fields=missing,
110
143
  message="ok" if not missing else f"partial payload: {', '.join(missing)}",
111
144
  )
112
- return payload
113
- except Exception as exc: # noqa: BLE001
145
+ return payload
146
+ except TimeoutError as exc:
114
147
  latency_ms = (perf_counter() - started) * 1000
148
+ message = f"{provider_name}: {method_name} timeout after {self.provider_timeout_seconds:.1f}s"
149
+ errors.append(message)
150
+ self._record_provider_metric(provider_name, success=False, latency_ms=latency_ms, fallback=True)
151
+ self._record_circuit_failure(provider_name)
152
+ self._record_provider_result(
153
+ provider=provider_name,
154
+ operation=method_name,
155
+ status=STATUS_NETWORK_ERROR,
156
+ realtime_label=_provider_realtime_label(provider),
157
+ message=message,
158
+ )
159
+ except Exception as exc: # noqa: BLE001
160
+ latency_ms = (perf_counter() - started) * 1000
115
161
  errors.append(f"{provider_name}: {exc}")
116
162
  self._record_provider_metric(provider_name, success=False, latency_ms=latency_ms, fallback=True)
163
+ self._record_circuit_failure(provider_name)
117
164
  self._record_provider_result(
118
165
  provider=provider_name,
119
166
  operation=method_name,
120
167
  status=classify_provider_error(exc),
168
+ realtime_label=_provider_realtime_label(provider),
121
169
  message=str(exc),
122
170
  )
123
- self.last_errors = errors
171
+ self.last_errors = errors
124
172
  raise ProviderError(
125
173
  f"Semua provider gagal untuk {method_name}.",
126
174
  "\n".join(errors),
127
175
  )
128
176
 
129
- def _record_provider_result(
130
- self,
131
- provider: str,
132
- operation: str,
177
+ def _normalize_provider_args(self, provider: str, method_name: str, args: tuple[object, ...]) -> tuple[object, ...]:
178
+ if method_name not in {"quote", "history", "news", "fundamentals"}:
179
+ return args
180
+ if not args or not isinstance(args[0], str):
181
+ return args
182
+ try:
183
+ symbol = self.symbol_resolver.provider_symbol(provider, args[0])
184
+ except Exception: # noqa: BLE001
185
+ return args
186
+ return (symbol, *args[1:])
187
+
188
+ def _is_circuit_open(self, provider: str) -> bool:
189
+ metric = self.provider_metrics.setdefault(provider, ProviderRuntimeMetrics(provider))
190
+ if not metric.circuit_open:
191
+ return False
192
+ if self.circuit_breaker_cooldown_seconds <= 0:
193
+ return False
194
+ if metric.circuit_opened_at is None:
195
+ return False
196
+ if monotonic() - metric.circuit_opened_at >= self.circuit_breaker_cooldown_seconds:
197
+ metric.circuit_open = False
198
+ metric.circuit_opened_at = None
199
+ metric.last_status = "half_open"
200
+ return False
201
+ return True
202
+
203
+ def _record_circuit_failure(self, provider: str) -> None:
204
+ metric = self.provider_metrics.setdefault(provider, ProviderRuntimeMetrics(provider))
205
+ metric.consecutive_failures += 1
206
+ if metric.consecutive_failures >= self.circuit_breaker_failure_threshold:
207
+ metric.circuit_open = True
208
+ metric.circuit_opened_at = monotonic()
209
+ metric.last_status = STATUS_CIRCUIT_OPEN
210
+
211
+ def _record_circuit_success(self, provider: str) -> None:
212
+ metric = self.provider_metrics.setdefault(provider, ProviderRuntimeMetrics(provider))
213
+ metric.consecutive_failures = 0
214
+ metric.circuit_open = False
215
+ metric.circuit_opened_at = None
216
+
217
+ def _record_provider_result(
218
+ self,
219
+ provider: str,
220
+ operation: str,
133
221
  status: str,
222
+ realtime_label: str = "unknown",
134
223
  missing_fields: tuple[str, ...] = (),
135
224
  message: str = "",
136
225
  ) -> None:
137
- self.provider_results.append(
138
- ProviderResult(
226
+ self.provider_results.append(
227
+ ProviderResult(
139
228
  provider=provider,
140
229
  operation=operation,
141
230
  status=status,
142
- realtime_label="unknown",
231
+ realtime_label=realtime_label,
143
232
  source=provider,
144
233
  data_quality=status,
145
234
  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()}
235
+ message=message,
236
+ )
237
+ )
238
+ if len(self.provider_results) > 50:
239
+ self.provider_results = self.provider_results[-50:]
240
+
241
+ def _record_provider_metric(self, provider: str, success: bool, latency_ms: float, fallback: bool = False) -> None:
242
+ metric = self.provider_metrics.setdefault(provider, ProviderRuntimeMetrics(provider))
243
+ metric.record(success=success, latency_ms=latency_ms, fallback=fallback)
244
+ if self.metrics_store is not None:
245
+ self.metrics_store.record(provider, success=success, latency_ms=latency_ms, fallback=fallback)
246
+
247
+ def provider_metrics_snapshot(self) -> dict[str, "ProviderRuntimeMetrics"]:
248
+ return {provider: metric.copy() for provider, metric in self.provider_metrics.items()}
160
249
 
161
250
  def _cache_key(self, symbol: str, *parts: object) -> str:
162
251
  provider_chain = ",".join(provider.name for provider in self.providers)
@@ -260,49 +349,64 @@ def _parse_datetime(value: object) -> datetime:
260
349
 
261
350
 
262
351
  def _optional_float(value: object) -> float | None:
263
- if value is None:
264
- return None
352
+ if value is None:
353
+ return None
265
354
  return float(value)
266
355
 
267
356
 
268
- class ProviderRuntimeMetrics:
269
- """Runtime metrics for one market provider."""
270
-
271
- def __init__(self, provider: str) -> None:
272
- self.provider = provider
273
- self.calls = 0
274
- self.successes = 0
275
- self.errors = 0
357
+ def _provider_realtime_label(provider: BaseMarketProvider) -> str:
358
+ realtime = getattr(provider, "realtime", None)
359
+ if realtime is True:
360
+ return "realtime_or_plan_dependent"
361
+ if realtime is False:
362
+ return "delayed_or_fallback"
363
+ return "unknown"
364
+
365
+
366
+ class ProviderRuntimeMetrics:
367
+ """Runtime metrics for one market provider."""
368
+
369
+ def __init__(self, provider: str) -> None:
370
+ self.provider = provider
371
+ self.calls = 0
372
+ self.successes = 0
373
+ self.errors = 0
276
374
  self.fallbacks = 0
277
375
  self.total_latency_ms = 0.0
278
376
  self.last_status = "not_called"
279
-
280
- @property
281
- def success_rate(self) -> float:
282
- return (self.successes / self.calls * 100) if self.calls else 0.0
283
-
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
377
+ self.consecutive_failures = 0
378
+ self.circuit_open = False
379
+ self.circuit_opened_at: float | None = None
380
+
381
+ @property
382
+ def success_rate(self) -> float:
383
+ return (self.successes / self.calls * 100) if self.calls else 0.0
384
+
385
+ @property
386
+ def avg_latency_ms(self) -> float:
387
+ return (self.total_latency_ms / self.calls) if self.calls else 0.0
388
+
389
+ def record(self, success: bool, latency_ms: float, fallback: bool = False) -> None:
390
+ self.calls += 1
391
+ self.total_latency_ms += max(latency_ms, 0.0)
392
+ if success:
393
+ self.successes += 1
394
+ self.last_status = "success"
395
+ else:
396
+ self.errors += 1
397
+ self.last_status = "error"
398
+ if fallback:
399
+ self.fallbacks += 1
400
+
401
+ def copy(self) -> "ProviderRuntimeMetrics":
402
+ duplicate = ProviderRuntimeMetrics(self.provider)
403
+ duplicate.calls = self.calls
404
+ duplicate.successes = self.successes
405
+ duplicate.errors = self.errors
305
406
  duplicate.fallbacks = self.fallbacks
306
407
  duplicate.total_latency_ms = self.total_latency_ms
307
408
  duplicate.last_status = self.last_status
409
+ duplicate.consecutive_failures = self.consecutive_failures
410
+ duplicate.circuit_open = self.circuit_open
411
+ duplicate.circuit_opened_at = self.circuit_opened_at
308
412
  return duplicate
@@ -1,152 +1,141 @@
1
- """Market overview orchestration."""
2
-
3
- from __future__ import annotations
4
-
1
+ """Market overview orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
5
  from dataclasses import dataclass
6
6
 
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
10
  from fincli.app.providers.reliability import STATUS_OK, STATUS_PARTIAL_DATA, STATUS_UNAVAILABLE
11
+ from fincli.app.services.data_quality import DataQualityReport
11
12
  from fincli.app.services.market_data import MarketDataService
12
13
  from fincli.app.utils.errors import FinCLIError
13
14
 
14
15
 
15
- @dataclass(frozen=True, slots=True)
16
- class DataQuality:
17
- score: int
18
- quote: str
19
- ohlcv: str
20
- news: str
21
- fundamentals: str
22
- provider: str
23
- tier: str
24
- freshness: str
25
- reliability_status: str
26
- missing_fields: tuple[str, ...]
27
- label: str
28
-
29
-
30
- @dataclass(frozen=True, slots=True)
31
- class MarketOverview:
32
- symbol: str
33
- timeframe: str
34
- quote: Quote
35
- candles: list[Candle]
36
- technical: TechnicalSummary
37
- structure: MarketStructureSummary
38
- news: list[NewsItem]
39
- fundamentals: FundamentalSnapshot | None
40
- data_quality: DataQuality
41
-
42
-
43
- async def build_market_overview(symbol: str, market_service: MarketDataService, timeframe: str = "1d") -> MarketOverview:
44
- """Build a compact market overview from available provider data."""
45
- normalized = symbol.upper()
46
- quote = await market_service.quote(normalized)
47
- candles = await market_service.history(normalized, period="6mo", interval=timeframe)
48
- technical = summarize_technical_indicators(candles)
49
- structure = analyze_market_structure(candles)
50
-
51
- try:
52
- news = await market_service.news(normalized, limit=3)
53
- except FinCLIError:
54
- news = []
55
-
56
- try:
57
- fundamentals = await market_service.fundamentals(normalized)
58
- except FinCLIError:
59
- fundamentals = None
60
-
61
- quality = _score_data_quality(quote, candles, news, fundamentals)
62
- return MarketOverview(
63
- symbol=normalized,
64
- timeframe=timeframe,
65
- quote=quote,
66
- candles=candles,
67
- technical=technical,
68
- structure=structure,
69
- news=news,
70
- fundamentals=fundamentals,
71
- data_quality=quality,
72
- )
73
-
74
-
75
- def _score_data_quality(
76
- quote: Quote,
77
- candles: list[Candle],
78
- news: list[NewsItem],
79
- fundamentals: FundamentalSnapshot | None,
80
- ) -> DataQuality:
81
- score = 0
82
- missing: list[str] = []
83
- quote_status = "ok" if quote.price is not None else "missing"
84
- if quote.price is not None:
85
- score += 25
86
- else:
87
- missing.append("quote")
88
-
89
- candle_count = len(candles)
90
- if candle_count >= 120:
91
- ohlcv_status = f"strong ({candle_count} candles)"
92
- score += 35
93
- elif candle_count >= 20:
94
- ohlcv_status = f"usable ({candle_count} candles)"
95
- score += 25
96
- elif candle_count:
97
- ohlcv_status = f"weak ({candle_count} candles)"
98
- score += 10
99
- else:
100
- ohlcv_status = "missing"
101
- missing.append("ohlcv")
102
-
103
- news_status = f"{len(news)} item(s)" if news else "missing"
104
- if news:
105
- score += 15
106
- else:
107
- missing.append("news")
108
-
109
- fundamentals_status = "ok" if fundamentals is not None else "missing"
110
- if fundamentals is not None:
111
- score += 20
112
- else:
113
- missing.append("fundamentals")
114
-
115
- if quote.status == "realtime":
116
- score += 5
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)
122
- return DataQuality(
123
- score=normalized_score,
124
- quote=quote_status,
125
- ohlcv=ohlcv_status,
126
- news=news_status,
127
- fundamentals=fundamentals_status,
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}",
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
16
+ DataQuality = DataQualityReport
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class MarketOverview:
21
+ symbol: str
22
+ timeframe: str
23
+ quote: Quote
24
+ candles: list[Candle]
25
+ technical: TechnicalSummary
26
+ structure: MarketStructureSummary
27
+ news: list[NewsItem]
28
+ fundamentals: FundamentalSnapshot | None
29
+ data_quality: DataQuality
30
+
31
+
32
+ async def build_market_overview(symbol: str, market_service: MarketDataService, timeframe: str = "1d") -> MarketOverview:
33
+ """Build a compact market overview from available provider data."""
34
+ normalized = symbol.upper()
35
+ quote = await market_service.quote(normalized)
36
+ candles = await market_service.history(normalized, period="6mo", interval=timeframe)
37
+ technical = summarize_technical_indicators(candles)
38
+ structure = analyze_market_structure(candles)
39
+
40
+ try:
41
+ news = await market_service.news(normalized, limit=3)
42
+ except FinCLIError:
43
+ news = []
44
+
45
+ try:
46
+ fundamentals = await market_service.fundamentals(normalized)
47
+ except FinCLIError:
48
+ fundamentals = None
49
+
50
+ quality = _score_data_quality(quote, candles, news, fundamentals)
51
+ return MarketOverview(
52
+ symbol=normalized,
53
+ timeframe=timeframe,
54
+ quote=quote,
55
+ candles=candles,
56
+ technical=technical,
57
+ structure=structure,
58
+ news=news,
59
+ fundamentals=fundamentals,
60
+ data_quality=quality,
61
+ )
62
+
63
+
64
+ def _score_data_quality(
65
+ quote: Quote,
66
+ candles: list[Candle],
67
+ news: list[NewsItem],
68
+ fundamentals: FundamentalSnapshot | None,
69
+ ) -> DataQuality:
70
+ score = 0
71
+ missing: list[str] = []
72
+ quote_status = "ok" if quote.price is not None else "missing"
73
+ if quote.price is not None:
74
+ score += 25
75
+ else:
76
+ missing.append("quote")
77
+
78
+ candle_count = len(candles)
79
+ if candle_count >= 120:
80
+ ohlcv_status = f"strong ({candle_count} candles)"
81
+ score += 35
82
+ elif candle_count >= 20:
83
+ ohlcv_status = f"usable ({candle_count} candles)"
84
+ score += 25
85
+ elif candle_count:
86
+ ohlcv_status = f"weak ({candle_count} candles)"
87
+ score += 10
88
+ else:
89
+ ohlcv_status = "missing"
90
+ missing.append("ohlcv")
91
+
92
+ news_status = f"{len(news)} item(s)" if news else "missing"
93
+ if news:
94
+ score += 15
95
+ else:
96
+ missing.append("news")
97
+
98
+ fundamentals_status = "ok" if fundamentals is not None else "missing"
99
+ if fundamentals is not None:
100
+ score += 20
101
+ else:
102
+ missing.append("fundamentals")
103
+
104
+ if quote.status == "realtime":
105
+ score += 5
106
+
107
+ normalized_score = min(score, 100)
108
+ tier = _quality_tier(normalized_score)
109
+ freshness = quote.status or "unknown"
110
+ reliability_status = _reliability_status(missing, normalized_score)
111
+ return DataQuality(
112
+ score=normalized_score,
113
+ quote=quote_status,
114
+ ohlcv=ohlcv_status,
115
+ news=news_status,
116
+ fundamentals=fundamentals_status,
117
+ provider=f"{quote.provider} ({quote.status})",
118
+ tier=tier,
119
+ freshness=freshness,
120
+ reliability_status=reliability_status,
121
+ missing_fields=tuple(missing),
122
+ label=f"{tier} | {reliability_status} | freshness={freshness}",
123
+ )
124
+
125
+
126
+ def _quality_tier(score: int) -> str:
127
+ if score >= 85:
128
+ return "strong"
129
+ if score >= 65:
130
+ return "usable"
131
+ if score >= 40:
132
+ return "partial"
133
+ return "weak"
134
+
135
+
136
+ def _reliability_status(missing: list[str], score: int) -> str:
137
+ if "quote" in missing or "ohlcv" in missing or score < 25:
138
+ return STATUS_UNAVAILABLE
139
+ if missing:
140
+ return STATUS_PARTIAL_DATA
141
+ return STATUS_OK