@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.
- package/README.md +217 -909
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +7 -2
- package/fincli/app/analysis/analyzer.py +8 -4
- package/fincli/app/analysis/assistant_context.py +1 -1
- package/fincli/app/cli/commands.py +7 -2
- package/fincli/app/cli/router.py +363 -102
- package/fincli/app/modules/economic_calendar.py +1 -1
- package/fincli/app/modules/portfolio_risk.py +305 -0
- package/fincli/app/plugins/loader.py +1 -1
- package/fincli/app/providers/reliability.py +86 -0
- package/fincli/app/research/__init__.py +2 -1
- package/fincli/app/research/engine.py +66 -4
- package/fincli/app/research/exporter.py +91 -0
- package/fincli/app/research/formatter.py +10 -5
- package/fincli/app/research/models.py +6 -0
- package/fincli/app/research/prompt_builder.py +8 -1
- package/fincli/app/services/macro_data.py +1 -1
- package/fincli/app/services/market_data.py +141 -36
- package/fincli/app/services/market_overview.py +42 -1
- package/fincli/app/services/news_aggregator.py +7 -2
- package/fincli/app/storage/database.py +12 -1
- package/fincli/app/storage/provider_metrics.py +61 -0
- package/fincli/app/storage/secrets.py +18 -0
- package/npm/bin/fincli.js +24 -1
- package/package.json +7 -5
- package/pyproject.toml +1 -1
package/fincli/app/cli/router.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
284
|
+
table = Table(title="FinCLI v0.3.1 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
|
|
283
|
-
|
|
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> [--
|
|
574
|
+
raise CommandError("Format: /research <symbol> [--deep|--report] [timeframe] [--export <md|json> <path>]")
|
|
557
575
|
symbol = args[0].upper()
|
|
558
|
-
|
|
559
|
-
|
|
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.
|
|
627
|
+
table.add_row("Version", "ok", "FinCLI v0.3.1 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 == "
|
|
741
|
-
return CommandResult(self.
|
|
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
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)};
|
|
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("
|
|
1918
|
-
table.add_column("
|
|
1919
|
-
table.
|
|
1920
|
-
table.
|
|
1921
|
-
table.
|
|
1922
|
-
table.
|
|
1923
|
-
table.
|
|
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.
|
|
2369
|
+
table.caption = "Plugins are manifest-only in v0.3.1; 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
|
-
|
|
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.
|
|
92
|
+
headers={"User-Agent": "FinCLI/0.3.1 economic-calendar"},
|
|
93
93
|
)
|
|
94
94
|
errors: list[str] = []
|
|
95
95
|
try:
|