@drico2008/fincli 0.2.2 → 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.
@@ -45,9 +45,10 @@ from fincli.app.modules.economic_calendar import (
45
45
  from fincli.app.modules.alerts import AlertCheckResult, AlertService, evaluate_alert
46
46
  from fincli.app.modules.exporter import export_rows
47
47
  from fincli.app.modules.journal_analytics import JournalStats, build_journal_review_prompt, calculate_journal_stats
48
- from fincli.app.modules.journal import JournalService
49
- from fincli.app.modules.portfolio import PortfolioService
50
- from fincli.app.modules.scanner import ScanResult, scan_symbols
48
+ from fincli.app.modules.journal import JournalService
49
+ from fincli.app.modules.portfolio import PortfolioService
50
+ from fincli.app.modules.portfolio_risk import PortfolioRiskReport, build_portfolio_risk
51
+ from fincli.app.modules.scanner import ScanResult, scan_symbols
51
52
  from fincli.app.modules.session_history import SessionHistoryService
52
53
  from fincli.app.modules.transactions import TransactionService
53
54
  from fincli.app.modules.user_profile import UserProfile, UserProfileService
@@ -62,17 +63,23 @@ from fincli.app.connectors.news_connectors import (
62
63
  from fincli.app.modules.reports import write_market_report
63
64
  from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
64
65
  from fincli.app.providers.ai.manager import AIProviderManager
65
- from fincli.app.providers.market.base import (
66
- BaseMarketProvider,
67
- FundamentalSnapshot,
68
- NewsItem,
69
- ProviderEntitlement,
66
+ from fincli.app.providers.market.base import (
67
+ BaseMarketProvider,
68
+ FundamentalSnapshot,
69
+ NewsItem,
70
+ ProviderEntitlement,
70
71
  Quote,
71
72
  SymbolSearchResult,
72
- )
73
- from fincli.app.providers.market.manager import MarketProviderManager
74
- from fincli.app.providers.market.symbols import provider_symbol_matrix, search_symbol_catalog
73
+ )
74
+ from fincli.app.providers.market.manager import MarketProviderManager
75
+ from fincli.app.providers.market.symbols import provider_symbol_matrix, search_symbol_catalog
75
76
  from fincli.app.providers.market.yfinance_provider import YahooTable, YFinanceProvider
77
+ from fincli.app.providers.reliability import (
78
+ STATUS_OK,
79
+ STATUS_PARTIAL_DATA,
80
+ STATUS_SCHEDULE_ONLY,
81
+ STATUS_UNAVAILABLE,
82
+ )
76
83
  from fincli.app.plugins.loader import PluginLoader, PluginManifest
77
84
  from fincli.app.services.market_data import MarketDataService
78
85
  from fincli.app.services.market_overview import MarketOverview, build_market_overview
@@ -84,12 +91,13 @@ from fincli.app.services.web_research import (
84
91
  build_web_research_context,
85
92
  should_use_web_research,
86
93
  )
87
- from fincli.app.research import ResearchEngine, format_research_brief
94
+ from fincli.app.research import ResearchEngine, format_research_brief, write_research_report
88
95
  from fincli.app.storage.cache import TTLCache
89
96
  from fincli.app.storage.config import ConfigManager
90
97
  from fincli.app.storage.database import FinCLIDatabase
91
- from fincli.app.storage.market_cache import MarketCache
92
- from fincli.app.storage.secrets import save_secret
98
+ from fincli.app.storage.market_cache import MarketCache
99
+ from fincli.app.storage.provider_metrics import ProviderMetricsStore
100
+ from fincli.app.storage.secrets import clear_secrets, read_secrets, save_secret
93
101
  from fincli.app.utils.errors import CommandError, FinCLIError
94
102
  from fincli.app.utils.formatting import AIResponseView, MarkdownBlock, semantic_text
95
103
 
@@ -116,9 +124,10 @@ class CommandRouter:
116
124
  self.config = config or ConfigManager()
117
125
  self.db = db or FinCLIDatabase()
118
126
  self.registry = registry or CommandRegistry()
119
- self.cache: TTLCache[object] = TTLCache(self.config.settings.cache_ttl_seconds)
120
- self.market_cache = MarketCache(self.db)
121
- self.market_manager = MarketProviderManager()
127
+ self.cache: TTLCache[object] = TTLCache(self.config.settings.cache_ttl_seconds)
128
+ self.market_cache = MarketCache(self.db)
129
+ self.provider_metrics_store = ProviderMetricsStore(self.db)
130
+ self.market_manager = MarketProviderManager()
122
131
  self.market_service = self._build_market_service(market_provider)
123
132
  self.market_provider = self.market_service.primary_provider
124
133
  self.ai_provider = ai_provider or AIProviderManager().create(self.config.settings.ai_provider)
@@ -192,6 +201,10 @@ class CommandRouter:
192
201
  return self._doctor(args)
193
202
  if root == "/setup":
194
203
  return self._setup(args)
204
+ if root == "/secrets":
205
+ return self._secrets(args)
206
+ if root == "/privacy":
207
+ return self._privacy(args)
195
208
  if root == "/agent":
196
209
  return self._agent(args)
197
210
  if root == "/connector":
@@ -268,7 +281,7 @@ class CommandRouter:
268
281
  )
269
282
 
270
283
  def _help_table(self) -> Table:
271
- table = Table(title="FinCLI v0.2.2 Commands", expand=True)
284
+ table = Table(title="FinCLI v0.3.0 Commands", expand=True)
272
285
  table.add_column("Command", style="cyan", no_wrap=True)
273
286
  table.add_column("Group", style="magenta")
274
287
  table.add_column("Fungsi", style="white")
@@ -277,10 +290,15 @@ class CommandRouter:
277
290
  table.add_row(command.name, command.group, command.description, command.example)
278
291
  return table
279
292
 
280
- def _record_history(self, raw: str, result: CommandResult) -> None:
281
- normalized = raw.strip().lower()
282
- if not normalized or normalized.startswith("/history"):
283
- return
293
+ def _record_history(self, raw: str, result: CommandResult) -> None:
294
+ normalized = raw.strip().lower()
295
+ if (
296
+ not normalized
297
+ or normalized.startswith("/history")
298
+ or normalized.startswith("/privacy purge")
299
+ or normalized.startswith("/secrets clear")
300
+ ):
301
+ return
284
302
  try:
285
303
  preview = _render_history_preview(result.renderable)
286
304
  self.history.record_event(self.session_id, raw, result.status, preview)
@@ -553,12 +571,33 @@ class CommandRouter:
553
571
 
554
572
  def _research(self, args: list[str]) -> CommandResult:
555
573
  if not args:
556
- raise CommandError("Format: /research <symbol> [--quick|--deep] [timeframe]")
574
+ raise CommandError("Format: /research <symbol> [--deep|--report] [timeframe] [--export <md|json> <path>]")
557
575
  symbol = args[0].upper()
558
- mode = "deep" if any(arg.lower() == "--deep" for arg in args[1:]) else "quick"
559
- timeframe = next((arg for arg in args[1:] if not arg.startswith("--")), "1d")
576
+ export_format: str | None = None
577
+ export_target: str | None = None
578
+ if "--export" in args:
579
+ export_index = args.index("--export")
580
+ if len(args) <= export_index + 2:
581
+ raise CommandError("Format export: /research <symbol> --report --export <md|json> <path>")
582
+ export_format = args[export_index + 1]
583
+ export_target = args[export_index + 2]
584
+ flags = {arg.lower() for arg in args[1:] if arg.startswith("--")}
585
+ if "--report" in flags:
586
+ mode = "report"
587
+ elif "--deep" in flags:
588
+ mode = "deep"
589
+ else:
590
+ mode = "quick"
591
+ ignored: set[int] = set()
592
+ if "--export" in args:
593
+ export_index = args.index("--export")
594
+ ignored.update({export_index, export_index + 1, export_index + 2})
595
+ timeframe = next((arg for index, arg in enumerate(args[1:], start=1) if index not in ignored and not arg.startswith("--")), "1d")
560
596
  engine = ResearchEngine(self.market_service, self.ai_provider, self.config.settings.ai_model)
561
597
  brief = self._run_async(engine.build(symbol, timeframe=timeframe, mode=mode))
598
+ if export_format and export_target:
599
+ written = write_research_report(brief, export_format, export_target)
600
+ return CommandResult(Panel(f"Research export selesai: {written}", title="Research Export", border_style="green"))
562
601
  return CommandResult(format_research_brief(brief))
563
602
 
564
603
  def _macro(self, args: list[str]) -> CommandResult:
@@ -585,7 +624,7 @@ class CommandRouter:
585
624
  table.add_column("Check", style="cyan", no_wrap=True)
586
625
  table.add_column("Status")
587
626
  table.add_column("Detail", overflow="fold")
588
- table.add_row("Version", "ok", "FinCLI v0.2.2 command surface loaded.")
627
+ table.add_row("Version", "ok", "FinCLI v0.3.0 command surface loaded.")
589
628
  table.add_row("Database", "ok", str(self.db.db_file))
590
629
  table.add_row("Market Provider", "ok", ", ".join(provider.name for provider in self.market_service.providers))
591
630
  profile = self.user_profiles.get()
@@ -613,6 +652,61 @@ class CommandRouter:
613
652
  )
