@drico2008/fincli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +644 -0
- package/fincli/__init__.py +3 -0
- package/fincli/app/__init__.py +1 -0
- package/fincli/app/analysis/__init__.py +1 -0
- package/fincli/app/analysis/ai_prompts.py +33 -0
- package/fincli/app/analysis/analyzer.py +119 -0
- package/fincli/app/analysis/assistant_context.py +161 -0
- package/fincli/app/analysis/indicators.py +143 -0
- package/fincli/app/analysis/market_structure.py +106 -0
- package/fincli/app/analysis/technical_debate.py +251 -0
- package/fincli/app/analysis/technical_signal.py +203 -0
- package/fincli/app/cli/__init__.py +1 -0
- package/fincli/app/cli/autocomplete.py +17 -0
- package/fincli/app/cli/commands.py +82 -0
- package/fincli/app/cli/router.py +1257 -0
- package/fincli/app/main.py +16 -0
- package/fincli/app/modules/__init__.py +1 -0
- package/fincli/app/modules/economic_calendar.py +139 -0
- package/fincli/app/modules/exporter.py +51 -0
- package/fincli/app/modules/journal.py +65 -0
- package/fincli/app/modules/journal_analytics.py +70 -0
- package/fincli/app/modules/portfolio.py +34 -0
- package/fincli/app/modules/scanner.py +105 -0
- package/fincli/app/modules/transactions.py +84 -0
- package/fincli/app/modules/watchlist.py +25 -0
- package/fincli/app/providers/__init__.py +1 -0
- package/fincli/app/providers/ai/__init__.py +1 -0
- package/fincli/app/providers/ai/anthropic_provider.py +11 -0
- package/fincli/app/providers/ai/base.py +29 -0
- package/fincli/app/providers/ai/gemini_provider.py +11 -0
- package/fincli/app/providers/ai/groq_provider.py +11 -0
- package/fincli/app/providers/ai/http_provider.py +145 -0
- package/fincli/app/providers/ai/huggingface_provider.py +11 -0
- package/fincli/app/providers/ai/manager.py +60 -0
- package/fincli/app/providers/ai/openai_provider.py +11 -0
- package/fincli/app/providers/ai/openrouter_provider.py +11 -0
- package/fincli/app/providers/ai/together_provider.py +11 -0
- package/fincli/app/providers/market/__init__.py +1 -0
- package/fincli/app/providers/market/base.py +77 -0
- package/fincli/app/providers/market/custom_provider.py +169 -0
- package/fincli/app/providers/market/finnhub_provider.py +187 -0
- package/fincli/app/providers/market/manager.py +123 -0
- package/fincli/app/providers/market/news_provider.py +28 -0
- package/fincli/app/providers/market/symbols.py +182 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -0
- package/fincli/app/providers/market/yfinance_provider.py +447 -0
- package/fincli/app/services/__init__.py +1 -0
- package/fincli/app/services/market_data.py +203 -0
- package/fincli/app/services/market_overview.py +111 -0
- package/fincli/app/storage/__init__.py +1 -0
- package/fincli/app/storage/cache.py +38 -0
- package/fincli/app/storage/config.py +114 -0
- package/fincli/app/storage/database.py +101 -0
- package/fincli/app/storage/market_cache.py +92 -0
- package/fincli/app/tui/__init__.py +1 -0
- package/fincli/app/tui/components.py +55 -0
- package/fincli/app/tui/layout.py +261 -0
- package/fincli/app/tui/market_provider_selector.py +267 -0
- package/fincli/app/tui/model_selector.py +412 -0
- package/fincli/app/tui/theme.py +157 -0
- package/fincli/app/utils/__init__.py +1 -0
- package/fincli/app/utils/errors.py +33 -0
- package/fincli/app/utils/formatting.py +17 -0
- package/fincli/app/utils/logger.py +19 -0
- package/npm/bin/fincli.js +35 -0
- package/npm/postinstall.js +72 -0
- package/package.json +23 -0
- package/pyproject.toml +31 -0
- package/requirements.txt +9 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Market overview orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
|
|
8
|
+
from fincli.app.analysis.market_structure import MarketStructureSummary, analyze_market_structure
|
|
9
|
+
from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, Quote
|
|
10
|
+
from fincli.app.services.market_data import MarketDataService
|
|
11
|
+
from fincli.app.utils.errors import FinCLIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class DataQuality:
|
|
16
|
+
score: int
|
|
17
|
+
quote: str
|
|
18
|
+
ohlcv: str
|
|
19
|
+
news: str
|
|
20
|
+
fundamentals: str
|
|
21
|
+
provider: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class MarketOverview:
|
|
26
|
+
symbol: str
|
|
27
|
+
timeframe: str
|
|
28
|
+
quote: Quote
|
|
29
|
+
candles: list[Candle]
|
|
30
|
+
technical: TechnicalSummary
|
|
31
|
+
structure: MarketStructureSummary
|
|
32
|
+
news: list[NewsItem]
|
|
33
|
+
fundamentals: FundamentalSnapshot | None
|
|
34
|
+
data_quality: DataQuality
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def build_market_overview(symbol: str, market_service: MarketDataService, timeframe: str = "1d") -> MarketOverview:
|
|
38
|
+
"""Build a compact market overview from available provider data."""
|
|
39
|
+
normalized = symbol.upper()
|
|
40
|
+
quote = await market_service.quote(normalized)
|
|
41
|
+
candles = await market_service.history(normalized, period="6mo", interval=timeframe)
|
|
42
|
+
technical = summarize_technical_indicators(candles)
|
|
43
|
+
structure = analyze_market_structure(candles)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
news = await market_service.news(normalized, limit=3)
|
|
47
|
+
except FinCLIError:
|
|
48
|
+
news = []
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
fundamentals = await market_service.fundamentals(normalized)
|
|
52
|
+
except FinCLIError:
|
|
53
|
+
fundamentals = None
|
|
54
|
+
|
|
55
|
+
quality = _score_data_quality(quote, candles, news, fundamentals)
|
|
56
|
+
return MarketOverview(
|
|
57
|
+
symbol=normalized,
|
|
58
|
+
timeframe=timeframe,
|
|
59
|
+
quote=quote,
|
|
60
|
+
candles=candles,
|
|
61
|
+
technical=technical,
|
|
62
|
+
structure=structure,
|
|
63
|
+
news=news,
|
|
64
|
+
fundamentals=fundamentals,
|
|
65
|
+
data_quality=quality,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _score_data_quality(
|
|
70
|
+
quote: Quote,
|
|
71
|
+
candles: list[Candle],
|
|
72
|
+
news: list[NewsItem],
|
|
73
|
+
fundamentals: FundamentalSnapshot | None,
|
|
74
|
+
) -> DataQuality:
|
|
75
|
+
score = 0
|
|
76
|
+
quote_status = "ok" if quote.price is not None else "missing"
|
|
77
|
+
if quote.price is not None:
|
|
78
|
+
score += 25
|
|
79
|
+
|
|
80
|
+
candle_count = len(candles)
|
|
81
|
+
if candle_count >= 120:
|
|
82
|
+
ohlcv_status = f"strong ({candle_count} candles)"
|
|
83
|
+
score += 35
|
|
84
|
+
elif candle_count >= 20:
|
|
85
|
+
ohlcv_status = f"usable ({candle_count} candles)"
|
|
86
|
+
score += 25
|
|
87
|
+
elif candle_count:
|
|
88
|
+
ohlcv_status = f"weak ({candle_count} candles)"
|
|
89
|
+
score += 10
|
|
90
|
+
else:
|
|
91
|
+
ohlcv_status = "missing"
|
|
92
|
+
|
|
93
|
+
news_status = f"{len(news)} item(s)" if news else "missing"
|
|
94
|
+
if news:
|
|
95
|
+
score += 15
|
|
96
|
+
|
|
97
|
+
fundamentals_status = "ok" if fundamentals is not None else "missing"
|
|
98
|
+
if fundamentals is not None:
|
|
99
|
+
score += 20
|
|
100
|
+
|
|
101
|
+
if quote.status == "realtime":
|
|
102
|
+
score += 5
|
|
103
|
+
|
|
104
|
+
return DataQuality(
|
|
105
|
+
score=min(score, 100),
|
|
106
|
+
quote=quote_status,
|
|
107
|
+
ohlcv=ohlcv_status,
|
|
108
|
+
news=news_status,
|
|
109
|
+
fundamentals=fundamentals_status,
|
|
110
|
+
provider=f"{quote.provider} ({quote.status})",
|
|
111
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Storage modules."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Lightweight in-memory cache scaffold for provider responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from time import monotonic
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class CacheEntry(Generic[T]):
|
|
14
|
+
value: T
|
|
15
|
+
expires_at: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TTLCache(Generic[T]):
|
|
19
|
+
"""Simple TTL cache for Phase 1. Provider modules can replace this later."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, default_ttl: int = 300) -> None:
|
|
22
|
+
self.default_ttl = default_ttl
|
|
23
|
+
self._items: dict[str, CacheEntry[T]] = {}
|
|
24
|
+
|
|
25
|
+
def get(self, key: str) -> T | None:
|
|
26
|
+
entry = self._items.get(key)
|
|
27
|
+
if entry is None:
|
|
28
|
+
return None
|
|
29
|
+
if entry.expires_at < monotonic():
|
|
30
|
+
self._items.pop(key, None)
|
|
31
|
+
return None
|
|
32
|
+
return entry.value
|
|
33
|
+
|
|
34
|
+
def set(self, key: str, value: T, ttl: int | None = None) -> None:
|
|
35
|
+
self._items[key] = CacheEntry(value=value, expires_at=monotonic() + (ttl or self.default_ttl))
|
|
36
|
+
|
|
37
|
+
def clear(self) -> None:
|
|
38
|
+
self._items.clear()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Configuration loading and persistence.
|
|
2
|
+
|
|
3
|
+
Secrets are read from environment variables. Non-secret preferences are stored
|
|
4
|
+
in a local JSON config file under ~/.fincli by default.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
except ImportError: # pragma: no cover - dependency exists in normal install
|
|
18
|
+
load_dotenv = None # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
from fincli.app.utils.errors import ConfigError
|
|
21
|
+
from fincli.app.utils.formatting import mask_secret
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
APP_DIR = Path.home() / ".fincli"
|
|
25
|
+
CONFIG_FILE = APP_DIR / "config.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class FinCLISettings:
|
|
30
|
+
ai_provider: str = "openrouter"
|
|
31
|
+
ai_model: str = "openai/gpt-4o-mini"
|
|
32
|
+
market_provider: str = "yfinance"
|
|
33
|
+
news_provider: str = "yfinance"
|
|
34
|
+
market_provider_priority: list[str] = field(default_factory=lambda: ["yfinance"])
|
|
35
|
+
timezone: str = "Asia/Jakarta"
|
|
36
|
+
default_currency: str = "USD"
|
|
37
|
+
cache_ttl_seconds: int = 300
|
|
38
|
+
theme: str = "fincli-dark"
|
|
39
|
+
|
|
40
|
+
def safe_dict(self) -> dict[str, Any]:
|
|
41
|
+
"""Return display-safe config, including masked secret status."""
|
|
42
|
+
data = asdict(self)
|
|
43
|
+
data["api_keys"] = {
|
|
44
|
+
"openrouter": mask_secret(os.getenv("OPENROUTER_API_KEY")),
|
|
45
|
+
"gemini": mask_secret(os.getenv("GEMINI_API_KEY")),
|
|
46
|
+
"anthropic": mask_secret(os.getenv("ANTHROPIC_API_KEY")),
|
|
47
|
+
"openai": mask_secret(os.getenv("OPENAI_API_KEY")),
|
|
48
|
+
"together": mask_secret(os.getenv("TOGETHER_API_KEY")),
|
|
49
|
+
"huggingface": mask_secret(os.getenv("HUGGINGFACE_API_KEY")),
|
|
50
|
+
"groq": mask_secret(os.getenv("GROQ_API_KEY")),
|
|
51
|
+
"market_data": mask_secret(os.getenv("MARKET_DATA_API_KEY")),
|
|
52
|
+
"news_data": mask_secret(os.getenv("NEWS_DATA_API_KEY")),
|
|
53
|
+
"finnhub": mask_secret(os.getenv("FINNHUB_API_KEY")),
|
|
54
|
+
"twelvedata": mask_secret(os.getenv("TWELVE_DATA_API_KEY")),
|
|
55
|
+
}
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConfigManager:
|
|
60
|
+
"""Load, update, and persist non-secret FinCLI settings."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, config_file: Path = CONFIG_FILE) -> None:
|
|
63
|
+
self.config_file = config_file
|
|
64
|
+
self.settings = self.load()
|
|
65
|
+
|
|
66
|
+
def load(self) -> FinCLISettings:
|
|
67
|
+
if load_dotenv is not None:
|
|
68
|
+
load_dotenv()
|
|
69
|
+
|
|
70
|
+
if not self.config_file.exists():
|
|
71
|
+
return FinCLISettings()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
raw = json.loads(self.config_file.read_text(encoding="utf-8"))
|
|
75
|
+
allowed = FinCLISettings.__dataclass_fields__.keys()
|
|
76
|
+
filtered = {key: value for key, value in raw.items() if key in allowed}
|
|
77
|
+
return FinCLISettings(**filtered)
|
|
78
|
+
except Exception as exc: # noqa: BLE001
|
|
79
|
+
raise ConfigError(
|
|
80
|
+
"Config lokal gagal dibaca.",
|
|
81
|
+
"Periksa ~/.fincli/config.json atau hapus file tersebut untuk memakai default.",
|
|
82
|
+
) from exc
|
|
83
|
+
|
|
84
|
+
def save(self) -> None:
|
|
85
|
+
try:
|
|
86
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
self.config_file.write_text(
|
|
88
|
+
json.dumps(asdict(self.settings), indent=2),
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
)
|
|
91
|
+
except Exception as exc: # noqa: BLE001
|
|
92
|
+
raise ConfigError("Config lokal gagal disimpan.") from exc
|
|
93
|
+
|
|
94
|
+
def set_ai_model(self, provider: str, model: str) -> None:
|
|
95
|
+
self.settings.ai_provider = provider.strip().lower()
|
|
96
|
+
self.settings.ai_model = model.strip()
|
|
97
|
+
self.save()
|
|
98
|
+
|
|
99
|
+
def set_market_provider(self, provider: str) -> None:
|
|
100
|
+
self.settings.market_provider = provider.strip().lower()
|
|
101
|
+
self.save()
|
|
102
|
+
|
|
103
|
+
def set_news_provider(self, provider: str) -> None:
|
|
104
|
+
self.settings.news_provider = provider.strip().lower()
|
|
105
|
+
self.save()
|
|
106
|
+
|
|
107
|
+
def set_market_provider_priority(self, providers: list[str]) -> None:
|
|
108
|
+
normalized = [provider.strip().lower() for provider in providers if provider.strip()]
|
|
109
|
+
if not normalized:
|
|
110
|
+
normalized = ["yfinance"]
|
|
111
|
+
self.settings.market_provider_priority = normalized
|
|
112
|
+
self.settings.market_provider = normalized[0]
|
|
113
|
+
self.settings.news_provider = normalized[0]
|
|
114
|
+
self.save()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""SQLite storage for FinCLI local data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from contextlib import closing
|
|
7
|
+
import sqlite3
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
from fincli.app.storage.config import APP_DIR
|
|
11
|
+
from fincli.app.utils.errors import StorageError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DB_FILE = APP_DIR / "fincli.db"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FinCLIDatabase:
|
|
18
|
+
"""Small SQLite wrapper for watchlist, portfolio, and journal data."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, db_file: Path = DB_FILE) -> None:
|
|
21
|
+
self.db_file = db_file
|
|
22
|
+
self.db_file.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
self.initialize()
|
|
24
|
+
|
|
25
|
+
def connect(self) -> sqlite3.Connection:
|
|
26
|
+
connection = sqlite3.connect(self.db_file)
|
|
27
|
+
connection.row_factory = sqlite3.Row
|
|
28
|
+
return connection
|
|
29
|
+
|
|
30
|
+
def initialize(self) -> None:
|
|
31
|
+
try:
|
|
32
|
+
with closing(self.connect()) as db:
|
|
33
|
+
with db:
|
|
34
|
+
db.executescript(
|
|
35
|
+
"""
|
|
36
|
+
CREATE TABLE IF NOT EXISTS watchlist (
|
|
37
|
+
symbol TEXT PRIMARY KEY,
|
|
38
|
+
group_name TEXT DEFAULT 'default',
|
|
39
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS portfolio_positions (
|
|
43
|
+
symbol TEXT PRIMARY KEY,
|
|
44
|
+
quantity REAL NOT NULL,
|
|
45
|
+
average_price REAL NOT NULL,
|
|
46
|
+
currency TEXT DEFAULT 'USD',
|
|
47
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
48
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS journal_entries (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
instrument TEXT NOT NULL,
|
|
54
|
+
bias TEXT DEFAULT '',
|
|
55
|
+
entry_reason TEXT DEFAULT '',
|
|
56
|
+
exit_reason TEXT DEFAULT '',
|
|
57
|
+
result TEXT DEFAULT '',
|
|
58
|
+
emotion TEXT DEFAULT '',
|
|
59
|
+
lesson TEXT DEFAULT '',
|
|
60
|
+
tags TEXT DEFAULT '',
|
|
61
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS portfolio_transactions (
|
|
65
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
+
action TEXT NOT NULL,
|
|
67
|
+
symbol TEXT NOT NULL,
|
|
68
|
+
quantity REAL NOT NULL,
|
|
69
|
+
price REAL NOT NULL,
|
|
70
|
+
currency TEXT DEFAULT 'USD',
|
|
71
|
+
realized_pnl REAL DEFAULT 0,
|
|
72
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS market_cache (
|
|
76
|
+
namespace TEXT NOT NULL,
|
|
77
|
+
cache_key TEXT NOT NULL,
|
|
78
|
+
payload TEXT NOT NULL,
|
|
79
|
+
expires_at REAL NOT NULL,
|
|
80
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
81
|
+
PRIMARY KEY (namespace, cache_key)
|
|
82
|
+
);
|
|
83
|
+
"""
|
|
84
|
+
)
|
|
85
|
+
except sqlite3.Error as exc:
|
|
86
|
+
raise StorageError("Database lokal gagal diinisialisasi.") from exc
|
|
87
|
+
|
|
88
|
+
def execute(self, sql: str, params: Iterable[object] = ()) -> None:
|
|
89
|
+
try:
|
|
90
|
+
with closing(self.connect()) as db:
|
|
91
|
+
with db:
|
|
92
|
+
db.execute(sql, tuple(params))
|
|
93
|
+
except sqlite3.Error as exc:
|
|
94
|
+
raise StorageError("Operasi database gagal.") from exc
|
|
95
|
+
|
|
96
|
+
def query(self, sql: str, params: Iterable[object] = ()) -> list[sqlite3.Row]:
|
|
97
|
+
try:
|
|
98
|
+
with closing(self.connect()) as db:
|
|
99
|
+
return list(db.execute(sql, tuple(params)).fetchall())
|
|
100
|
+
except sqlite3.Error as exc:
|
|
101
|
+
raise StorageError("Query database gagal.") from exc
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Persistent SQLite cache for market provider responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from time import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fincli.app.storage.database import FinCLIDatabase
|
|
10
|
+
from fincli.app.utils.errors import StorageError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MarketCache:
|
|
14
|
+
"""Provider response cache with TTL persisted in SQLite."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, db: FinCLIDatabase) -> None:
|
|
17
|
+
self.db = db
|
|
18
|
+
|
|
19
|
+
def get(self, namespace: str, cache_key: str) -> dict[str, Any] | list[Any] | None:
|
|
20
|
+
rows = self.db.query(
|
|
21
|
+
"SELECT payload, expires_at FROM market_cache WHERE namespace = ? AND cache_key = ?",
|
|
22
|
+
(namespace, cache_key),
|
|
23
|
+
)
|
|
24
|
+
if not rows:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
row = rows[0]
|
|
28
|
+
if float(row["expires_at"]) <= time():
|
|
29
|
+
self.delete(namespace, cache_key)
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
payload = json.loads(str(row["payload"]))
|
|
34
|
+
except json.JSONDecodeError as exc:
|
|
35
|
+
self.delete(namespace, cache_key)
|
|
36
|
+
raise StorageError("Payload cache market rusak dan sudah dihapus.") from exc
|
|
37
|
+
|
|
38
|
+
if isinstance(payload, (dict, list)):
|
|
39
|
+
return payload
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def set(self, namespace: str, cache_key: str, payload: dict[str, Any] | list[Any], ttl_seconds: int) -> None:
|
|
43
|
+
encoded = json.dumps(payload, separators=(",", ":"))
|
|
44
|
+
expires_at = time() + max(1, ttl_seconds)
|
|
45
|
+
self.db.execute(
|
|
46
|
+
"""
|
|
47
|
+
INSERT INTO market_cache (namespace, cache_key, payload, expires_at)
|
|
48
|
+
VALUES (?, ?, ?, ?)
|
|
49
|
+
ON CONFLICT(namespace, cache_key)
|
|
50
|
+
DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at, created_at = CURRENT_TIMESTAMP
|
|
51
|
+
""",
|
|
52
|
+
(namespace, cache_key, encoded, expires_at),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def delete(self, namespace: str, cache_key: str) -> None:
|
|
56
|
+
self.db.execute(
|
|
57
|
+
"DELETE FROM market_cache WHERE namespace = ? AND cache_key = ?",
|
|
58
|
+
(namespace, cache_key),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def clear(self, namespace: str | None = None) -> int:
|
|
62
|
+
if namespace:
|
|
63
|
+
count = int(self.db.query("SELECT COUNT(*) AS total FROM market_cache WHERE namespace = ?", (namespace,))[0]["total"])
|
|
64
|
+
self.db.execute("DELETE FROM market_cache WHERE namespace = ?", (namespace,))
|
|
65
|
+
return count
|
|
66
|
+
count = int(self.db.query("SELECT COUNT(*) AS total FROM market_cache")[0]["total"])
|
|
67
|
+
self.db.execute("DELETE FROM market_cache")
|
|
68
|
+
return count
|
|
69
|
+
|
|
70
|
+
def prune_expired(self) -> int:
|
|
71
|
+
count = int(self.db.query("SELECT COUNT(*) AS total FROM market_cache WHERE expires_at <= ?", (time(),))[0]["total"])
|
|
72
|
+
self.db.execute("DELETE FROM market_cache WHERE expires_at <= ?", (time(),))
|
|
73
|
+
return count
|
|
74
|
+
|
|
75
|
+
def stats(self) -> dict[str, int]:
|
|
76
|
+
self.prune_expired()
|
|
77
|
+
rows = self.db.query(
|
|
78
|
+
"""
|
|
79
|
+
SELECT namespace, COUNT(*) AS total
|
|
80
|
+
FROM market_cache
|
|
81
|
+
GROUP BY namespace
|
|
82
|
+
ORDER BY namespace
|
|
83
|
+
"""
|
|
84
|
+
)
|
|
85
|
+
by_namespace = {str(row["namespace"]): int(row["total"]) for row in rows}
|
|
86
|
+
return {
|
|
87
|
+
"total": sum(by_namespace.values()),
|
|
88
|
+
"quote": by_namespace.get("quote", 0),
|
|
89
|
+
"history": by_namespace.get("history", 0),
|
|
90
|
+
"news": by_namespace.get("news", 0),
|
|
91
|
+
"fundamentals": by_namespace.get("fundamentals", 0),
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Textual TUI modules."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Reusable TUI components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.markdown import Markdown
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
from fincli.app.cli.commands import CommandSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CommandPalette(Static):
|
|
15
|
+
"""Slash command palette shown near the command input."""
|
|
16
|
+
|
|
17
|
+
def render_commands(self, commands: list[CommandSpec], query: str = "") -> None:
|
|
18
|
+
table = Table.grid(expand=True)
|
|
19
|
+
table.add_column("Command", style="white", no_wrap=True, ratio=1)
|
|
20
|
+
table.add_column("Description", style="bright_black", justify="right", ratio=3)
|
|
21
|
+
|
|
22
|
+
for index, command in enumerate(commands):
|
|
23
|
+
command_text = command.name
|
|
24
|
+
description = command.description
|
|
25
|
+
if index == 0:
|
|
26
|
+
command_text = f"[black on cyan]> {command.name}[/]"
|
|
27
|
+
description = f"[black on cyan]{command.description}[/]"
|
|
28
|
+
table.add_row(command_text, description)
|
|
29
|
+
|
|
30
|
+
if len(commands) > 6:
|
|
31
|
+
table.add_row("[bright_black]v more[/]", "[bright_black]Ketik command lebih spesifik[/]")
|
|
32
|
+
|
|
33
|
+
title = f"[cyan]>[/] {query or '/'}"
|
|
34
|
+
self.update(Panel(table, title=title, border_style="bright_black", padding=(0, 1)))
|
|
35
|
+
|
|
36
|
+
def clear_palette(self) -> None:
|
|
37
|
+
self.update("")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_user_message(message: str) -> Panel:
|
|
41
|
+
text = Text()
|
|
42
|
+
text.append("> ", style="bold cyan")
|
|
43
|
+
text.append(message, style="bold white")
|
|
44
|
+
return Panel(text, border_style="#2f332f", style="on #2b2f2b", padding=(0, 1))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_thinking_message(message: str) -> Text:
|
|
48
|
+
text = Text()
|
|
49
|
+
text.append("> Thinking: ", style="dim")
|
|
50
|
+
text.append(message, style="italic dim")
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_ai_message(message: str) -> Markdown:
|
|
55
|
+
return Markdown(f"* {message}")
|