@drico2008/fincli 0.3.1 → 0.4.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 +217 -217
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +29 -27
- package/fincli/app/analysis/analyzer.py +34 -34
- package/fincli/app/analysis/assistant_context.py +3 -3
- package/fincli/app/cli/commands.py +33 -27
- package/fincli/app/cli/router.py +1633 -1105
- package/fincli/app/diagnostics/__init__.py +2 -0
- package/fincli/app/diagnostics/capabilities.py +44 -0
- package/fincli/app/diagnostics/runtime.py +106 -0
- package/fincli/app/main.py +6 -1
- package/fincli/app/modules/economic_calendar.py +512 -512
- package/fincli/app/modules/portfolio_risk.py +305 -305
- package/fincli/app/modules/trading.py +142 -0
- package/fincli/app/plugins/loader.py +72 -72
- package/fincli/app/providers/market/finnhub_provider.py +51 -2
- package/fincli/app/providers/market/symbols.py +95 -2
- package/fincli/app/providers/reliability.py +82 -65
- package/fincli/app/research/__init__.py +8 -8
- package/fincli/app/research/engine.py +119 -112
- package/fincli/app/research/exporter.py +91 -91
- package/fincli/app/research/formatter.py +25 -24
- package/fincli/app/research/models.py +22 -21
- package/fincli/app/research/prompt_builder.py +53 -51
- package/fincli/app/services/data_quality.py +27 -0
- package/fincli/app/services/data_trust.py +117 -0
- package/fincli/app/services/macro_data.py +158 -50
- package/fincli/app/services/market_data.py +183 -79
- package/fincli/app/services/market_overview.py +131 -142
- package/fincli/app/services/news_aggregator.py +95 -95
- package/fincli/app/storage/config.py +6 -3
- package/fincli/app/storage/database.py +130 -117
- package/fincli/app/storage/provider_metrics.py +61 -61
- package/fincli/app/storage/secrets.py +128 -128
- package/npm/bin/fincli.js +65 -65
- package/package.json +7 -7
- package/pyproject.toml +1 -1
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
"""News aggregation helpers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from datetime import datetime, timedelta, timezone
|
|
7
|
-
|
|
8
|
-
from fincli.app.connectors.news_connectors import NewsConnectorManager
|
|
9
|
-
from fincli.app.providers.market.base import NewsItem
|
|
10
|
-
from fincli.app.providers.reliability import STATUS_OK, STATUS_PARTIAL_DATA, STATUS_UNAVAILABLE, classify_provider_error
|
|
11
|
-
from fincli.app.services.market_data import MarketDataService
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass(frozen=True, slots=True)
|
|
15
|
-
class NewsDesk:
|
|
16
|
-
symbol: str
|
|
17
|
-
provider_chain: tuple[str, ...]
|
|
18
|
-
items: list[NewsItem]
|
|
19
|
-
note: str
|
|
20
|
-
errors: tuple[str, ...] = ()
|
|
21
|
-
lookback_days: int | None = None
|
|
22
|
-
reliability_status: str = STATUS_UNAVAILABLE
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class NewsAggregator:
|
|
26
|
-
def __init__(
|
|
27
|
-
self,
|
|
28
|
-
market_service: MarketDataService,
|
|
29
|
-
news_connectors: NewsConnectorManager | None = None,
|
|
30
|
-
priority: list[str] | None = None,
|
|
31
|
-
) -> None:
|
|
32
|
-
self.market_service = market_service
|
|
33
|
-
self.news_connectors = news_connectors or NewsConnectorManager()
|
|
34
|
-
self.priority = priority or ["yfinance", "google_news_rss", "yahoo_finance_rss"]
|
|
35
|
-
|
|
36
|
-
async def latest(self, symbol: str, limit: int = 12, lookback_days: int | None = None) -> NewsDesk:
|
|
37
|
-
normalized = symbol.upper()
|
|
38
|
-
items: list[NewsItem] = []
|
|
39
|
-
errors: list[str] = []
|
|
40
|
-
seen: set[str] = set()
|
|
41
|
-
provider_chain = tuple(_dedupe(self.priority))
|
|
42
|
-
|
|
43
|
-
for provider in provider_chain:
|
|
44
|
-
try:
|
|
45
|
-
fetched = await self._fetch_provider(provider, normalized, max(limit - len(items), 1))
|
|
46
|
-
except Exception as exc: # noqa: BLE001 - fallback chain should continue
|
|
47
|
-
errors.append(f"{provider}: {classify_provider_error(exc)} ({exc})")
|
|
48
|
-
continue
|
|
49
|
-
for item in fetched:
|
|
50
|
-
if lookback_days is not None and not _within_lookback(item, lookback_days):
|
|
51
|
-
continue
|
|
52
|
-
key = (item.url or item.title).strip().lower()
|
|
53
|
-
if key and key not in seen:
|
|
54
|
-
seen.add(key)
|
|
55
|
-
items.append(item)
|
|
56
|
-
if len(items) >= limit:
|
|
57
|
-
break
|
|
58
|
-
if len(items) >= limit:
|
|
59
|
-
break
|
|
60
|
-
|
|
61
|
-
note = "Provider-backed news. Realtime/delayed status depends on provider entitlement."
|
|
62
|
-
reliability_status = STATUS_OK
|
|
63
|
-
if not items:
|
|
64
|
-
note = "No news returned by active providers. Try /research <symbol> --deep or configure /news_model priority."
|
|
65
|
-
reliability_status = STATUS_UNAVAILABLE if errors else STATUS_PARTIAL_DATA
|
|
66
|
-
elif errors:
|
|
67
|
-
note = f"{note} Fallback used after {len(errors)} provider error(s)."
|
|
68
|
-
reliability_status = STATUS_PARTIAL_DATA
|
|
69
|
-
return NewsDesk(normalized, provider_chain, items, note, tuple(errors), lookback_days, reliability_status)
|
|
70
|
-
|
|
71
|
-
async def _fetch_provider(self, provider: str, symbol: str, limit: int) -> list[NewsItem]:
|
|
72
|
-
if provider == "yfinance" or any(item.name == provider for item in self.market_service.providers):
|
|
73
|
-
return await self.market_service.news(symbol, limit=limit)
|
|
74
|
-
return await self.news_connectors.fetch(provider, symbol, limit=limit)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _dedupe(values: list[str]) -> list[str]:
|
|
78
|
-
seen: set[str] = set()
|
|
79
|
-
result: list[str] = []
|
|
80
|
-
for value in values:
|
|
81
|
-
normalized = value.strip().lower()
|
|
82
|
-
if normalized and normalized not in seen:
|
|
83
|
-
seen.add(normalized)
|
|
84
|
-
result.append(normalized)
|
|
85
|
-
return result
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _within_lookback(item: NewsItem, lookback_days: int) -> bool:
|
|
89
|
-
if item.published_at is None:
|
|
90
|
-
return True
|
|
91
|
-
published = item.published_at
|
|
92
|
-
if published.tzinfo is None:
|
|
93
|
-
published = published.replace(tzinfo=timezone.utc)
|
|
94
|
-
cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days)
|
|
95
|
-
return published >= cutoff
|
|
1
|
+
"""News aggregation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
from fincli.app.connectors.news_connectors import NewsConnectorManager
|
|
9
|
+
from fincli.app.providers.market.base import NewsItem
|
|
10
|
+
from fincli.app.providers.reliability import STATUS_OK, STATUS_PARTIAL_DATA, STATUS_UNAVAILABLE, classify_provider_error
|
|
11
|
+
from fincli.app.services.market_data import MarketDataService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class NewsDesk:
|
|
16
|
+
symbol: str
|
|
17
|
+
provider_chain: tuple[str, ...]
|
|
18
|
+
items: list[NewsItem]
|
|
19
|
+
note: str
|
|
20
|
+
errors: tuple[str, ...] = ()
|
|
21
|
+
lookback_days: int | None = None
|
|
22
|
+
reliability_status: str = STATUS_UNAVAILABLE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NewsAggregator:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
market_service: MarketDataService,
|
|
29
|
+
news_connectors: NewsConnectorManager | None = None,
|
|
30
|
+
priority: list[str] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.market_service = market_service
|
|
33
|
+
self.news_connectors = news_connectors or NewsConnectorManager()
|
|
34
|
+
self.priority = priority or ["yfinance", "google_news_rss", "yahoo_finance_rss"]
|
|
35
|
+
|
|
36
|
+
async def latest(self, symbol: str, limit: int = 12, lookback_days: int | None = None) -> NewsDesk:
|
|
37
|
+
normalized = symbol.upper()
|
|
38
|
+
items: list[NewsItem] = []
|
|
39
|
+
errors: list[str] = []
|
|
40
|
+
seen: set[str] = set()
|
|
41
|
+
provider_chain = tuple(_dedupe(self.priority))
|
|
42
|
+
|
|
43
|
+
for provider in provider_chain:
|
|
44
|
+
try:
|
|
45
|
+
fetched = await self._fetch_provider(provider, normalized, max(limit - len(items), 1))
|
|
46
|
+
except Exception as exc: # noqa: BLE001 - fallback chain should continue
|
|
47
|
+
errors.append(f"{provider}: {classify_provider_error(exc)} ({exc})")
|
|
48
|
+
continue
|
|
49
|
+
for item in fetched:
|
|
50
|
+
if lookback_days is not None and not _within_lookback(item, lookback_days):
|
|
51
|
+
continue
|
|
52
|
+
key = (item.url or item.title).strip().lower()
|
|
53
|
+
if key and key not in seen:
|
|
54
|
+
seen.add(key)
|
|
55
|
+
items.append(item)
|
|
56
|
+
if len(items) >= limit:
|
|
57
|
+
break
|
|
58
|
+
if len(items) >= limit:
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
note = "Provider-backed news. Realtime/delayed status depends on provider entitlement."
|
|
62
|
+
reliability_status = STATUS_OK
|
|
63
|
+
if not items:
|
|
64
|
+
note = "No news returned by active providers. Try /research <symbol> --deep or configure /news_model priority."
|
|
65
|
+
reliability_status = STATUS_UNAVAILABLE if errors else STATUS_PARTIAL_DATA
|
|
66
|
+
elif errors:
|
|
67
|
+
note = f"{note} Fallback used after {len(errors)} provider error(s)."
|
|
68
|
+
reliability_status = STATUS_PARTIAL_DATA
|
|
69
|
+
return NewsDesk(normalized, provider_chain, items, note, tuple(errors), lookback_days, reliability_status)
|
|
70
|
+
|
|
71
|
+
async def _fetch_provider(self, provider: str, symbol: str, limit: int) -> list[NewsItem]:
|
|
72
|
+
if provider == "yfinance" or any(item.name == provider for item in self.market_service.providers):
|
|
73
|
+
return await self.market_service.news(symbol, limit=limit)
|
|
74
|
+
return await self.news_connectors.fetch(provider, symbol, limit=limit)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _dedupe(values: list[str]) -> list[str]:
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
result: list[str] = []
|
|
80
|
+
for value in values:
|
|
81
|
+
normalized = value.strip().lower()
|
|
82
|
+
if normalized and normalized not in seen:
|
|
83
|
+
seen.add(normalized)
|
|
84
|
+
result.append(normalized)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _within_lookback(item: NewsItem, lookback_days: int) -> bool:
|
|
89
|
+
if item.published_at is None:
|
|
90
|
+
return True
|
|
91
|
+
published = item.published_at
|
|
92
|
+
if published.tzinfo is None:
|
|
93
|
+
published = published.replace(tzinfo=timezone.utc)
|
|
94
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days)
|
|
95
|
+
return published >= cutoff
|
|
@@ -35,9 +35,12 @@ class FinCLISettings:
|
|
|
35
35
|
default_factory=lambda: ["yfinance", "google_news_rss", "yahoo_finance_rss", "marketaux", "newsapi", "gnews"]
|
|
36
36
|
)
|
|
37
37
|
timezone: str = "Asia/Jakarta"
|
|
38
|
-
default_currency: str = "USD"
|
|
39
|
-
cache_ttl_seconds: int = 300
|
|
40
|
-
|
|
38
|
+
default_currency: str = "USD"
|
|
39
|
+
cache_ttl_seconds: int = 300
|
|
40
|
+
provider_timeout_seconds: float = 12.0
|
|
41
|
+
provider_circuit_breaker_failure_threshold: int = 3
|
|
42
|
+
provider_circuit_breaker_cooldown_seconds: float = 60.0
|
|
43
|
+
theme: str = "fincli-dark"
|
|
41
44
|
|
|
42
45
|
def safe_dict(self) -> dict[str, Any]:
|
|
43
46
|
"""Return display-safe config, including masked secret status."""
|
|
@@ -98,43 +98,56 @@ class FinCLIDatabase:
|
|
|
98
98
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
-
CREATE TABLE IF NOT EXISTS alerts (
|
|
102
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
-
symbol TEXT NOT NULL,
|
|
104
|
-
condition TEXT NOT NULL,
|
|
105
|
-
target REAL NOT NULL,
|
|
101
|
+
CREATE TABLE IF NOT EXISTS alerts (
|
|
102
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
+
symbol TEXT NOT NULL,
|
|
104
|
+
condition TEXT NOT NULL,
|
|
105
|
+
target REAL NOT NULL,
|
|
106
106
|
note TEXT DEFAULT '',
|
|
107
107
|
active INTEGER DEFAULT 1,
|
|
108
|
-
triggered_at TEXT DEFAULT '',
|
|
109
|
-
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
110
|
-
);
|
|
111
|
-
|
|
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,
|
|
120
|
-
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
121
|
-
);
|
|
122
|
-
|
|
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
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
108
|
+
triggered_at TEXT DEFAULT '',
|
|
109
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
110
|
+
);
|
|
111
|
+
|
|
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,
|
|
120
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
121
|
+
);
|
|
122
|
+
|
|
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
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE TABLE IF NOT EXISTS paper_orders (
|
|
135
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
136
|
+
side TEXT NOT NULL,
|
|
137
|
+
symbol TEXT NOT NULL,
|
|
138
|
+
quantity REAL NOT NULL,
|
|
139
|
+
order_type TEXT NOT NULL,
|
|
140
|
+
price REAL,
|
|
141
|
+
notional REAL DEFAULT 0,
|
|
142
|
+
status TEXT NOT NULL,
|
|
143
|
+
strategy TEXT DEFAULT 'manual',
|
|
144
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
145
|
+
);
|
|
146
|
+
"""
|
|
147
|
+
)
|
|
148
|
+
_migrate_user_profile_schema(db)
|
|
149
|
+
except sqlite3.Error as exc:
|
|
150
|
+
raise StorageError("Database lokal gagal diinisialisasi.") from exc
|
|
138
151
|
|
|
139
152
|
def execute(self, sql: str, params: Iterable[object] = ()) -> None:
|
|
140
153
|
try:
|
|
@@ -144,85 +157,85 @@ class FinCLIDatabase:
|
|
|
144
157
|
except sqlite3.Error as exc:
|
|
145
158
|
raise StorageError("Operasi database gagal.") from exc
|
|
146
159
|
|
|
147
|
-
def query(self, sql: str, params: Iterable[object] = ()) -> list[sqlite3.Row]:
|
|
148
|
-
try:
|
|
149
|
-
with closing(self.connect()) as db:
|
|
150
|
-
return list(db.execute(sql, tuple(params)).fetchall())
|
|
151
|
-
except sqlite3.Error as exc:
|
|
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.
|
|
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"
|
|
160
|
+
def query(self, sql: str, params: Iterable[object] = ()) -> list[sqlite3.Row]:
|
|
161
|
+
try:
|
|
162
|
+
with closing(self.connect()) as db:
|
|
163
|
+
return list(db.execute(sql, tuple(params)).fetchall())
|
|
164
|
+
except sqlite3.Error as exc:
|
|
165
|
+
raise StorageError("Query database gagal.") from exc
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _migrate_user_profile_schema(db: sqlite3.Connection) -> None:
|
|
169
|
+
"""Normalize older user_profile schemas to the v0.4.0 canonical shape."""
|
|
170
|
+
|
|
171
|
+
columns = {str(row["name"]) for row in db.execute("PRAGMA table_info(user_profile)").fetchall()}
|
|
172
|
+
canonical = {"id", "name", "equity", "currency", "leverage", "years_in_investment", "gameplay", "updated_at"}
|
|
173
|
+
if canonical.issubset(columns):
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
rows = list(db.execute("SELECT * FROM user_profile").fetchall())
|
|
177
|
+
legacy_profile = _legacy_profile_payload(rows[0]) if rows else None
|
|
178
|
+
db.execute("DROP TABLE user_profile")
|
|
179
|
+
db.execute(
|
|
180
|
+
"""
|
|
181
|
+
CREATE TABLE user_profile (
|
|
182
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
183
|
+
name TEXT NOT NULL,
|
|
184
|
+
equity REAL NOT NULL,
|
|
185
|
+
currency TEXT NOT NULL,
|
|
186
|
+
leverage TEXT NOT NULL,
|
|
187
|
+
years_in_investment REAL NOT NULL,
|
|
188
|
+
gameplay TEXT NOT NULL,
|
|
189
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
190
|
+
)
|
|
191
|
+
"""
|
|
192
|
+
)
|
|
193
|
+
if legacy_profile is None:
|
|
194
|
+
return
|
|
195
|
+
db.execute(
|
|
196
|
+
"""
|
|
197
|
+
INSERT INTO user_profile (id, name, equity, currency, leverage, years_in_investment, gameplay, updated_at)
|
|
198
|
+
VALUES (1, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
199
|
+
""",
|
|
200
|
+
legacy_profile,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _legacy_profile_payload(row: sqlite3.Row) -> tuple[object, ...]:
|
|
205
|
+
keys = set(row.keys())
|
|
206
|
+
name = row["name"] if "name" in keys else "User"
|
|
207
|
+
equity = row["equity"] if "equity" in keys else row["equity_amount"] if "equity_amount" in keys else 0
|
|
208
|
+
currency = row["currency"] if "currency" in keys else row["equity_currency"] if "equity_currency" in keys else "USD"
|
|
209
|
+
leverage = row["leverage"] if "leverage" in keys else "1:1"
|
|
210
|
+
years = (
|
|
211
|
+
row["years_in_investment"]
|
|
212
|
+
if "years_in_investment" in keys
|
|
213
|
+
else row["experience_years"]
|
|
214
|
+
if "experience_years" in keys
|
|
215
|
+
else 0
|
|
216
|
+
)
|
|
217
|
+
gameplay = _normalize_legacy_gameplay(str(row["gameplay"])) if "gameplay" in keys else _classify_legacy_gameplay(float(equity))
|
|
218
|
+
return (name, equity, currency, leverage, years, gameplay)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _normalize_legacy_gameplay(value: str) -> str:
|
|
222
|
+
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
|
|
223
|
+
return {
|
|
224
|
+
"scalper": "Scalper",
|
|
225
|
+
"intra_day": "Intra day",
|
|
226
|
+
"intraday": "Intra day",
|
|
227
|
+
"day_trade": "Day trade",
|
|
228
|
+
"day_trader": "Day trade",
|
|
229
|
+
"swing": "Swing/Investor",
|
|
230
|
+
"investor": "Swing/Investor",
|
|
231
|
+
}.get(normalized, value.strip() or "Scalper")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _classify_legacy_gameplay(equity: float) -> str:
|
|
235
|
+
if equity <= 400:
|
|
236
|
+
return "Scalper"
|
|
237
|
+
if equity <= 1000:
|
|
238
|
+
return "Intra day"
|
|
239
|
+
if equity <= 5000:
|
|
240
|
+
return "Day trade"
|
|
241
|
+
return "Swing/Investor"
|
|
@@ -1,61 +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
|
-
|
|
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
|
+
|