614
653
  )
615
654
 
655
+ def _secrets(self, args: list[str]) -> CommandResult:
656
+ action = args[0].lower() if args else "status"
657
+ if action == "status":
658
+ return CommandResult(_format_secrets_status(read_secrets()))
659
+ if action == "clear":
660
+ cleared = clear_secrets()
661
+ return CommandResult(
662
+ Panel(
663
+ f"{cleared} local secret(s) cleared from ~/.fincli/secrets.env. Current process keys from that store were removed.",
664
+ title="Secrets Cleared",
665
+ border_style="yellow",
666
+ )
667
+ )
668
+ raise CommandError("Format: /secrets status atau /secrets clear")
669
+
670
+ def _privacy(self, args: list[str]) -> CommandResult:
671
+ action = args[0].lower() if args else "status"
672
+ if action == "status":
673
+ stats = self.market_cache.stats()
674
+ return CommandResult(
675
+ Panel(
676
+ "\n".join(
677
+ [
678
+ f"Secrets stored : {len(read_secrets())}",
679
+ f"Session events : {len(self.history.get_events(self.session_id))}",
680
+ f"Persistent cache rows: {stats['total']}",
681
+ "Purge scope : secrets + current session history + runtime/persistent cache",
682
+ "Portfolio, journal, alerts, and profile are not deleted by /privacy purge.",
683
+ ]
684
+ ),
685
+ title="Privacy Status",
686
+ border_style="cyan",
687
+ )
688
+ )
689
+ if action == "purge":
690
+ secrets_cleared = clear_secrets()
691
+ self.history.clear_events(self.session_id)
692
+ self.cache.clear()
693
+ cache_cleared = self.market_cache.clear()
694
+ return CommandResult(
695
+ Panel(
696
+ (
697
+ f"Privacy state purged.\n"
698
+ f"- secrets cleared: {secrets_cleared}\n"
699
+ f"- current session history cleared\n"
700
+ f"- runtime cache cleared\n"
701
+ f"- persistent market cache rows cleared: {cache_cleared}\n\n"
702
+ "Portfolio, journal, alerts, and profile were kept."
703
+ ),
704
+ title="Privacy Purge",
705
+ border_style="yellow",
706
+ )
707
+ )
708
+ raise CommandError("Format: /privacy status atau /privacy purge")
709
+
616
710
  def _agent(self, args: list[str]) -> CommandResult:
617
711
  action = args[0].lower() if args else "list"
618
712
  if action in {"list", "ls"}:
@@ -736,9 +830,11 @@ class CommandRouter:
736
830
  )
737
831
  return CommandResult(table)
738
832
 
739
- action = args[0].lower()
740
- if action == "performance":
741
- return CommandResult(self._portfolio_performance_table())
833
+ action = args[0].lower()
834
+ if action == "risk":
835
+ return CommandResult(_format_portfolio_risk(self._portfolio_risk_report()))
836
+ if action == "performance":
837
+ return CommandResult(self._portfolio_performance_table())
742
838
  if action == "add" and len(args) >= 4:
743
839
  try:
744
840
  quantity = float(args[2])
@@ -750,10 +846,10 @@ class CommandRouter:
750
846
  if action == "remove" and len(args) >= 2:
751
847
  self.portfolio.remove(args[1])
752
848
  return CommandResult(Panel(f"Posisi {args[1].upper()} dihapus.", title="Portfolio"))
753
- raise CommandError(
754
- "Format: /portfolio, /portfolio performance, /portfolio add <symbol> <qty> <avg_price>, "
755
- "/portfolio remove <symbol>"
756
- )
849
+ raise CommandError(
850
+ "Format: /portfolio, /portfolio risk, /portfolio performance, /portfolio add <symbol> <qty> <avg_price>, "
851
+ "/portfolio remove <symbol>"
852
+ )
757
853
 
758
854
  def _tx(self, args: list[str]) -> CommandResult:
759
855
  if not args or args[0].lower() == "list":
@@ -1020,7 +1116,17 @@ class CommandRouter:
1020
1116
  structure = analyze_market_structure(candles)
1021
1117
  news_context = self._analysis_context(symbol)
1022
1118
  gameplay_context = format_gameplay_context(self.user_profiles.get(), symbol)
