@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.
- package/README.md +217 -217
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +29 -27
- package/fincli/app/analysis/analyzer.py +34 -34
- package/fincli/app/analysis/assistant_context.py +3 -3
- package/fincli/app/cli/commands.py +33 -27
- package/fincli/app/cli/router.py +1633 -1105
- package/fincli/app/diagnostics/__init__.py +2 -0
- package/fincli/app/diagnostics/capabilities.py +44 -0
- package/fincli/app/diagnostics/runtime.py +106 -0
- package/fincli/app/main.py +6 -1
- package/fincli/app/modules/economic_calendar.py +512 -512
- package/fincli/app/modules/portfolio_risk.py +305 -305
- package/fincli/app/modules/trading.py +142 -0
- package/fincli/app/plugins/loader.py +72 -72
- package/fincli/app/providers/market/finnhub_provider.py +51 -2
- package/fincli/app/providers/market/symbols.py +95 -2
- package/fincli/app/providers/reliability.py +82 -65
- package/fincli/app/research/__init__.py +8 -8
- package/fincli/app/research/engine.py +119 -112
- package/fincli/app/research/exporter.py +91 -91
- package/fincli/app/research/formatter.py +25 -24
- package/fincli/app/research/models.py +22 -21
- package/fincli/app/research/prompt_builder.py +53 -51
- package/fincli/app/services/data_quality.py +27 -0
- package/fincli/app/services/data_trust.py +117 -0
- package/fincli/app/services/macro_data.py +158 -50
- package/fincli/app/services/market_data.py +183 -79
- package/fincli/app/services/market_overview.py +131 -142
- package/fincli/app/services/news_aggregator.py +95 -95
- package/fincli/app/storage/config.py +6 -3
- package/fincli/app/storage/database.py +130 -117
- package/fincli/app/storage/provider_metrics.py +61 -61
- package/fincli/app/storage/secrets.py +128 -128
- package/npm/bin/fincli.js +65 -65
- package/package.json +7 -7
- 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.
|
|
14
|
-
from fincli.app.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
35
|
-
self.
|
|
36
|
-
self.
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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=
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def
|
|
289
|
-
self.calls
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
self.
|
|
296
|
-
self.last_status = "
|
|
297
|
-
|
|
298
|
-
self.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
duplicate
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
quote
|
|
35
|
-
candles
|
|
36
|
-
technical
|
|
37
|
-
structure
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
candles
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
missing.append("
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
score
|
|
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
|
-
if
|
|
139
|
-
return
|
|
140
|
-
|
|
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
|