@drico2008/fincli 0.2.2 → 0.3.1

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.
@@ -2,32 +2,41 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
- from concurrent.futures import ThreadPoolExecutor
7
- from dataclasses import asdict
8
- from datetime import datetime
9
- from typing import Any, Awaitable
10
-
11
- from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
12
- from fincli.app.storage.market_cache import MarketCache
13
- from fincli.app.utils.errors import ProviderError
14
-
15
-
16
- class MarketDataService:
5
+ import asyncio
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import asdict
8
+ from datetime import datetime
9
+ from time import perf_counter
10
+ from typing import Any, Awaitable
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
14
+ from fincli.app.storage.market_cache import MarketCache
15
+ from fincli.app.utils.errors import ProviderError
16
+
17
+
18
+ class MarketDataService:
17
19
  """Fetch market data through a prioritized provider chain."""
18
20
 
19
21
  def __init__(
20
22
  self,
21
- providers: list[BaseMarketProvider],
22
- cache: MarketCache | None = None,
23
- cache_ttl_seconds: int = 300,
24
- ) -> None:
23
+ providers: list[BaseMarketProvider],
24
+ cache: MarketCache | None = None,
25
+ cache_ttl_seconds: int = 300,
26
+ metrics_store: Any | None = None,
27
+ ) -> None:
25
28
  if not providers:
26
29
  raise ProviderError("MarketDataService membutuhkan minimal satu provider.")
27
30
  self.providers = providers
28
- self.cache = cache
29
- self.cache_ttl_seconds = cache_ttl_seconds
30
- self.last_errors: list[str] = []
31
+ self.cache = cache
32
+ self.cache_ttl_seconds = cache_ttl_seconds
33
+ 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
+ }
31
40
 
32
41
  @property
33
42
  def primary_provider(self) -> BaseMarketProvider:
@@ -82,19 +91,72 @@ class MarketDataService:
82
91
  message=str(exc),
83
92
  )
84
93
 
85
- async def _with_fallback(self, method_name: str, *args: object) -> Any:
86
- errors: list[str] = []
87
- for provider in self.providers:
88
- try:
89
- method = getattr(provider, method_name)
90
- return await method(*args)
91
- except Exception as exc: # noqa: BLE001
92
- errors.append(f"{getattr(provider, 'name', 'unknown')}: {exc}")
93
- self.last_errors = errors
94
- raise ProviderError(
95
- f"Semua provider gagal untuk {method_name}.",
96
- "\n".join(errors),
97
- )
94
+ async def _with_fallback(self, method_name: str, *args: object) -> Any:
95
+ errors: list[str] = []
96
+ for provider in self.providers:
97
+ provider_name = getattr(provider, "name", "unknown")
98
+ started = perf_counter()
99
+ try:
100
+ method = getattr(provider, method_name)
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
113
+ except Exception as exc: # noqa: BLE001
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
+ )
123
+ self.last_errors = errors
124
+ raise ProviderError(
125
+ f"Semua provider gagal untuk {method_name}.",
126
+ "\n".join(errors),
127
+ )
128
+
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()}
98
160
 
99
161
  def _cache_key(self, symbol: str, *parts: object) -> str:
100
162
  provider_chain = ",".join(provider.name for provider in self.providers)
@@ -197,7 +259,50 @@ def _parse_datetime(value: object) -> datetime:
197
259
  return datetime.fromisoformat(str(value))
198
260
 
199
261
 
200
- def _optional_float(value: object) -> float | None:
201
- if value is None:
202
- return None
203
- return float(value)
262
+ def _optional_float(value: object) -> float | None:
263
+ if value is None:
264
+ return None
265
+ return float(value)
266
+
267
+
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
276
+ self.fallbacks = 0
277
+ self.total_latency_ms = 0.0
278
+ 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
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
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
7
7
 
8
8
  from fincli.app.connectors.news_connectors import NewsConnectorManager
9
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
10
11
  from fincli.app.services.market_data import MarketDataService
11
12
 
12
13
 
@@ -18,6 +19,7 @@ class NewsDesk:
18
19
  note: str
19
20
  errors: tuple[str, ...] = ()
20
21
  lookback_days: int | None = None
22
+ reliability_status: str = STATUS_UNAVAILABLE
21
23
 
22
24
 
23
25
  class NewsAggregator:
@@ -42,7 +44,7 @@ class NewsAggregator:
42
44
  try:
43
45
  fetched = await self._fetch_provider(provider, normalized, max(limit - len(items), 1))