1023
- prompt = build_market_analysis_prompt(symbol, timeframe, candles, technical, structure, news_context, gameplay_context)
1119
+ grounding_context = self._ai_grounding_context(symbol, timeframe)
1120
+ prompt = build_market_analysis_prompt(
1121
+ symbol,
1122
+ timeframe,
1123
+ candles,
1124
+ technical,
1125
+ structure,
1126
+ news_context,
1127
+ gameplay_context,
1128
+ grounding_context=grounding_context,
1129
+ )
1024
1130
  request = AIRequest(prompt=prompt, model=self.config.settings.ai_model)
1025
1131
  response = self._run_async(self.ai_provider.complete(request))
1026
1132
  if not isinstance(response, AIResponse):
@@ -1147,31 +1253,26 @@ class CommandRouter:
1147
1253
  except (TypeError, ValueError, KeyError):
1148
1254
  return None, None, None
1149
1255
 
1150
- def _portfolio_performance_table(self) -> Table:
1151
- positions = self.portfolio.list()
1152
- realized = self.transactions.realized_pnl_total()
1153
- cost_basis = 0.0
1154
- market_value = 0.0
1155
- unrealized = 0.0
1156
- for row in positions:
1157
- quantity = float(row["quantity"])
1158
- average_price = float(row["average_price"])
1159
- current_price, pnl, _ = self._portfolio_market_values(row)
1160
- cost_basis += quantity * average_price
1161
- if current_price is not None:
1162
- market_value += quantity * current_price
1163
- if pnl is not None:
1164
- unrealized += pnl
1165
-
1166
- table = Table(title="Portfolio Performance", expand=True)
1167
- table.add_column("Metric", style="cyan")
1168
- table.add_column("Value", justify="right")
1169
- table.add_row("Cost Basis", _fmt(cost_basis))
1170
- table.add_row("Market Value", _fmt(market_value))
1171
- table.add_row("Unrealized PnL", _fmt(unrealized))
1172
- table.add_row("Realized PnL", _fmt(realized))
1173
- table.add_row("Total PnL", _fmt(realized + unrealized))
1174
- return table
1256
+ def _portfolio_performance_table(self) -> Table:
1257
+ risk = self._portfolio_risk_report()
1258
+
1259
+ table = Table(title="Portfolio Performance", expand=True)
1260
+ table.add_column("Metric", style="cyan")
1261
+ table.add_column("Value", justify="right")
1262
+ table.add_row("Cost Basis", _fmt(risk.total_cost_basis))
1263
+ table.add_row("Market Value", _fmt(risk.total_market_value))
1264
+ table.add_row("Unrealized PnL", _fmt(risk.unrealized_pnl))
1265
+ table.add_row("Realized PnL", _fmt(risk.realized_pnl))
1266
+ table.add_row("Total PnL", _fmt(risk.total_pnl))
1267
+ table.add_row("Health Score", f"{risk.health.score}/100 ({risk.health.label})")
1268
+ return table
1269
+
1270
+ def _portfolio_risk_report(self) -> PortfolioRiskReport:
1271
+ positions = self.portfolio.list()
1272
+ values: dict[str, tuple[float | None, float | None, float | None]] = {}
1273
+ for row in positions:
1274
+ values[str(row["symbol"]).upper()] = self._portfolio_market_values(row)
1275
+ return build_portfolio_risk(positions, values, self.transactions.realized_pnl_total(), profile=self.user_profiles.get())
1175
1276
 
1176
1277
  def _get_quote(self, symbol: str) -> Quote:
1177
1278
  normalized = symbol.upper()
@@ -1185,16 +1286,26 @@ class CommandRouter:
1185
1286
  self.cache.set(cache_key, quote)
1186
1287
  return quote
1187
1288
 
1188
- def _provider_health_text(self) -> str:
1189
- try:
1190
- status = self._run_async(self.market_service.status())
1191
- return (
1192
- f"Provider health: {status.status}\n"
1193
- f"Provider realtime: {status.realtime}\n"
1194
- f"Provider message: {status.message}"
1195
- )
1196
- except (FinCLIError, AttributeError) as exc:
1197
- return f"Provider health: unavailable ({exc})"
1289
+ def _provider_health_text(self) -> str:
1290
+ try:
1291
+ status = self._run_async(self.market_service.status())
1292
+ base = (
1293
+ f"Provider health: {status.status}\n"
1294
+ f"Provider realtime: {status.realtime}\n"
1295
+ f"Provider message: {status.message}"
1296
+ )
1297
+ except (FinCLIError, AttributeError) as exc:
1298
+ base = f"Provider health: unavailable ({exc})"
1299
+
1300
+ results = getattr(self.market_service, "provider_results", [])[-6:]
1301
+ if not results:
1302
+ return f"{base}\nRecent provider results: none"
1303
+ lines = ["Recent provider results:"]
1304
+ for result in results:
1305
+ missing = f"; missing={', '.join(result.missing_fields)}" if result.missing_fields else ""
1306
+ message = f"; {result.message}" if result.message and result.message != "ok" else ""
1307
+ lines.append(f"- {result.provider}.{result.operation}: {result.status}{missing}{message}")
1308
+ return f"{base}\n" + "\n".join(lines)
1198
1309
 
