@drico2008/fincli 0.1.3 → 0.1.9
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 +41 -7
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/assistant_context.py +27 -1
- package/fincli/app/analysis/indicators.py +1 -1
- package/fincli/app/analysis/market_structure.py +1 -1
- package/fincli/app/cli/commands.py +10 -4
- package/fincli/app/cli/router.py +211 -18
- package/fincli/app/modules/session_history.py +113 -0
- package/fincli/app/providers/ai/anthropic_provider.py +8 -7
- package/fincli/app/providers/ai/gemini_provider.py +8 -7
- package/fincli/app/providers/ai/groq_provider.py +8 -7
- package/fincli/app/providers/ai/huggingface_provider.py +8 -7
- package/fincli/app/providers/ai/openai_provider.py +8 -7
- package/fincli/app/providers/ai/openrouter_provider.py +8 -7
- package/fincli/app/providers/ai/together_provider.py +8 -7
- package/fincli/app/providers/market/manager.py +1 -1
- package/fincli/app/providers/market/news_provider.py +4 -4
- package/fincli/app/providers/market/yfinance_provider.py +1 -1
- package/fincli/app/services/web_research.py +267 -0
- package/fincli/app/storage/cache.py +2 -2
- package/fincli/app/storage/database.py +17 -0
- package/fincli/app/storage/secrets.py +5 -2
- package/fincli/app/tui/components.py +1 -1
- package/fincli/app/tui/layout.py +8 -7
- package/fincli/app/tui/market_provider_selector.py +3 -0
- package/fincli/app/tui/model_selector.py +11 -3
- package/fincli/app/utils/formatting.py +50 -0
- package/npm/bin/fincli.js +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
FinCLI adalah financial CLI/TUI terminal modern untuk memantau market, mengelola watchlist, portfolio, journal, konfigurasi provider, dan menyiapkan integrasi AI market analysis secara modular.
|
|
4
4
|
|
|
5
|
-
Status saat ini:
|
|
5
|
+
Status saat ini: FinCLI MVP aktif dengan TUI, provider chain, AI assistance, web research, portfolio, journal, watchlist, export, dan session history lokal.
|
|
6
6
|
|
|
7
7
|
- Textual TUI satu kolom dengan command palette inline yang bisa discroll; sidebar lama sudah dihapus agar output market lebih lega.
|
|
8
8
|
- Slash command router dengan command wajib FinCLI v0.1.
|
|
@@ -148,12 +148,17 @@ python -m fincli.app.main
|
|
|
148
148
|
/journal add BTC-USD bullish "Breakout gagal, tunggu konfirmasi"
|
|
149
149
|
/journal stats
|
|
150
150
|
/journal review
|
|
151
|
-
/
|
|
151
|
+
/history
|
|
152
|
+
/history sessions
|
|
153
|
+
/history save "Riset market pagi"
|
|
154
|
+
/quote AAPL
|
|
152
155
|
/technical BTC-USD 1d
|
|
153
156
|
/technical XAUUSD 1d
|
|
154
157
|
/technical EURUSD 1d
|
|
155
158
|
/structure BTC-USD 1d
|
|
156
159
|
/news AAPL
|
|
160
|
+
/web penyebab rupiah melemah hari ini
|
|
161
|
+
/web sources penyebab rupiah melemah hari ini
|
|
157
162
|
/funda MSFT
|
|
158
163
|
/yahoo BBRI history 6mo 1d
|
|
159
164
|
/yahoo BBRI statistics
|
|
@@ -177,7 +182,7 @@ python -m fincli.app.main
|
|
|
177
182
|
/exit
|
|
178
183
|
```
|
|
179
184
|
|
|
180
|
-
Command `/market`, `/
|
|
185
|
+
Command `/market`, `/quote`, `/technical`, `/structure`, `/news`, dan `/funda` sudah memakai provider chain aktif. Command `/ai` dan `/analyze` sudah memakai AI provider aktif dari `/ai_model` dan `.env`. `/analyze` membawa konteks indikator, struktur pasar, news, dan fundamental ringkas ke prompt AI. `/ai` juga mengambil quote, OHLCV/technical, structure, news, dan fundamental saat user menyebut symbol seperti `AAPL`, `EURUSD`, atau `XAUUSD`.
|
|
181
186
|
|
|
182
187
|
## AI Chat UX
|
|
183
188
|
|
|
@@ -207,8 +212,31 @@ AI assistant di dalam FinCLI dipersonalisasi untuk market workflow:
|
|
|
207
212
|
- Boleh free chat untuk pertanyaan umum, market, portfolio, journal, provider, dan risk workflow.
|
|
208
213
|
- Menolak coding/debugging/refactor/pembuatan software di dalam assistant FinCLI agar fokus app tetap jelas.
|
|
209
214
|
- Jika prompt berisi symbol eksplisit, FinCLI menyisipkan market context dari provider chain aktif sebelum memanggil AI provider.
|
|
215
|
+
- Jika prompt membutuhkan info terkini, FinCLI dapat mengambil konteks web publik dan memasukkannya ke AI prompt.
|
|
210
216
|
- Tidak membocorkan API key dan tidak mengklaim realtime jika provider aktif hanya delayed/fallback.
|
|
211
217
|
|
|
218
|
+
Contoh web-aware freechat:
|
|
219
|
+
|
|
220
|
+
```text
|
|
221
|
+
apa yang menyebabkan penurunan rupiah terhadap semua mata uang hari ini
|
|
222
|
+
berita terbaru BI rate dan dampaknya ke IHSG
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Untuk web search yang dirangkum oleh AI:
|
|
226
|
+
|
|
227
|
+
```text
|
|
228
|
+
/web penyebab rupiah melemah hari ini
|
|
229
|
+
/web update harga emas dan dollar index
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Untuk melihat sumber mentah tanpa ringkasan AI:
|
|
233
|
+
|
|
234
|
+
```text
|
|
235
|
+
/web sources penyebab rupiah melemah hari ini
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
FinCLI memakai lightweight HTTP web research, bukan Chrome automation. Ini lebih stabil untuk npm global install dan tidak membuka browser di background. Output tetap harus diverifikasi karena kualitas sumber web bisa berbeda-beda.
|
|
239
|
+
|
|
212
240
|
## Interactive AI Model Selector
|
|
213
241
|
|
|
214
242
|
```text
|
|
@@ -474,7 +502,7 @@ FinCLI memakai yfinance untuk akses saham global yang tersedia di Yahoo Finance.
|
|
|
474
502
|
Command:
|
|
475
503
|
|
|
476
504
|
```text
|
|
477
|
-
/
|
|
505
|
+
/quote BBRI
|
|
478
506
|
/technical BBRI 1d
|
|
479
507
|
/analyze BBRI 1d
|
|
480
508
|
/yahoo BBRI history 6mo 1d
|
|
@@ -643,8 +671,14 @@ FinCLI menyimpan data lokal di:
|
|
|
643
671
|
~/.fincli/fincli.log
|
|
644
672
|
```
|
|
645
673
|
|
|
646
|
-
API key tidak disimpan di output terminal
|
|
647
|
-
|
|
674
|
+
API key tidak disimpan di output terminal. Untuk install global via npm, jalur utama adalah command FinCLI:
|
|
675
|
+
|
|
676
|
+
```text
|
|
677
|
+
/ai_model key groq <api_key>
|
|
678
|
+
/news_model key twelvedata <api_key>
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Key disimpan lokal di `~/.fincli/secrets.env`, dipakai otomatis untuk semua session FinCLI berikutnya, dan tidak perlu dikonfigurasi ulang. Jika `.env` lokal berisi nilai kosong, FinCLI tetap memakai secret lokal yang sudah tersimpan.
|
|
648
682
|
|
|
649
683
|
## Test
|
|
650
684
|
|
|
@@ -663,7 +697,7 @@ Hasil terakhir di environment ini:
|
|
|
663
697
|
- `fincli` tidak dikenali: jalankan `pip install -e .` dari root project.
|
|
664
698
|
- TUI tidak tampil rapi: perbesar terminal desktop.
|
|
665
699
|
- API key tidak terbaca: gunakan `/ai_model key <provider> <api_key>` atau `/news_model key <provider> <api_key>`, lalu cek `/config` atau `/provider key status`.
|
|
666
|
-
- `/
|
|
700
|
+
- `/quote` gagal karena yfinance belum ada: jalankan `pip install -e ".[dev]"` atau `pip install -r requirements.txt`.
|
|
667
701
|
- Config rusak: hapus `~/.fincli/config.json` untuk kembali ke default.
|
|
668
702
|
|
|
669
703
|
## Roadmap Lanjutan
|
package/fincli/__init__.py
CHANGED
|
@@ -15,6 +15,7 @@ Identity and scope:
|
|
|
15
15
|
|
|
16
16
|
Financial analysis rules:
|
|
17
17
|
- Analyze from provided market context first. Do not invent prices, news, fundamentals, provider status, or certainty.
|
|
18
|
+
- If Web Research Context is provided, use it as current public context, mention source URLs, and separate sourced facts from interpretation.
|
|
18
19
|
- Use probabilistic language: scenario, bias, confirmation, invalidation, risk, caution.
|
|
19
20
|
- Do not promise profit and do not present aggressive entries as guaranteed signals.
|
|
20
21
|
- For technical analysis, weigh trend, momentum, volatility, support/resistance, market structure, and data quality.
|
|
@@ -155,7 +156,32 @@ def build_fincli_assistant_prompt(user_prompt: str, market_context: str = "") ->
|
|
|
155
156
|
f"{user_prompt.strip()}\n\n"
|
|
156
157
|
"Instruction:\n"
|
|
157
158
|
"- Answer the user's prompt directly.\n"
|
|
158
|
-
"- If market context is present, cite provider/data-quality limitations.\n"
|
|
159
|
+
"- If market or web context is present, cite provider/data-quality limitations and source URLs when available.\n"
|
|
159
160
|
"- If market context is missing and the user asks about an instrument, say what data is missing.\n"
|
|
160
161
|
"- Keep the coding boundary enforced.\n"
|
|
161
162
|
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_web_research_answer_prompt(user_prompt: str, web_context: str) -> str:
|
|
166
|
+
"""Build a prompt that turns gathered web context into an answer, not a source dump."""
|
|
167
|
+
context = web_context.strip() or "Web Research: no public web context returned."
|
|
168
|
+
return (
|
|
169
|
+
f"{FINCLI_ASSISTANT_SYSTEM_PROMPT}\n\n"
|
|
170
|
+
"Web Search Skill Result:\n"
|
|
171
|
+
f"{context}\n\n"
|
|
172
|
+
"User Prompt:\n"
|
|
173
|
+
f"{user_prompt.strip()}\n\n"
|
|
174
|
+
"Instruction:\n"
|
|
175
|
+
"- You already have web search context above. Do not answer by only listing articles or links.\n"
|
|
176
|
+
"- Synthesize the sources into a useful explanation/summary for the user.\n"
|
|
177
|
+
"- Prioritize facts found in the web context, then clearly label interpretation.\n"
|
|
178
|
+
"- If sources disagree or are thin, say that the evidence is limited.\n"
|
|
179
|
+
"- Use this output structure when relevant:\n"
|
|
180
|
+
" 1. Ringkasan singkat\n"
|
|
181
|
+
" 2. Poin utama/penyebab\n"
|
|
182
|
+
" 3. Dampak atau implikasi\n"
|
|
183
|
+
" 4. Risiko dan hal yang perlu diverifikasi\n"
|
|
184
|
+
" 5. Sumber singkat\n"
|
|
185
|
+
"- Keep source citations compact: source title or URL only where useful.\n"
|
|
186
|
+
"- Do not provide financial advice or certainty about market direction.\n"
|
|
187
|
+
)
|
|
@@ -26,7 +26,7 @@ class TechnicalSummary:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def phase_one_indicator_status() -> str:
|
|
29
|
-
return "Indicator engine
|
|
29
|
+
return "Indicator engine active: SMA, EMA, RSI, MACD, Bollinger Bands, ATR, volume, support, and resistance."
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def summarize_technical_indicators(candles: list[Candle]) -> TechnicalSummary:
|
|
@@ -20,7 +20,7 @@ class MarketStructureSummary:
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def phase_one_structure_status() -> str:
|
|
23
|
-
return "Market structure
|
|
23
|
+
return "Market structure engine active: HH/HL/LH/LL, BOS, CHoCH, liquidity area, and risk zone."
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def analyze_market_structure(candles: list[Candle], lookback: int = 20) -> MarketStructureSummary:
|
|
@@ -22,6 +22,8 @@ COMMANDS: tuple[CommandSpec, ...] = (
|
|
|
22
22
|
CommandSpec("/news_model key", "Simpan API key market/news lokal.", "/news_model key finnhub <api_key>", "Provider"),
|
|
23
23
|
CommandSpec("/market", "Ringkasan market profesional untuk instrumen.", "/market AAPL 1d", "Market"),
|
|
24
24
|
CommandSpec("/news", "Tampilkan news/fundamental terbaru untuk instrumen.", "/news AAPL", "Market"),
|
|
25
|
+
CommandSpec("/web", "Web search lalu AI merangkum jawaban.", "/web penyebab rupiah melemah hari ini", "Research"),
|
|
26
|
+
CommandSpec("/web sources", "Tampilkan sumber mentah hasil web search.", "/web sources penyebab rupiah melemah hari ini", "Research"),
|
|
25
27
|
CommandSpec("/technical", "Analisis teknikal instrumen.", "/technical BTC-USD 1d", "Analysis"),
|
|
26
28
|
CommandSpec("/structure", "Analisis struktur pasar instrumen.", "/structure BTC-USD 1d", "Analysis"),
|
|
27
29
|
CommandSpec("/funda", "Fundamental ringkas instrumen.", "/funda MSFT", "Market"),
|
|
@@ -41,9 +43,13 @@ COMMANDS: tuple[CommandSpec, ...] = (
|
|
|
41
43
|
CommandSpec("/journal add", "Tambahkan catatan journal singkat.", '/journal add BTC-USD bullish "Breakout gagal, tunggu konfirmasi"', "Journal"),
|
|
42
44
|
CommandSpec("/journal stats", "Tampilkan statistik journal.", "/journal stats", "Journal"),
|
|
43
45
|
CommandSpec("/journal review", "AI review kebiasaan journal.", "/journal review", "Journal"),
|
|
46
|
+
CommandSpec("/history", "Tampilkan command history current session.", "/history", "History"),
|
|
47
|
+
CommandSpec("/history sessions", "Tampilkan daftar session tersimpan.", "/history sessions", "History"),
|
|
48
|
+
CommandSpec("/history show", "Tampilkan detail session tertentu.", "/history show <session_id>", "History"),
|
|
49
|
+
CommandSpec("/history save", "Beri nama current session.", '/history save "Riset IHSG pagi"', "History"),
|
|
50
|
+
CommandSpec("/history delete", "Hapus session tertentu.", "/history delete <session_id>", "History"),
|
|
44
51
|
CommandSpec("/config", "Tampilkan konfigurasi aktif tanpa membocorkan API key.", "/config"),
|
|
45
|
-
CommandSpec("/
|
|
46
|
-
CommandSpec("/quote", "Alias harga/quote instrumen.", "/quote NVDA", "Market"),
|
|
52
|
+
CommandSpec("/quote", "Tampilkan harga/quote instrumen.", "/quote NVDA", "Market"),
|
|
47
53
|
CommandSpec("/scan", "Scanner watchlist dengan filter indikator.", "/scan watchlist rsi<30", "Market"),
|
|
48
54
|
CommandSpec("/calendar", "Economic calendar provider/fallback.", "/calendar week US high", "Market"),
|
|
49
55
|
CommandSpec("/provider status", "Tampilkan status provider aktif.", "/provider status", "Provider"),
|
|
@@ -52,8 +58,8 @@ COMMANDS: tuple[CommandSpec, ...] = (
|
|
|
52
58
|
CommandSpec("/provider key status", "Tampilkan status API key market provider.", "/provider key status", "Provider"),
|
|
53
59
|
CommandSpec("/cache stats", "Tampilkan statistik cache market persistent.", "/cache stats", "System"),
|
|
54
60
|
CommandSpec("/cache clear", "Bersihkan runtime dan persistent market cache.", "/cache clear", "System"),
|
|
55
|
-
CommandSpec("/export journal", "Export journal
|
|
56
|
-
CommandSpec("/export portfolio", "Export portfolio
|
|
61
|
+
CommandSpec("/export journal", "Export journal ke CSV/JSON.", "/export journal csv journal.csv", "Export"),
|
|
62
|
+
CommandSpec("/export portfolio", "Export portfolio ke CSV/JSON.", "/export portfolio json portfolio.json", "Export"),
|
|
57
63
|
CommandSpec("/clear", "Bersihkan output terminal.", "/clear"),
|
|
58
64
|
CommandSpec("/exit", "Keluar dari aplikasi.", "/exit"),
|
|
59
65
|
)
|
package/fincli/app/cli/router.py
CHANGED
|
@@ -6,16 +6,19 @@ import asyncio
|
|
|
6
6
|
from concurrent.futures import ThreadPoolExecutor
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from datetime import date
|
|
9
|
+
import io
|
|
9
10
|
import os
|
|
10
11
|
import shlex
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
14
|
+
from rich.console import Console
|
|
13
15
|
from rich.panel import Panel
|
|
14
16
|
from rich.table import Table
|
|
15
17
|
|
|
16
18
|
from fincli.app.cli.commands import CommandRegistry
|
|
17
19
|
from fincli.app.analysis.analyzer import build_market_analysis_prompt, build_technical_ai_summary
|
|
18
20
|
from fincli.app.analysis.assistant_context import (
|
|
21
|
+
build_web_research_answer_prompt,
|
|
19
22
|
build_fincli_assistant_prompt,
|
|
20
23
|
coding_refusal,
|
|
21
24
|
extract_market_symbols,
|
|
@@ -37,6 +40,7 @@ from fincli.app.modules.journal_analytics import JournalStats, build_journal_rev
|
|
|
37
40
|
from fincli.app.modules.journal import JournalService
|
|
38
41
|
from fincli.app.modules.portfolio import PortfolioService
|
|
39
42
|
from fincli.app.modules.scanner import ScanResult, scan_symbols
|
|
43
|
+
from fincli.app.modules.session_history import SessionHistoryService
|
|
40
44
|
from fincli.app.modules.transactions import TransactionService
|
|
41
45
|
from fincli.app.modules.watchlist import WatchlistService
|
|
42
46
|
from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
|
|
@@ -46,12 +50,19 @@ from fincli.app.providers.market.manager import MarketProviderManager
|
|
|
46
50
|
from fincli.app.providers.market.yfinance_provider import YahooTable, YFinanceProvider
|
|
47
51
|
from fincli.app.services.market_data import MarketDataService
|
|
48
52
|
from fincli.app.services.market_overview import MarketOverview, build_market_overview
|
|
53
|
+
from fincli.app.services.web_research import (
|
|
54
|
+
WebResearchService,
|
|
55
|
+
WebSearchResult,
|
|
56
|
+
build_web_research_context,
|
|
57
|
+
should_use_web_research,
|
|
58
|
+
)
|
|
49
59
|
from fincli.app.storage.cache import TTLCache
|
|
50
60
|
from fincli.app.storage.config import ConfigManager
|
|
51
61
|
from fincli.app.storage.database import FinCLIDatabase
|
|
52
62
|
from fincli.app.storage.market_cache import MarketCache
|
|
53
63
|
from fincli.app.storage.secrets import save_secret
|
|
54
64
|
from fincli.app.utils.errors import CommandError, FinCLIError
|
|
65
|
+
from fincli.app.utils.formatting import AIResponseView, MarkdownBlock
|
|
55
66
|
|
|
56
67
|
|
|
57
68
|
@dataclass(slots=True)
|
|
@@ -86,8 +97,16 @@ class CommandRouter:
|
|
|
86
97
|
self.portfolio = PortfolioService(self.db)
|
|
87
98
|
self.transactions = TransactionService(self.db, self.portfolio)
|
|
88
99
|
self.journal = JournalService(self.db)
|
|
100
|
+
self.history = SessionHistoryService(self.db)
|
|
101
|
+
self.session_id = self.history.start_session()
|
|
102
|
+
self.web_research = WebResearchService()
|
|
89
103
|
|
|
90
104
|
def route(self, raw: str) -> CommandResult:
|
|
105
|
+
result = self._route(raw)
|
|
106
|
+
self._record_history(raw, result)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def _route(self, raw: str) -> CommandResult:
|
|
91
110
|
raw = raw.strip()
|
|
92
111
|
if not raw:
|
|
93
112
|
return CommandResult(Panel("Ketik /help untuk melihat command.", title="FinCLI"))
|
|
@@ -117,6 +136,8 @@ class CommandRouter:
|
|
|
117
136
|
return CommandResult("Keluar dari FinCLI.", should_exit=True)
|
|
118
137
|
if root == "/config":
|
|
119
138
|
return CommandResult(self._config_panel())
|
|
139
|
+
if root == "/history":
|
|
140
|
+
return self._history(args)
|
|
120
141
|
if root == "/ai_model":
|
|
121
142
|
return self._ai_model(args)
|
|
122
143
|
if root == "/news_model":
|
|
@@ -133,8 +154,8 @@ class CommandRouter:
|
|
|
133
154
|
return self._tx(args)
|
|
134
155
|
if root == "/journal":
|
|
135
156
|
return self._journal(args)
|
|
136
|
-
if root
|
|
137
|
-
return self.
|
|
157
|
+
if root == "/quote":
|
|
158
|
+
return self._quote(args)
|
|
138
159
|
if root == "/market":
|
|
139
160
|
return self._market(args)
|
|
140
161
|
if root == "/technical":
|
|
@@ -143,6 +164,8 @@ class CommandRouter:
|
|
|
143
164
|
return self._structure(args)
|
|
144
165
|
if root == "/news":
|
|
145
166
|
return self._news(args)
|
|
167
|
+
if root == "/web":
|
|
168
|
+
return self._web(args)
|
|
146
169
|
if root == "/funda":
|
|
147
170
|
return self._fundamentals(args)
|
|
148
171
|
if root == "/yahoo":
|
|
@@ -180,6 +203,57 @@ class CommandRouter:
|
|
|
180
203
|
table.add_row(command.name, command.group, command.description, command.example)
|
|
181
204
|
return table
|
|
182
205
|
|
|
206
|
+
def _record_history(self, raw: str, result: CommandResult) -> None:
|
|
207
|
+
normalized = raw.strip().lower()
|
|
208
|
+
if not normalized or normalized.startswith("/history"):
|
|
209
|
+
return
|
|
210
|
+
try:
|
|
211
|
+
preview = _render_history_preview(result.renderable)
|
|
212
|
+
self.history.record_event(self.session_id, raw, result.status, preview)
|
|
213
|
+
except FinCLIError:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
def _history(self, args: list[str]) -> CommandResult:
|
|
217
|
+
action = args[0].lower() if args else "current"
|
|
218
|
+
if action in {"current", "show"}:
|
|
219
|
+
session_id = self.session_id if action == "current" or len(args) == 1 else args[1]
|
|
220
|
+
if action == "show" and len(args) < 2:
|
|
221
|
+
raise CommandError("Format: /history show <session_id>")
|
|
222
|
+
session = self.history.get_session(session_id)
|
|
223
|
+
if not session:
|
|
224
|
+
raise CommandError(f"Session tidak ditemukan: {session_id}")
|
|
225
|
+
events = self.history.get_events(session_id)
|
|
226
|
+
return CommandResult(_format_session_events(session, events, current=session_id == self.session_id))
|
|
227
|
+
if action in {"sessions", "list"}:
|
|
228
|
+
sessions = self.history.list_sessions()
|
|
229
|
+
return CommandResult(_format_sessions(sessions, self.session_id))
|
|
230
|
+
if action == "save":
|
|
231
|
+
title = " ".join(args[1:]).strip()
|
|
232
|
+
if not title:
|
|
233
|
+
raise CommandError("Format: /history save <session_title>")
|
|
234
|
+
self.history.save_session(self.session_id, title)
|
|
235
|
+
return CommandResult(Panel(f"Current session disimpan sebagai: {title}", title="History", border_style="green"))
|
|
236
|
+
if action == "delete":
|
|
237
|
+
if len(args) < 2:
|
|
238
|
+
raise CommandError("Format: /history delete <session_id>")
|
|
239
|
+
if args[1] == self.session_id:
|
|
240
|
+
self.history.clear_events(self.session_id)
|
|
241
|
+
self.history.save_session(self.session_id, "FinCLI session")
|
|
242
|
+
return CommandResult(Panel("Current session dikosongkan.", title="History", border_style="yellow"))
|
|
243
|
+
self.history.delete_session(args[1])
|
|
244
|
+
return CommandResult(Panel(f"Session dihapus: {args[1]}", title="History", border_style="green"))
|
|
245
|
+
if action == "clear":
|
|
246
|
+
target = args[1].lower() if len(args) >= 2 else "current"
|
|
247
|
+
if target == "all":
|
|
248
|
+
self.history.clear_all()
|
|
249
|
+
self.session_id = self.history.start_session()
|
|
250
|
+
return CommandResult(Panel("Semua history session dihapus. Session baru dibuat.", title="History"))
|
|
251
|
+
self.history.clear_events(self.session_id)
|
|
252
|
+
return CommandResult(Panel("Current session history dikosongkan.", title="History"))
|
|
253
|
+
raise CommandError(
|
|
254
|
+
"Format: /history [current|sessions|show <id>|save <title>|delete <id>|clear current|clear all]"
|
|
255
|
+
)
|
|
256
|
+
|
|
183
257
|
def _dashboard(self) -> Table:
|
|
184
258
|
return _format_dashboard(
|
|
185
259
|
provider_chain=[provider.name for provider in self.market_service.providers],
|
|
@@ -220,11 +294,16 @@ class CommandRouter:
|
|
|
220
294
|
if info is None:
|
|
221
295
|
raise CommandError(f"AI provider tidak dikenal: {provider}")
|
|
222
296
|
save_secret(info.env_key, args[2])
|
|
223
|
-
if self.config.settings.ai_provider == provider
|
|
224
|
-
|
|
297
|
+
model = self.config.settings.ai_model if self.config.settings.ai_provider == provider else info.default_model
|
|
298
|
+
self.config.set_ai_model(provider, model)
|
|
299
|
+
self.ai_provider = AIProviderManager().create(provider)
|
|
225
300
|
return CommandResult(
|
|
226
301
|
Panel(
|
|
227
|
-
|
|
302
|
+
(
|
|
303
|
+
f"API key AI untuk {provider} disimpan global di ~/.fincli/secrets.env.\n"
|
|
304
|
+
f"Provider aktif disimpan: {provider} / {model}.\n"
|
|
305
|
+
"Key tidak ditampilkan di terminal dan dipakai lintas session."
|
|
306
|
+
),
|
|
228
307
|
title="AI API Key Saved",
|
|
229
308
|
border_style="green",
|
|
230
309
|
)
|
|
@@ -260,13 +339,17 @@ class CommandRouter:
|
|
|
260
339
|
save_secret(env_keys[0], args[2])
|
|
261
340
|
if provider == "custom" and len(args) >= 4:
|
|
262
341
|
save_secret("MARKET_DATA_BASE_URL", args[3])
|
|
342
|
+
self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
|
|
263
343
|
self._refresh_market_service()
|
|
264
344
|
self.cache.clear()
|
|
265
345
|
extra = "\nBase URL custom juga disimpan." if provider == "custom" and len(args) >= 4 else ""
|
|
266
346
|
return CommandResult(
|
|
267
347
|
Panel(
|
|
268
|
-
|
|
269
|
-
|
|
348
|
+
(
|
|
349
|
+
f"API key market/news untuk {provider} disimpan global di ~/.fincli/secrets.env.{extra}\n"
|
|
350
|
+
f"Provider market/news aktif disimpan: {provider}.\n"
|
|
351
|
+
"Key tidak ditampilkan di terminal dan dipakai lintas session."
|
|
352
|
+
),
|
|
270
353
|
title="Market API Key Saved",
|
|
271
354
|
border_style="green",
|
|
272
355
|
)
|
|
@@ -481,7 +564,9 @@ class CommandRouter:
|
|
|
481
564
|
response = self._run_async(self.ai_provider.complete(AIRequest(prompt=prompt, model=self.config.settings.ai_model)))
|
|
482
565
|
if not isinstance(response, AIResponse):
|
|
483
566
|
raise CommandError("AI provider mengembalikan data tidak valid.")
|
|
484
|
-
return CommandResult(
|
|
567
|
+
return CommandResult(
|
|
568
|
+
MarkdownBlock("Journal Review", _format_ai_response(response), "Disclaimer: bukan nasihat keuangan.")
|
|
569
|
+
)
|
|
485
570
|
|
|
486
571
|
if args[0].lower() == "add":
|
|
487
572
|
if len(args) < 3:
|
|
@@ -511,9 +596,9 @@ class CommandRouter:
|
|
|
511
596
|
table.add_row("-", "-", "-", 'Belum ada journal. Gunakan /journal add BTC-USD bullish "Alasan entry"', "-")
|
|
512
597
|
return table
|
|
513
598
|
|
|
514
|
-
def
|
|
599
|
+
def _quote(self, args: list[str]) -> CommandResult:
|
|
515
600
|
if not args:
|
|
516
|
-
raise CommandError("Format: /
|
|
601
|
+
raise CommandError("Format: /quote <symbol>")
|
|
517
602
|
symbol = args[0].upper()
|
|
518
603
|
cache_key = f"quote:{symbol}"
|
|
519
604
|
cached = self.cache.get(cache_key)
|
|
@@ -595,6 +680,9 @@ class CommandRouter:
|
|
|
595
680
|
return CommandResult(_format_ai_response(response))
|
|
596
681
|
|
|
597
682
|
market_context = self._freechat_market_context(prompt)
|
|
683
|
+
web_context = self._freechat_web_context(prompt)
|
|
684
|
+
if web_context:
|
|
685
|
+
market_context = f"{market_context}\n\n{web_context}".strip()
|
|
598
686
|
assistant_prompt = build_fincli_assistant_prompt(prompt, market_context)
|
|
599
687
|
request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
|
|
600
688
|
response = self._run_async(self.ai_provider.complete(request))
|
|
@@ -602,6 +690,26 @@ class CommandRouter:
|
|
|
602
690
|
raise CommandError("AI provider mengembalikan data tidak valid.")
|
|
603
691
|
return CommandResult(_format_ai_response(response))
|
|
604
692
|
|
|
693
|
+
def _web(self, args: list[str]) -> CommandResult:
|
|
694
|
+
if not args:
|
|
695
|
+
raise CommandError("Format: /web <query>")
|
|
696
|
+
if args[0].lower() in {"sources", "source", "raw"}:
|
|
697
|
+
source_query = " ".join(args[1:]).strip()
|
|
698
|
+
if not source_query:
|
|
699
|
+
raise CommandError("Format: /web sources <query>")
|
|
700
|
+
results = self._run_async(self.web_research.research(source_query, limit=5))
|
|
701
|
+
return CommandResult(_format_web_results(source_query, results))
|
|
702
|
+
|
|
703
|
+
query = " ".join(args)
|
|
704
|
+
results = self._run_async(self.web_research.research(query, limit=5))
|
|
705
|
+
context = build_web_research_context(results)
|
|
706
|
+
assistant_prompt = build_web_research_answer_prompt(query, context)
|
|
707
|
+
request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
|
|
708
|
+
response = self._run_async(self.ai_provider.complete(request))
|
|
709
|
+
if not isinstance(response, AIResponse):
|
|
710
|
+
raise CommandError("AI provider mengembalikan data tidak valid.")
|
|
711
|
+
return CommandResult(_format_ai_response(response))
|
|
712
|
+
|
|
605
713
|
def _analyze(self, args: list[str]) -> CommandResult:
|
|
606
714
|
if not args:
|
|
607
715
|
raise CommandError("Format: /analyze <symbol> [timeframe]")
|
|
@@ -618,7 +726,9 @@ class CommandRouter:
|
|
|
618
726
|
response = self._run_async(self.ai_provider.complete(request))
|
|
619
727
|
if not isinstance(response, AIResponse):
|
|
620
728
|
raise CommandError("AI provider mengembalikan data tidak valid.")
|
|
621
|
-
return CommandResult(
|
|
729
|
+
return CommandResult(
|
|
730
|
+
MarkdownBlock(f"AI Market Analysis: {symbol}", _format_ai_response(response), "Disclaimer: bukan nasihat keuangan.")
|
|
731
|
+
)
|
|
622
732
|
|
|
623
733
|
def _scan(self, args: list[str]) -> CommandResult:
|
|
624
734
|
if not args or args[0].lower() != "watchlist":
|
|
@@ -757,6 +867,21 @@ class CommandRouter:
|
|
|
757
867
|
sections.append(self._symbol_freechat_context(symbol))
|
|
758
868
|
return "\n\n".join(sections)
|
|
759
869
|
|
|
870
|
+
def _freechat_web_context(self, prompt: str) -> str:
|
|
871
|
+
if not should_use_web_research(prompt):
|
|
872
|
+
return ""
|
|
873
|
+
cache_key = f"web:{prompt.lower()[:180]}"
|
|
874
|
+
cached = self.cache.get(cache_key)
|
|
875
|
+
if isinstance(cached, str):
|
|
876
|
+
return cached
|
|
877
|
+
try:
|
|
878
|
+
results = self._run_async(self.web_research.research(prompt, limit=3))
|
|
879
|
+
except FinCLIError as exc:
|
|
880
|
+
return f"Web Research: unavailable ({exc})"
|
|
881
|
+
context = build_web_research_context(results)
|
|
882
|
+
self.cache.set(cache_key, context)
|
|
883
|
+
return context
|
|
884
|
+
|
|
760
885
|
def _symbol_freechat_context(self, symbol: str) -> str:
|
|
761
886
|
lines = [f"Symbol: {symbol}"]
|
|
762
887
|
try:
|
|
@@ -879,6 +1004,63 @@ def _format_quote(quote: Quote) -> str:
|
|
|
879
1004
|
)
|
|
880
1005
|
|
|
881
1006
|
|
|
1007
|
+
def _format_sessions(sessions: list[dict[str, object]], current_session_id: str) -> Table:
|
|
1008
|
+
table = Table(title="FinCLI Sessions", expand=True)
|
|
1009
|
+
table.add_column("Current", justify="center", width=7)
|
|
1010
|
+
table.add_column("Session ID", style="cyan", no_wrap=True)
|
|
1011
|
+
table.add_column("Title", style="white")
|
|
1012
|
+
table.add_column("Events", justify="right")
|
|
1013
|
+
table.add_column("Updated", style="dim")
|
|
1014
|
+
for session in sessions:
|
|
1015
|
+
session_id = str(session["id"])
|
|
1016
|
+
table.add_row(
|
|
1017
|
+
"*" if session_id == current_session_id else "",
|
|
1018
|
+
session_id,
|
|
1019
|
+
str(session["title"]),
|
|
1020
|
+
str(session["event_count"]),
|
|
1021
|
+
str(session["updated_at"]),
|
|
1022
|
+
)
|
|
1023
|
+
if not sessions:
|
|
1024
|
+
table.add_row("-", "-", "Belum ada session.", "0", "-")
|
|
1025
|
+
table.caption = "/history current | /history show <session_id> | /history delete <session_id>"
|
|
1026
|
+
return table
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _format_session_events(session: dict[str, object], events: list[dict[str, object]], current: bool = False) -> Table:
|
|
1030
|
+
marker = "current" if current else "saved"
|
|
1031
|
+
table = Table(title=f"Session {session['id']} ({marker}) - {session['title']}", expand=True)
|
|
1032
|
+
table.add_column("#", justify="right", width=4)
|
|
1033
|
+
table.add_column("Time", style="dim", no_wrap=True)
|
|
1034
|
+
table.add_column("Status", style="cyan", no_wrap=True)
|
|
1035
|
+
table.add_column("Command", style="white")
|
|
1036
|
+
table.add_column("Output Preview", style="dim")
|
|
1037
|
+
for event in events:
|
|
1038
|
+
table.add_row(
|
|
1039
|
+
str(event["id"]),
|
|
1040
|
+
str(event["created_at"]),
|
|
1041
|
+
str(event["status"]),
|
|
1042
|
+
str(event["command"]),
|
|
1043
|
+
str(event["output_preview"] or "")[:180],
|
|
1044
|
+
)
|
|
1045
|
+
if not events:
|
|
1046
|
+
table.add_row("-", "-", "-", "Belum ada command di session ini.", "")
|
|
1047
|
+
table.caption = "/history sessions | /history save <title> | /history clear current"
|
|
1048
|
+
return table
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _render_history_preview(renderable: Any) -> str:
|
|
1052
|
+
if renderable is None:
|
|
1053
|
+
return ""
|
|
1054
|
+
if isinstance(renderable, str):
|
|
1055
|
+
return renderable[:1200]
|
|
1056
|
+
console = Console(width=100, record=True, force_terminal=False, file=io.StringIO())
|
|
1057
|
+
try:
|
|
1058
|
+
console.print(renderable)
|
|
1059
|
+
return console.export_text(clear=False).strip()[:1200]
|
|
1060
|
+
except Exception:
|
|
1061
|
+
return str(renderable)[:1200]
|
|
1062
|
+
|
|
1063
|
+
|
|
882
1064
|
def _format_dashboard(
|
|
883
1065
|
provider_chain: list[str],
|
|
884
1066
|
watchlist_rows: list[dict[str, object]],
|
|
@@ -1218,6 +1400,21 @@ def _format_news(symbol: str, items: list[NewsItem]) -> str:
|
|
|
1218
1400
|
return "\n".join(lines)
|
|
1219
1401
|
|
|
1220
1402
|
|
|
1403
|
+
def _format_web_results(query: str, results: list[WebSearchResult]) -> Table:
|
|
1404
|
+
table = Table(title=f"Web Research: {query}", expand=True)
|
|
1405
|
+
table.add_column("#", justify="right", width=3)
|
|
1406
|
+
table.add_column("Title", style="cyan", overflow="fold")
|
|
1407
|
+
table.add_column("Snippet / Extract", overflow="fold")
|
|
1408
|
+
table.add_column("URL", style="dim", overflow="fold")
|
|
1409
|
+
for index, result in enumerate(results, start=1):
|
|
1410
|
+
extract = result.content[:500] if result.content else result.snippet
|
|
1411
|
+
table.add_row(str(index), result.title, extract or "-", result.url)
|
|
1412
|
+
if not results:
|
|
1413
|
+
table.add_row("-", "No results", "Search providers returned no public context.", "-")
|
|
1414
|
+
table.caption = "Web context is public web data; verify source quality before using it for financial decisions."
|
|
1415
|
+
return table
|
|
1416
|
+
|
|
1417
|
+
|
|
1221
1418
|
def _format_fundamentals(snapshot: FundamentalSnapshot) -> str:
|
|
1222
1419
|
return (
|
|
1223
1420
|
f"Fundamental Snapshot: {snapshot.symbol}\n"
|
|
@@ -1277,12 +1474,8 @@ def _format_fundamental_context(snapshot: FundamentalSnapshot) -> str:
|
|
|
1277
1474
|
)
|
|
1278
1475
|
|
|
1279
1476
|
|
|
1280
|
-
def _format_ai_response(response: AIResponse) ->
|
|
1281
|
-
return (
|
|
1282
|
-
f"Provider: {response.provider}\n"
|
|
1283
|
-
f"Model: {response.model}\n"
|
|
1284
|
-
f"Response:\n{response.content}"
|
|
1285
|
-
)
|
|
1477
|
+
def _format_ai_response(response: AIResponse) -> AIResponseView:
|
|
1478
|
+
return AIResponseView(response)
|
|
1286
1479
|
|
|
1287
1480
|
|
|
1288
1481
|
def _fmt(value: float | None) -> str:
|
|
@@ -1300,5 +1493,5 @@ class UnavailableAIProvider:
|
|
|
1300
1493
|
async def complete(self, request: AIRequest) -> AIResponse:
|
|
1301
1494
|
raise CommandError(
|
|
1302
1495
|
f"AI provider {self.name} belum siap dipakai.",
|
|
1303
|
-
"
|
|
1496
|
+
"Gunakan /ai_model untuk memilih provider dan /ai_model key <provider> <api_key> untuk menyimpan API key.",
|
|
1304
1497
|
)
|