44
46
  except Exception as exc: # noqa: BLE001 - fallback chain should continue
45
- errors.append(f"{provider}: {exc}")
47
+ errors.append(f"{provider}: {classify_provider_error(exc)} ({exc})")
46
48
  continue
47
49
  for item in fetched:
48
50
  if lookback_days is not None and not _within_lookback(item, lookback_days):
@@ -57,11 +59,14 @@ class NewsAggregator:
57
59
  break
58
60
 
59
61
  note = "Provider-backed news. Realtime/delayed status depends on provider entitlement."
62
+ reliability_status = STATUS_OK
60
63
  if not items:
61
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
62
66
  elif errors:
63
67
  note = f"{note} Fallback used after {len(errors)} provider error(s)."
64
- return NewsDesk(normalized, provider_chain, items, note, tuple(errors), lookback_days)
68
+ reliability_status = STATUS_PARTIAL_DATA
69
+ return NewsDesk(normalized, provider_chain, items, note, tuple(errors), lookback_days, reliability_status)
65
70
 
66
71
  async def _fetch_provider(self, provider: str, symbol: str, limit: int) -> list[NewsItem]:
67
72
  if provider == "yfinance" or any(item.name == provider for item in self.market_service.providers):
@@ -119,6 +119,17 @@ class FinCLIDatabase:
119
119
  gameplay TEXT NOT NULL,
120
120
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP
121
121
  );
122
+
123
+ CREATE TABLE IF NOT EXISTS provider_metrics (
124
+ provider TEXT PRIMARY KEY,
125
+ calls INTEGER DEFAULT 0,
126
+ successes INTEGER DEFAULT 0,
127
+ errors INTEGER DEFAULT 0,
128
+ fallbacks INTEGER DEFAULT 0,
129
+ total_latency_ms REAL DEFAULT 0,
130
+ last_status TEXT DEFAULT 'not_called',
131
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
132
+ );
122
133
  """
123
134
  )
124
135
  _migrate_user_profile_schema(db)
@@ -142,7 +153,7 @@ class FinCLIDatabase:
142
153
 
143
154
 
144
155
  def _migrate_user_profile_schema(db: sqlite3.Connection) -> None:
145
- """Normalize older user_profile schemas to the v0.2.2 canonical shape."""
156
+ """Normalize older user_profile schemas to the v0.3.1 canonical shape."""
146
157
 
147
158
  columns = {str(row["name"]) for row in db.execute("PRAGMA table_info(user_profile)").fetchall()}
148
159
  canonical = {"id", "name", "equity", "currency", "leverage", "years_in_investment", "gameplay", "updated_at"}
@@ -0,0 +1,61 @@
1
+ """Persistent provider metrics storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.services.market_data import ProviderRuntimeMetrics
6
+ from fincli.app.storage.database import FinCLIDatabase
7
+
8
+
9
+ class ProviderMetricsStore:
10
+ """Persist aggregate provider metrics across FinCLI sessions."""
11
+
12
+ def __init__(self, db: FinCLIDatabase) -> None:
13
+ self.db = db
14
+
15
+ def record(self, provider: str, success: bool, latency_ms: float, fallback: bool = False) -> None:
16
+ current = self.snapshot().get(provider, ProviderRuntimeMetrics(provider))
17
+ current.record(success=success, latency_ms=latency_ms, fallback=fallback)
18
+ self.db.execute(
19
+ """
20
+ INSERT INTO provider_metrics(provider, calls, successes, errors, fallbacks, total_latency_ms, last_status, updated_at)
21
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
22
+ ON CONFLICT(provider) DO UPDATE SET
23
+ calls=excluded.calls,
24
+ successes=excluded.successes,
25
+ errors=excluded.errors,
26
+ fallbacks=excluded.fallbacks,
27
+ total_latency_ms=excluded.total_latency_ms,
28
+ last_status=excluded.last_status,
29
+ updated_at=CURRENT_TIMESTAMP
30
+ """,
31
+ (
32
+ current.provider,
33
+ current.calls,
34
+ current.successes,
35
+ current.errors,
36
+ current.fallbacks,
37
+ current.total_latency_ms,
38
+ current.last_status,
39
+ ),
40
+ )
41
+
42
+ def snapshot(self) -> dict[str, ProviderRuntimeMetrics]:
43
+ rows = self.db.query(
44
+ """
45
+ SELECT provider, calls, successes, errors, fallbacks, total_latency_ms, last_status
46
+ FROM provider_metrics
47
+ ORDER BY provider
48
+ """
49
+ )
50
+ metrics: dict[str, ProviderRuntimeMetrics] = {}
51
+ for row in rows:
52
+ metric = ProviderRuntimeMetrics(str(row["provider"]))
53
+ metric.calls = int(row["calls"])
54
+ metric.successes = int(row["successes"])
55
+ metric.errors = int(row["errors"])
56
+ metric.fallbacks = int(row["fallbacks"])
57
+ metric.total_latency_ms = float(row["total_latency_ms"])
58
+ metric.last_status = str(row["last_status"])
59
+ metrics[metric.provider] = metric
60
+ return metrics
61
+
@@ -60,6 +60,24 @@ def save_secret(env_key: str, value: str, path: Path | None = None) -> None:
60
60
  os.environ[key] = secret