1199
1310
  def _safe_quote(self, symbol: str) -> Quote | None:
1200
1311
  try:
@@ -1202,7 +1313,7 @@ class CommandRouter:
1202
1313
  except FinCLIError:
1203
1314
  return None
1204
1315
 
1205
- def _analysis_context(self, symbol: str) -> str:
1316
+ def _analysis_context(self, symbol: str) -> str:
1206
1317
  sections: list[str] = []
1207
1318
  try:
1208
1319
  news_items = self._run_async(self.market_service.news(symbol, limit=3))
@@ -1214,7 +1325,36 @@ class CommandRouter:
1214
1325
  sections.append(_format_fundamental_context(fundamentals))
1215
1326
  except (FinCLIError, AttributeError) as exc:
1216
1327
  sections.append(f"Fundamentals unavailable: {exc}")
1217
- return "\n\n".join(sections)
1328
+ return "\n\n".join(sections)
1329
+
1330
+ def _ai_grounding_context(self, symbol: str, timeframe: str) -> str:
1331
+ try:
1332
+ overview = self._run_async(build_market_overview(symbol, self.market_service, timeframe))
1333
+ quality = overview.data_quality
1334
+ missing = ", ".join(quality.missing_fields) if quality.missing_fields else "none"
1335
+ quality_text = (
1336
+ f"Data Quality: {quality.score}/100 | tier={quality.tier} | freshness={quality.freshness}\n"
1337
+ f"Provider Reliability: {quality.reliability_status} | provider={quality.provider}\n"
1338
+ f"Missing Data: {missing}"
1339
+ )
1340
+ except FinCLIError as exc:
1341
+ quality_text = (
1342
+ "Data Quality: unavailable\n"
1343
+ "Provider Reliability: unavailable\n"
1344
+ f"Missing Data: market overview unavailable ({exc})"
1345
+ )
1346
+
1347
+ metric_lines = []
1348
+ for provider in self.market_service.providers:
1349
+ metric = self.market_service.provider_metrics_snapshot().get(provider.name)
1350
+ if metric is None:
1351
+ metric_lines.append(f"- {provider.name}: calls=0; success_rate=0.00%; errors=0; fallbacks=0")
1352
+ else:
1353
+ metric_lines.append(
1354
+ f"- {provider.name}: calls={metric.calls}; success_rate={metric.success_rate:.2f}%; "
1355
+ f"errors={metric.errors}; fallbacks={metric.fallbacks}; avg_latency={metric.avg_latency_ms:.2f}ms"
1356
+ )
1357
+ return f"{quality_text}\nProvider Metrics:\n" + "\n".join(metric_lines)
1218
1358
 
1219
1359
  def _freechat_market_context(self, prompt: str) -> str:
1220
1360
  symbols = extract_market_symbols(prompt)
@@ -1331,16 +1471,18 @@ class CommandRouter:
1331
1471
  def _build_market_service(self, injected_provider: BaseMarketProvider | None = None) -> MarketDataService:
1332
1472
  if injected_provider is not None:
1333
1473
  return MarketDataService(
1334
- [injected_provider],
1335
- cache=self.market_cache,
1336
- cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1337
- )
1474
+ [injected_provider],
1475
+ cache=self.market_cache,
1476
+ cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1477
+ metrics_store=self.provider_metrics_store,
1478
+ )
1338
1479
  priority = self.config.settings.market_provider_priority or [self.config.settings.market_provider]
1339
1480
  return MarketDataService(
1340
- self.market_manager.create_many(priority),
1341
- cache=self.market_cache,
1342
- cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1343
- )
1481
+ self.market_manager.create_many(priority),
1482
+ cache=self.market_cache,
1483
+ cache_ttl_seconds=self.config.settings.cache_ttl_seconds,
1484
+ metrics_store=self.provider_metrics_store,
1485
+ )
1344
1486
 
