@drico2008/fincli 0.1.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +124 -625
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +26 -14
- package/fincli/app/analysis/analyzer.py +107 -96
- package/fincli/app/analysis/assistant_context.py +187 -186
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +108 -81
- package/fincli/app/cli/router.py +2327 -1237
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/portfolio_risk.py +305 -0
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/transactions.py +84 -84
- package/fincli/app/modules/user_profile.py +84 -0
- package/fincli/app/plugins/loader.py +72 -0
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/market/alphavantage_provider.py +194 -0
- package/fincli/app/providers/market/base.py +98 -77
- package/fincli/app/providers/market/custom_provider.py +186 -169
- package/fincli/app/providers/market/manager.py +84 -1
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/reliability.py +86 -0
- package/fincli/app/research/__init__.py +8 -0
- package/fincli/app/research/engine.py +137 -0
- package/fincli/app/research/exporter.py +91 -0
- package/fincli/app/research/formatter.py +27 -0
- package/fincli/app/research/models.py +24 -0
- package/fincli/app/research/prompt_builder.py +54 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +274 -169
- package/fincli/app/services/market_overview.py +42 -1
- package/fincli/app/services/news_aggregator.py +95 -0
- package/fincli/app/services/web_research.py +267 -267
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +209 -99
- package/fincli/app/storage/provider_metrics.py +61 -0
- package/fincli/app/storage/secrets.py +26 -2
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +269 -258
- package/fincli/app/tui/market_provider_selector.py +3 -1
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +123 -60
- package/package.json +22 -20
- package/pyproject.toml +35 -35
|
@@ -1,112 +1,146 @@
|
|
|
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
|
-
|
|
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
15
|
try:
|
|
16
|
-
from dotenv import load_dotenv
|
|
16
|
+
from dotenv import dotenv_values, load_dotenv
|
|
17
17
|
except ImportError: # pragma: no cover - dependency exists in normal install
|
|
18
18
|
load_dotenv = None # type: ignore[assignment]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
from fincli.app.utils.
|
|
22
|
-
from fincli.app.
|
|
23
|
-
from fincli.app.storage.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
dotenv_values = None # type: ignore[assignment]
|
|
20
|
+
|
|
21
|
+
from fincli.app.utils.errors import ConfigError
|
|
22
|
+
from fincli.app.utils.formatting import mask_secret
|
|
23
|
+
from fincli.app.storage.config_paths import APP_DIR, CONFIG_FILE
|
|
24
|
+
from fincli.app.storage.secrets import load_local_secrets
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class FinCLISettings:
|
|
29
|
+
ai_provider: str = "openrouter"
|
|
30
|
+
ai_model: str = "openai/gpt-4o-mini"
|
|
30
31
|
market_provider: str = "yfinance"
|
|
31
32
|
news_provider: str = "yfinance"
|
|
32
33
|
market_provider_priority: list[str] = field(default_factory=lambda: ["yfinance"])
|
|
34
|
+
news_provider_priority: list[str] = field(
|
|
35
|
+
default_factory=lambda: ["yfinance", "google_news_rss", "yahoo_finance_rss", "marketaux", "newsapi", "gnews"]
|
|
36
|
+
)
|
|
33
37
|
timezone: str = "Asia/Jakarta"
|
|
34
|
-
default_currency: str = "USD"
|
|
35
|
-
cache_ttl_seconds: int = 300
|
|
36
|
-
theme: str = "fincli-dark"
|
|
37
|
-
|
|
38
|
-
def safe_dict(self) -> dict[str, Any]:
|
|
39
|
-
"""Return display-safe config, including masked secret status."""
|
|
40
|
-
data = asdict(self)
|
|
41
|
-
data["api_keys"] = {
|
|
42
|
-
"openrouter": mask_secret(os.getenv("OPENROUTER_API_KEY")),
|
|
43
|
-
"gemini": mask_secret(os.getenv("GEMINI_API_KEY")),
|
|
44
|
-
"anthropic": mask_secret(os.getenv("ANTHROPIC_API_KEY")),
|
|
45
|
-
"openai": mask_secret(os.getenv("OPENAI_API_KEY")),
|
|
46
|
-
"together": mask_secret(os.getenv("TOGETHER_API_KEY")),
|
|
47
|
-
"huggingface": mask_secret(os.getenv("HUGGINGFACE_API_KEY")),
|
|
48
|
-
"groq": mask_secret(os.getenv("GROQ_API_KEY")),
|
|
49
|
-
"market_data": mask_secret(os.getenv("MARKET_DATA_API_KEY")),
|
|
38
|
+
default_currency: str = "USD"
|
|
39
|
+
cache_ttl_seconds: int = 300
|
|
40
|
+
theme: str = "fincli-dark"
|
|
41
|
+
|
|
42
|
+
def safe_dict(self) -> dict[str, Any]:
|
|
43
|
+
"""Return display-safe config, including masked secret status."""
|
|
44
|
+
data = asdict(self)
|
|
45
|
+
data["api_keys"] = {
|
|
46
|
+
"openrouter": mask_secret(os.getenv("OPENROUTER_API_KEY")),
|
|
47
|
+
"gemini": mask_secret(os.getenv("GEMINI_API_KEY")),
|
|
48
|
+
"anthropic": mask_secret(os.getenv("ANTHROPIC_API_KEY")),
|
|
49
|
+
"openai": mask_secret(os.getenv("OPENAI_API_KEY")),
|
|
50
|
+
"together": mask_secret(os.getenv("TOGETHER_API_KEY")),
|
|
51
|
+
"huggingface": mask_secret(os.getenv("HUGGINGFACE_API_KEY")),
|
|
52
|
+
"groq": mask_secret(os.getenv("GROQ_API_KEY")),
|
|
53
|
+
"market_data": mask_secret(os.getenv("MARKET_DATA_API_KEY")),
|
|
50
54
|
"news_data": mask_secret(os.getenv("NEWS_DATA_API_KEY")),
|
|
51
55
|
"finnhub": mask_secret(os.getenv("FINNHUB_API_KEY")),
|
|
52
56
|
"twelvedata": mask_secret(os.getenv("TWELVE_DATA_API_KEY")),
|
|
57
|
+
"alphavantage": mask_secret(os.getenv("ALPHA_VANTAGE_API_KEY")),
|
|
58
|
+
"marketaux": mask_secret(os.getenv("MARKETAUX_API_KEY")),
|
|
59
|
+
"newsapi": mask_secret(os.getenv("NEWSAPI_API_KEY")),
|
|
60
|
+
"gnews": mask_secret(os.getenv("GNEWS_API_KEY")),
|
|
61
|
+
"stocknewsapi": mask_secret(os.getenv("STOCKNEWSAPI_API_KEY")),
|
|
62
|
+
"apitube": mask_secret(os.getenv("APITUBE_API_KEY")),
|
|
63
|
+
"benzinga": mask_secret(os.getenv("BENZINGA_API_KEY")),
|
|
64
|
+
"polygon": mask_secret(os.getenv("POLYGON_API_KEY")),
|
|
65
|
+
"tiingo": mask_secret(os.getenv("TIINGO_API_KEY")),
|
|
66
|
+
"fmp": mask_secret(os.getenv("FMP_API_KEY")),
|
|
67
|
+
"eodhd": mask_secret(os.getenv("EODHD_API_KEY")),
|
|
68
|
+
"custom_news": mask_secret(os.getenv("CUSTOM_NEWS_API_KEY") or os.getenv("NEWS_DATA_API_KEY")),
|
|
53
69
|
}
|
|
54
70
|
return data
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class ConfigManager:
|
|
58
|
-
"""Load, update, and persist non-secret FinCLI settings."""
|
|
59
|
-
|
|
60
|
-
def __init__(self, config_file: Path = CONFIG_FILE) -> None:
|
|
61
|
-
self.config_file = config_file
|
|
62
|
-
self.settings = self.load()
|
|
63
|
-
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ConfigManager:
|
|
74
|
+
"""Load, update, and persist non-secret FinCLI settings."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, config_file: Path = CONFIG_FILE) -> None:
|
|
77
|
+
self.config_file = config_file
|
|
78
|
+
self.settings = self.load()
|
|
79
|
+
|
|
64
80
|
def load(self) -> FinCLISettings:
|
|
81
|
+
dotenv_loaded_keys: set[str] = set()
|
|
65
82
|
if load_dotenv is not None:
|
|
83
|
+
before_env = dict(os.environ)
|
|
66
84
|
load_dotenv()
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
85
|
+
if dotenv_values is not None:
|
|
86
|
+
dotenv_loaded_keys = {
|
|
87
|
+
key
|
|
88
|
+
for key, value in dotenv_values().items()
|
|
89
|
+
if key and value is not None and (key not in before_env or before_env.get(key, "") == "")
|
|
90
|
+
}
|
|
91
|
+
# API keys saved from FinCLI commands should override stale project .env values,
|
|
92
|
+
# while explicit OS/process environment variables remain respected.
|
|
93
|
+
load_local_secrets(override_keys=dotenv_loaded_keys)
|
|
94
|
+
|
|
95
|
+
if not self.config_file.exists():
|
|
96
|
+
return FinCLISettings()
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
raw = json.loads(self.config_file.read_text(encoding="utf-8"))
|
|
100
|
+
allowed = FinCLISettings.__dataclass_fields__.keys()
|
|
101
|
+
filtered = {key: value for key, value in raw.items() if key in allowed}
|
|
102
|
+
return FinCLISettings(**filtered)
|
|
103
|
+
except Exception as exc: # noqa: BLE001
|
|
104
|
+
raise ConfigError(
|
|
105
|
+
"Config lokal gagal dibaca.",
|
|
106
|
+
"Periksa ~/.fincli/config.json atau hapus file tersebut untuk memakai default.",
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
def save(self) -> None:
|
|
110
|
+
try:
|
|
111
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
self.config_file.write_text(
|
|
113
|
+
json.dumps(asdict(self.settings), indent=2),
|
|
114
|
+
encoding="utf-8",
|
|
115
|
+
)
|
|
116
|
+
except Exception as exc: # noqa: BLE001
|
|
117
|
+
raise ConfigError("Config lokal gagal disimpan.") from exc
|
|
118
|
+
|
|
119
|
+
def set_ai_model(self, provider: str, model: str) -> None:
|
|
120
|
+
self.settings.ai_provider = provider.strip().lower()
|
|
121
|
+
self.settings.ai_model = model.strip()
|
|
122
|
+
self.save()
|
|
123
|
+
|
|
124
|
+
def set_market_provider(self, provider: str) -> None:
|
|
125
|
+
self.settings.market_provider = provider.strip().lower()
|
|
126
|
+
self.save()
|
|
127
|
+
|
|
102
128
|
def set_news_provider(self, provider: str) -> None:
|
|
103
129
|
self.settings.news_provider = provider.strip().lower()
|
|
104
130
|
self.save()
|
|
105
131
|
|
|
106
|
-
def
|
|
132
|
+
def set_news_provider_priority(self, providers: list[str]) -> None:
|
|
107
133
|
normalized = [provider.strip().lower() for provider in providers if provider.strip()]
|
|
108
134
|
if not normalized:
|
|
109
|
-
normalized = ["yfinance"]
|
|
135
|
+
normalized = ["yfinance", "google_news_rss", "yahoo_finance_rss"]
|
|
136
|
+
self.settings.news_provider_priority = normalized
|
|
137
|
+
self.settings.news_provider = normalized[0]
|
|
138
|
+
self.save()
|
|
139
|
+
|
|
140
|
+
def set_market_provider_priority(self, providers: list[str]) -> None:
|
|
141
|
+
normalized = [provider.strip().lower() for provider in providers if provider.strip()]
|
|
142
|
+
if not normalized:
|
|
143
|
+
normalized = ["yfinance"]
|
|
110
144
|
self.settings.market_provider_priority = normalized
|
|
111
145
|
self.settings.market_provider = normalized[0]
|
|
112
146
|
self.settings.news_provider = normalized[0]
|
|
@@ -1,118 +1,228 @@
|
|
|
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 (
|
|
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
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
title TEXT NOT NULL,
|
|
87
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
88
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
92
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
93
|
+
session_id TEXT NOT NULL,
|
|
94
|
+
command TEXT NOT NULL,
|
|
95
|
+
status TEXT NOT NULL,
|
|
96
|
+
output_preview TEXT DEFAULT '',
|
|
97
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
98
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
CREATE TABLE IF NOT EXISTS alerts (
|
|
52
102
|
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
103
|
symbol TEXT NOT NULL,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
104
|
+
condition TEXT NOT NULL,
|
|
105
|
+
target REAL NOT NULL,
|
|
106
|
+
note TEXT DEFAULT '',
|
|
107
|
+
active INTEGER DEFAULT 1,
|
|
108
|
+
triggered_at TEXT DEFAULT '',
|
|
72
109
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
73
110
|
);
|
|
74
111
|
|
|
75
|
-
CREATE TABLE IF NOT EXISTS
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
85
|
-
id TEXT PRIMARY KEY,
|
|
86
|
-
title TEXT NOT NULL,
|
|
87
|
-
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
112
|
+
CREATE TABLE IF NOT EXISTS user_profile (
|
|
113
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
114
|
+
name TEXT NOT NULL,
|
|
115
|
+
equity REAL NOT NULL,
|
|
116
|
+
currency TEXT NOT NULL,
|
|
117
|
+
leverage TEXT NOT NULL,
|
|
118
|
+
years_in_investment REAL NOT NULL,
|
|
119
|
+
gameplay TEXT NOT NULL,
|
|
88
120
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
89
121
|
);
|
|
90
122
|
|
|
91
|
-
CREATE TABLE IF NOT EXISTS
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
CREATE TABLE IF NOT EXISTS provider_metrics (
|
|
124
|
+
provider TEXT PRIMARY KEY,
|
|
125
|
+
calls INTEGER DEFAULT 0,
|
|
126
|
+
successes INTEGER DEFAULT 0,
|
|
127
|
+
errors INTEGER DEFAULT 0,
|
|
128
|
+
fallbacks INTEGER DEFAULT 0,
|
|
129
|
+
total_latency_ms REAL DEFAULT 0,
|
|
130
|
+
last_status TEXT DEFAULT 'not_called',
|
|
131
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
99
132
|
);
|
|
100
133
|
"""
|
|
101
134
|
)
|
|
135
|
+
_migrate_user_profile_schema(db)
|
|
102
136
|
except sqlite3.Error as exc:
|
|
103
137
|
raise StorageError("Database lokal gagal diinisialisasi.") from exc
|
|
104
|
-
|
|
105
|
-
def execute(self, sql: str, params: Iterable[object] = ()) -> None:
|
|
106
|
-
try:
|
|
107
|
-
with closing(self.connect()) as db:
|
|
108
|
-
with db:
|
|
109
|
-
db.execute(sql, tuple(params))
|
|
110
|
-
except sqlite3.Error as exc:
|
|
111
|
-
raise StorageError("Operasi database gagal.") from exc
|
|
112
|
-
|
|
138
|
+
|
|
139
|
+
def execute(self, sql: str, params: Iterable[object] = ()) -> None:
|
|
140
|
+
try:
|
|
141
|
+
with closing(self.connect()) as db:
|
|
142
|
+
with db:
|
|
143
|
+
db.execute(sql, tuple(params))
|
|
144
|
+
except sqlite3.Error as exc:
|
|
145
|
+
raise StorageError("Operasi database gagal.") from exc
|
|
146
|
+
|
|
113
147
|
def query(self, sql: str, params: Iterable[object] = ()) -> list[sqlite3.Row]:
|
|
114
148
|
try:
|
|
115
149
|
with closing(self.connect()) as db:
|
|
116
150
|
return list(db.execute(sql, tuple(params)).fetchall())
|
|
117
151
|
except sqlite3.Error as exc:
|
|
118
152
|
raise StorageError("Query database gagal.") from exc
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _migrate_user_profile_schema(db: sqlite3.Connection) -> None:
|
|
156
|
+
"""Normalize older user_profile schemas to the v0.3.0 canonical shape."""
|
|
157
|
+
|
|
158
|
+
columns = {str(row["name"]) for row in db.execute("PRAGMA table_info(user_profile)").fetchall()}
|
|
159
|
+
canonical = {"id", "name", "equity", "currency", "leverage", "years_in_investment", "gameplay", "updated_at"}
|
|
160
|
+
if canonical.issubset(columns):
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
rows = list(db.execute("SELECT * FROM user_profile").fetchall())
|
|
164
|
+
legacy_profile = _legacy_profile_payload(rows[0]) if rows else None
|
|
165
|
+
db.execute("DROP TABLE user_profile")
|
|
166
|
+
db.execute(
|
|
167
|
+
"""
|
|
168
|
+
CREATE TABLE user_profile (
|
|
169
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
170
|
+
name TEXT NOT NULL,
|
|
171
|
+
equity REAL NOT NULL,
|
|
172
|
+
currency TEXT NOT NULL,
|
|
173
|
+
leverage TEXT NOT NULL,
|
|
174
|
+
years_in_investment REAL NOT NULL,
|
|
175
|
+
gameplay TEXT NOT NULL,
|
|
176
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
177
|
+
)
|
|
178
|
+
"""
|
|
179
|
+
)
|
|
180
|
+
if legacy_profile is None:
|
|
181
|
+
return
|
|
182
|
+
db.execute(
|
|
183
|
+
"""
|
|
184
|
+
INSERT INTO user_profile (id, name, equity, currency, leverage, years_in_investment, gameplay, updated_at)
|
|
185
|
+
VALUES (1, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
186
|
+
""",
|
|
187
|
+
legacy_profile,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _legacy_profile_payload(row: sqlite3.Row) -> tuple[object, ...]:
|
|
192
|
+
keys = set(row.keys())
|
|
193
|
+
name = row["name"] if "name" in keys else "User"
|
|
194
|
+
equity = row["equity"] if "equity" in keys else row["equity_amount"] if "equity_amount" in keys else 0
|
|
195
|
+
currency = row["currency"] if "currency" in keys else row["equity_currency"] if "equity_currency" in keys else "USD"
|
|
196
|
+
leverage = row["leverage"] if "leverage" in keys else "1:1"
|
|
197
|
+
years = (
|
|
198
|
+
row["years_in_investment"]
|
|
199
|
+
if "years_in_investment" in keys
|
|
200
|
+
else row["experience_years"]
|
|
201
|
+
if "experience_years" in keys
|
|
202
|
+
else 0
|
|
203
|
+
)
|
|
204
|
+
gameplay = _normalize_legacy_gameplay(str(row["gameplay"])) if "gameplay" in keys else _classify_legacy_gameplay(float(equity))
|
|
205
|
+
return (name, equity, currency, leverage, years, gameplay)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _normalize_legacy_gameplay(value: str) -> str:
|
|
209
|
+
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
|
|
210
|
+
return {
|
|
211
|
+
"scalper": "Scalper",
|
|
212
|
+
"intra_day": "Intra day",
|
|
213
|
+
"intraday": "Intra day",
|
|
214
|
+
"day_trade": "Day trade",
|
|
215
|
+
"day_trader": "Day trade",
|
|
216
|
+
"swing": "Swing/Investor",
|
|
217
|
+
"investor": "Swing/Investor",
|
|
218
|
+
}.get(normalized, value.strip() or "Scalper")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _classify_legacy_gameplay(equity: float) -> str:
|
|
222
|
+
if equity <= 400:
|
|
223
|
+
return "Scalper"
|
|
224
|
+
if equity <= 1000:
|
|
225
|
+
return "Intra day"
|
|
226
|
+
if equity <= 5000:
|
|
227
|
+
return "Day trade"
|
|
228
|
+
return "Swing/Investor"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Persistent provider metrics storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fincli.app.services.market_data import ProviderRuntimeMetrics
|
|
6
|
+
from fincli.app.storage.database import FinCLIDatabase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProviderMetricsStore:
|
|
10
|
+
"""Persist aggregate provider metrics across FinCLI sessions."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, db: FinCLIDatabase) -> None:
|
|
13
|
+
self.db = db
|
|
14
|
+
|
|
15
|
+
def record(self, provider: str, success: bool, latency_ms: float, fallback: bool = False) -> None:
|
|
16
|
+
current = self.snapshot().get(provider, ProviderRuntimeMetrics(provider))
|
|
17
|
+
current.record(success=success, latency_ms=latency_ms, fallback=fallback)
|
|
18
|
+
self.db.execute(
|
|
19
|
+
"""
|
|
20
|
+
INSERT INTO provider_metrics(provider, calls, successes, errors, fallbacks, total_latency_ms, last_status, updated_at)
|
|
21
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
22
|
+
ON CONFLICT(provider) DO UPDATE SET
|
|
23
|
+
calls=excluded.calls,
|
|
24
|
+
successes=excluded.successes,
|
|
25
|
+
errors=excluded.errors,
|
|
26
|
+
fallbacks=excluded.fallbacks,
|
|
27
|
+
total_latency_ms=excluded.total_latency_ms,
|
|
28
|
+
last_status=excluded.last_status,
|
|
29
|
+
updated_at=CURRENT_TIMESTAMP
|
|
30
|
+
""",
|
|
31
|
+
(
|
|
32
|
+
current.provider,
|
|
33
|
+
current.calls,
|
|
34
|
+
current.successes,
|
|
35
|
+
current.errors,
|
|
36
|
+
current.fallbacks,
|
|
37
|
+
current.total_latency_ms,
|
|
38
|
+
current.last_status,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def snapshot(self) -> dict[str, ProviderRuntimeMetrics]:
|
|
43
|
+
rows = self.db.query(
|
|
44
|
+
"""
|
|
45
|
+
SELECT provider, calls, successes, errors, fallbacks, total_latency_ms, last_status
|
|
46
|
+
FROM provider_metrics
|
|
47
|
+
ORDER BY provider
|
|
48
|
+
"""
|
|
49
|
+
)
|
|
50
|
+
metrics: dict[str, ProviderRuntimeMetrics] = {}
|
|
51
|
+
for row in rows:
|
|
52
|
+
metric = ProviderRuntimeMetrics(str(row["provider"]))
|
|
53
|
+
metric.calls = int(row["calls"])
|
|
54
|
+
metric.successes = int(row["successes"])
|
|
55
|
+
metric.errors = int(row["errors"])
|
|
56
|
+
metric.fallbacks = int(row["fallbacks"])
|
|
57
|
+
metric.total_latency_ms = float(row["total_latency_ms"])
|
|
58
|
+
metric.last_status = str(row["last_status"])
|
|
59
|
+
metrics[metric.provider] = metric
|
|
60
|
+
return metrics
|
|
61
|
+
|