61
61
 
62
62
 
63
+ def clear_secrets(path: Path | None = None) -> int:
64
+ """Clear persisted local secrets and remove them from the current process."""
65
+ path = path or SECRETS_FILE
66
+ secrets = read_secrets(path)
67
+ for key in secrets:
68
+ os.environ.pop(key, None)
69
+ try:
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ path.write_text("# FinCLI local secrets. Do not commit or share this file.\n", encoding="utf-8")
72
+ try:
73
+ os.chmod(path, 0o600)
74
+ except OSError:
75
+ pass
76
+ except OSError as exc:
77
+ raise ConfigError("Secret lokal gagal dibersihkan.", f"Path: {path}") from exc
78
+ return len(secrets)
79
+
80
+
63
81
  def read_secrets(path: Path | None = None) -> dict[str, str]:
64
82
  """Read local secrets without printing or masking them."""
65
83
  path = path or SECRETS_FILE
package/npm/bin/fincli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
- const { spawn } = require("child_process");
5
+ const { spawn, spawnSync } = require("child_process");
6
6
 
7
7
  const packageRoot = path.resolve(__dirname, "..", "..");
8
8
  const packageJson = require(path.join(packageRoot, "package.json"));
@@ -25,6 +25,8 @@ function run() {
25
25
  process.exit(1);
26
26
  }
27
27
 
28
+ ensurePythonRuntime();
29
+
28
30
  const child = spawn(pythonBin, ["-m", "fincli.app.main", ...args], {
29
31
  cwd: packageRoot,
30
32
  stdio: "inherit"
@@ -39,4 +41,25 @@ function run() {
39
41
  });
40
42
  }
41
43
 
44
+ function ensurePythonRuntime() {
45
+ const probe = spawnSync(pythonBin, ["-c", "import textual, rich, httpx, pydantic, yfinance, pandas, numpy"], {
46
+ cwd: packageRoot,
47
+ stdio: "ignore"
48
+ });
49
+ if (probe.status === 0) {
50
+ return;
51
+ }
52
+
53
+ console.error("FinCLI Python dependencies are incomplete. Repairing local npm runtime...");
54
+ const repair = spawnSync(pythonBin, ["-m", "pip", "install", "."], {
55
+ cwd: packageRoot,
56
+ stdio: "inherit"
57
+ });
58
+ if (repair.status !== 0) {
59
+ console.error("FinCLI runtime repair failed.");
60
+ console.error("Try reinstalling with: npm install -g @drico2008/fincli");
61
+ process.exit(repair.status ?? 1);
62
+ }
63
+ }
64
+
42
65
  run();
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@drico2008/fincli",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Modern financial CLI/TUI terminal for market monitoring and analysis.",
5
5
  "license": "MIT",
6
6
  "bin": {
7
7
  "fincli": "npm/bin/fincli.js"
8
8
  },
9
- "scripts": {
10
- "postinstall": "node npm/postinstall.js",
11
- "check": "node --check npm/bin/fincli.js && node --check npm/postinstall.js"
12
- },
9
+ "scripts": {
10
+ "postinstall": "node npm/postinstall.js",
11
+ "check": "node --check npm/bin/fincli.js && node --check npm/postinstall.js",
12
+ "prepublish:safety": "python scripts/prepublish_check.py",
13
+ "prepublishOnly": "python scripts/prepublish_check.py"
14
+ },
13
15
  "files": [
14
16
  "fincli/**/*.py",
15
17
  "npm",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fincli"
7
- version = "0.2.2"
7
+ version = "0.3.1"
8
8
  description = "Modern financial CLI/TUI terminal for market monitoring and analysis."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"