1345
1487
  def _refresh_market_service(self) -> None:
1346
1488
  self.market_service = self._build_market_service()
@@ -1516,17 +1658,22 @@ def _format_dashboard(
1516
1658
 
1517
1659
 
1518
1660
  def _format_market_overview(overview: MarketOverview) -> Table:
1519
- table = Table(title=f"Market Overview: {overview.symbol} | {overview.timeframe}", expand=True)
1661
+ table = Table(title=f"Market Overview: {overview.symbol} | {overview.timeframe}", expand=True)
1520
1662
  table.add_column("Section", style="cyan", no_wrap=True)
1521
1663
  table.add_column("Value", style="white")
1522
1664
  table.add_column("Context", style="dim")
1523
1665
 
1524
1666
  quality = overview.data_quality
1525
- table.add_row(
1526
- "Data Quality",
1527
- f"{quality.score}/100",
1528
- f"quote={quality.quote}; ohlcv={quality.ohlcv}; news={quality.news}; fundamentals={quality.fundamentals}; provider={quality.provider}",
1529
- )
1667
+ table.add_row(
1668
+ "Data Quality",
1669
+ semantic_text(f"{quality.score}/100 | {quality.label}"),
1670
+ (
1671
+ f"quote={quality.quote}; ohlcv={quality.ohlcv}; news={quality.news}; "
1672
+ f"fundamentals={quality.fundamentals}; provider={quality.provider}; "
1673
+ f"Reliability={quality.reliability_status}; "
1674
+ f"Missing={', '.join(quality.missing_fields) if quality.missing_fields else 'none'}"
1675
+ ),
1676
+ )
1530
1677
  table.add_row(
1531
1678
  "Quote",
1532
1679
  f"{_fmt(overview.quote.price)} {overview.quote.currency}",
@@ -1567,11 +1714,64 @@ def _format_market_overview(overview: MarketOverview) -> Table:
1567
1714
  else:
1568
1715
  table.add_row("Latest News", "N/A", "Provider did not return recent news.")
1569
1716
 
1570
- table.add_row("Disclaimer", "Informational only", "Bukan nasihat keuangan.")
1571
- return table
1572
-
1573
-
1574
- def _format_technical(
1717
+ table.add_row("Disclaimer", "Informational only", "Bukan nasihat keuangan.")
1718
+ return table
1719
+
1720
+
1721
+ def _format_portfolio_risk(report: PortfolioRiskReport) -> Table:
1722
+ table = Table(title="Portfolio Risk v3 | Portfolio Risk v2 compatible", expand=True)
1723
+ table.add_column("Section", style="cyan", no_wrap=True)
1724
+ table.add_column("Metric", style="white", overflow="fold")
1725
+ table.add_column("Value", justify="right", overflow="fold")
1726
+
1727
+ table.add_row("Health Score", report.health.label, f"{report.health.score}/100")
1728
+ table.add_row("Health Notes", ", ".join(report.health.notes), "")
1729
+ table.add_row("PnL Detail", "Cost Basis", _fmt(report.total_cost_basis))
1730
+ table.add_row("PnL Detail", "Market Value", _fmt(report.total_market_value))
1731
+ table.add_row("PnL Detail", "Realized PnL", semantic_text(_fmt(report.realized_pnl)))
1732
+ table.add_row("PnL Detail", "Unrealized PnL", semantic_text(_fmt(report.unrealized_pnl)))
1733
+ table.add_row("PnL Detail", "Total PnL", semantic_text(_fmt(report.total_pnl)))
1734
+ table.add_row("Drawdown Estimate", "Unrealized drawdown vs cost basis", f"{report.drawdown_estimate:.2f}%")
1735
+ table.add_row(
1736
+ "Risk Budget",
1737
+ f"{report.risk_budget.profile_gameplay} | {report.risk_budget.note}",
1738
+ f"{_fmt(report.risk_budget.risk_per_trade)} / {_fmt(report.risk_budget.max_portfolio_risk)} {report.risk_budget.currency}",
1739
+ )
1740
+ table.add_row(
1741
+ "Concentration Risk",
1742
+ f"{report.concentration.level}: {report.concentration.top_symbol}",
1743
+ f"{report.concentration.top_weight:.2f}%",
1744
+ )
1745
+ table.add_row("Concentration Risk", report.concentration.note, "")
1746
+
1747
+ if report.exposure_by_asset_class:
1748
+ for exposure in report.exposure_by_asset_class.values():
1749
+ table.add_row(
1750
+ "Exposure by Asset Class",
1751
+ f"{exposure.asset_class} ({exposure.count} position(s))",
1752
+ f"{_fmt(exposure.market_value)} | {exposure.weight:.2f}%",
1753
+ )
1754
+ else:
1755
+ table.add_row("Exposure by Asset Class", "No positions", "-")
1756
+ if report.currency_exposure:
1757
+ for exposure in report.currency_exposure.values():
1758
+ table.add_row(
1759
+ "Currency Exposure",
1760
+ f"{exposure.currency} ({exposure.count} position(s))",
1761
+ f"{_fmt(exposure.market_value)} | {exposure.weight:.2f}%",
1762
+ )
1763
+ else:
1764
+ table.add_row("Currency Exposure", "No positions", "-")
1765
+ if report.asset_class_warnings:
1766
+ for warning in report.asset_class_warnings:
1767
+ table.add_row("Asset-Class Cap Warning", f"{warning.level}: {warning.note}", f"cap {warning.cap:.2f}%")
1768
+ else:
1769
+ table.add_row("Asset-Class Cap Warning", "none", "-")
1770
+ table.caption = "Portfolio Risk v3 is local analytics only. It is not financial advice."
1771
+ return table
1772
+
1773
+
1774
+ def _format_technical(
1575
1775
  symbol: str,
1576
1776
  interval: str,
1577
1777
  summary: TechnicalSummary,
@@ -1842,7 +2042,11 @@ def _calendar_static_fallback_note(provider_error: FinCLIError, public_error: Fi
1842
2042
 
1843
2043
 
1844
2044
  def _format_calendar(events: list[EconomicEvent], start: date, end: date, source: str, note: str) -> Table:
1845
- table = Table(title=f"Economic Calendar | {start.isoformat()} to {end.isoformat()} | {source}", expand=True)
2045
+ reliability = _calendar_reliability_status(events, source, note)
2046
+ table = Table(
2047
+ title=f"Economic Calendar | {start.isoformat()} to {end.isoformat()} | {source} | {reliability}",
2048
+ expand=True,
2049
+ )
1846
2050
  table.add_column("Time", style="cyan", no_wrap=True, width=16, max_width=16)
1847
2051
  table.add_column("Country", no_wrap=True, width=7, max_width=7)
1848
2052
  table.add_column("Impact", no_wrap=True, width=6, max_width=6)
@@ -1864,10 +2068,25 @@ def _format_calendar(events: list[EconomicEvent], start: date, end: date, source
1864
2068
  "Summary",
1865
2069
  source,
1866
2070
  "-",
1867
- f"total={summary['total']}; high={summary.get('high', 0)}; medium={summary.get('medium', 0)}; low={summary.get('low', 0)}",
2071
+ f"total={summary['total']}; high={summary.get('high', 0)}; medium={summary.get('medium', 0)}; "
2072
+ f"low={summary.get('low', 0)}; reliability={reliability}",
1868
2073
  )
1869
2074
  table.add_row("Note", source, "-", note)
1870
2075
  return table
2076
+
2077
+
2078
+ def _calendar_reliability_status(events: list[EconomicEvent], source: str, note: str) -> str:
2079
+ normalized_source = source.lower()
2080
+ normalized_note = note.lower()
2081
+ if normalized_source == "finnhub" and events:
2082
+ return STATUS_OK
2083
+ if normalized_source == "fallback":
2084
+ return STATUS_SCHEDULE_ONLY
2085
+ if "static macro fallback" in normalized_note or "fallback kategori" in normalized_note:
2086
+ return STATUS_SCHEDULE_ONLY
2087
+ if events:
2088
+ return STATUS_PARTIAL_DATA
2089
+ return STATUS_UNAVAILABLE
1871
2090
 
1872
2091
 
1873
2092
  def _format_provider_list() -> Table:
@@ -1912,15 +2131,53 @@ def _format_provider_key_status(manager: MarketProviderManager) -> Table:
1912
2131
  return table
1913
2132
 
1914
2133
 
2134
+ def _format_secrets_status(secrets: dict[str, str]) -> Table:
2135
+ table = Table(title="Local Secrets Status", expand=True)
2136
+ table.add_column("Key", style="cyan", no_wrap=True)
2137
+ table.add_column("Status", no_wrap=True)
2138
+ table.add_column("Source")
2139
+ for key in sorted(secrets):
2140
+ table.add_row(key, "set", "~/.fincli/secrets.env")
2141
+ if not secrets:
2142
+ table.add_row("-", "empty", "No local secrets stored.")
2143
+ table.caption = "Values are never printed. Use /secrets clear before publishing screenshots or sharing a machine."
2144
+ return table
2145
+
2146
+
1915
2147
  def _format_provider_metrics(service: MarketDataService) -> Table:
1916
- table = Table(title="Provider Metrics", expand=True)
1917
- table.add_column("Metric", style="cyan", no_wrap=True)
1918
- table.add_column("Value", overflow="fold")
1919
- table.add_row("Active Provider", service.primary_provider.name)
1920
- table.add_row("Provider Chain", ", ".join(provider.name for provider in service.providers))
1921
- table.add_row("Last Errors", "\n".join(service.last_errors) if service.last_errors else "none")
1922
- table.add_row("Runtime Label", "realtime/delayed depends on provider entitlement and API plan")
1923
- table.caption = "Use /provider entitlement for static capability labels and /provider test <symbol> for live checks."
2148
+ table = Table(title="Provider Metrics Dashboard", expand=True)
2149
+ table.add_column("Provider", style="cyan", no_wrap=True)
2150
+ table.add_column("Session Calls", justify="right")
2151
+ table.add_column("All-Time Calls", justify="right")
2152
+ table.add_column("Success Rate", justify="right")
2153
+ table.add_column("Avg Latency", justify="right")
2154
+ table.add_column("Fallback Count", justify="right")
2155
+ table.add_column("Error Count", justify="right")
2156
+ table.add_column("Last Status", no_wrap=True)
2157
+
2158
+ metrics = service.provider_metrics_snapshot()
2159
+ persisted = service.metrics_store.snapshot() if getattr(service, "metrics_store", None) is not None else {}
2160
+ for provider in service.providers:
2161
+ name = provider.name
2162
+ metric = metrics.get(name)
2163
+ persisted_metric = persisted.get(name)
2164
+ if metric is None:
2165
+ table.add_row(name, "0", str(persisted_metric.calls if persisted_metric else 0), "0.00%", "0.00ms", "0", "0", "not_called")
2166
+ continue
2167
+ table.add_row(
2168
+ name,
2169
+ str(metric.calls),
2170
+ str(persisted_metric.calls if persisted_metric else metric.calls),
2171
+ f"{metric.success_rate:.2f}%",
2172
+ f"{metric.avg_latency_ms:.2f}ms",
2173
+ str(metric.fallbacks),
2174
+ str(metric.errors),
2175
+ metric.last_status,
2176
+ )
2177
+ table.caption = (
2178
+ f"Active provider: {service.primary_provider.name}. "
2179
+ "Metrics are runtime-only for the current FinCLI session."
2180
+ )
1924
2181
  return table
1925
2182
 
1926
2183
 
@@ -2109,7 +2366,7 @@ def _format_plugins(plugins: list[PluginManifest], status_only: bool = False) ->
2109
2366
  if not status_only:
2110
2367
  empty.append("Create ~/.fincli/plugins/<name>/plugin.json to register a local plugin.")
2111
2368
  table.add_row(*empty)
2112
- table.caption = "Plugins are manifest-only in v0.2.2; FinCLI does not execute plugin code yet."
2369
+ table.caption = "Plugins are manifest-only in v0.3.0; FinCLI does not execute plugin code yet."
2113
2370
  return table
2114
2371
 
2115
2372
 
@@ -2176,7 +2433,11 @@ def _format_news_desk(desk: NewsDesk) -> Table:
2176
2433
  if not desk.items:
2177
2434
  table.add_row("-", "-", "No news from active providers.", desk.note, "-")
2178
2435
  lookback = f" | Lookback: {desk.lookback_days}d" if desk.lookback_days else ""
2179
- table.caption = f"Providers: {', '.join(desk.provider_chain)}{lookback} | {desk.note}"
2436
+ errors = f" | Errors: {len(desk.errors)}" if desk.errors else ""
2437
+ table.caption = (
2438
+ f"Providers: {', '.join(desk.provider_chain)}{lookback} | "
2439
+ f"Reliability: {desk.reliability_status}{errors} | {desk.note}"
2440
+ )
2180
2441
  return table
2181
2442
 
2182
2443
 
@@ -89,7 +89,7 @@ class PublicEconomicCalendarService:
89
89
  client = self._client or httpx.AsyncClient(
90
90
  timeout=20,
91
91
  follow_redirects=True,
92
- headers={"User-Agent": "FinCLI/0.2.2 economic-calendar"},
92
+ headers={"User-Agent": "FinCLI/0.3.0 economic-calendar"},
93
93
  )
94
94
  errors: list[str] = []
95
95
  try: