@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,447 @@
|
|
|
1
|
+
"""yfinance fallback provider placeholder for Phase 2."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fincli.app.providers.market.base import (
|
|
9
|
+
BaseMarketProvider,
|
|
10
|
+
Candle,
|
|
11
|
+
FundamentalSnapshot,
|
|
12
|
+
NewsItem,
|
|
13
|
+
ProviderStatus,
|
|
14
|
+
Quote,
|
|
15
|
+
)
|
|
16
|
+
from fincli.app.providers.market.symbols import resolve_yfinance_symbol
|
|
17
|
+
from fincli.app.utils.errors import ProviderError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class YahooTable:
|
|
22
|
+
symbol: str
|
|
23
|
+
section: str
|
|
24
|
+
columns: list[str]
|
|
25
|
+
rows: list[list[str]]
|
|
26
|
+
source_url: str
|
|
27
|
+
note: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class YFinanceProvider(BaseMarketProvider):
|
|
31
|
+
name = "yfinance"
|
|
32
|
+
|
|
33
|
+
async def quote(self, symbol: str) -> Quote:
|
|
34
|
+
return await asyncio.to_thread(self._quote_sync, symbol)
|
|
35
|
+
|
|
36
|
+
async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
|
|
37
|
+
return await asyncio.to_thread(self._history_sync, symbol, period, interval)
|
|
38
|
+
|
|
39
|
+
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
40
|
+
return await asyncio.to_thread(self._news_sync, symbol, limit)
|
|
41
|
+
|
|
42
|
+
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
43
|
+
return await asyncio.to_thread(self._fundamentals_sync, symbol)
|
|
44
|
+
|
|
45
|
+
async def yahoo_table(
|
|
46
|
+
self,
|
|
47
|
+
symbol: str,
|
|
48
|
+
section: str,
|
|
49
|
+
period: str = "6mo",
|
|
50
|
+
interval: str = "1d",
|
|
51
|
+
) -> YahooTable:
|
|
52
|
+
return await asyncio.to_thread(self._yahoo_table_sync, symbol, section, period, interval)
|
|
53
|
+
|
|
54
|
+
async def status(self) -> ProviderStatus:
|
|
55
|
+
return ProviderStatus(
|
|
56
|
+
name=self.name,
|
|
57
|
+
realtime=False,
|
|
58
|
+
status="fallback",
|
|
59
|
+
message=f"Configured at {datetime.now().isoformat(timespec='seconds')}; yfinance fallback may be delayed.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _ticker(self, symbol: str) -> Any:
|
|
63
|
+
try:
|
|
64
|
+
import yfinance as yf
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise ProviderError(
|
|
67
|
+
"Dependency yfinance belum terinstall.",
|
|
68
|
+
"Jalankan: pip install -e \".[market]\" atau pip install yfinance pandas numpy",
|
|
69
|
+
) from exc
|
|
70
|
+
return yf.Ticker(symbol)
|
|
71
|
+
|
|
72
|
+
def _quote_sync(self, symbol: str) -> Quote:
|
|
73
|
+
resolved = resolve_yfinance_symbol(symbol)
|
|
74
|
+
ticker = self._ticker(resolved.symbol)
|
|
75
|
+
info = getattr(ticker, "fast_info", None)
|
|
76
|
+
price = None
|
|
77
|
+
currency = "USD"
|
|
78
|
+
if info is not None:
|
|
79
|
+
price = _safe_float(_safe_get(info, "last_price") or _safe_get(info, "lastPrice"))
|
|
80
|
+
currency = str(_safe_get(info, "currency") or currency)
|
|
81
|
+
|
|
82
|
+
if price is None:
|
|
83
|
+
history = ticker.history(period="5d", interval="1d")
|
|
84
|
+
if history.empty:
|
|
85
|
+
raise ProviderError(f"Data harga kosong untuk {symbol}.", "Coba symbol lain, contoh AAPL atau BTC-USD.")
|
|
86
|
+
price = float(history["Close"].dropna().iloc[-1])
|
|
87
|
+
|
|
88
|
+
return Quote(
|
|
89
|
+
symbol=resolved.symbol.upper(),
|
|
90
|
+
price=price,
|
|
91
|
+
currency=currency,
|
|
92
|
+
provider=self.name,
|
|
93
|
+
timestamp=datetime.now(),
|
|
94
|
+
status="delayed",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _history_sync(self, symbol: str, period: str, interval: str) -> list[Candle]:
|
|
98
|
+
resolved = resolve_yfinance_symbol(symbol)
|
|
99
|
+
ticker = self._ticker(resolved.symbol)
|
|
100
|
+
frame = ticker.history(period=period, interval=interval)
|
|
101
|
+
if frame.empty:
|
|
102
|
+
raise ProviderError(
|
|
103
|
+
f"Data OHLCV kosong untuk {symbol} ({resolved.symbol}).",
|
|
104
|
+
"Coba provider twelvedata/finnhub atau symbol lain, contoh EURUSD, XAUUSD, SPX, AAPL.",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
candles: list[Candle] = []
|
|
108
|
+
for index, row in frame.dropna(subset=["Open", "High", "Low", "Close"]).iterrows():
|
|
109
|
+
timestamp = index.to_pydatetime() if hasattr(index, "to_pydatetime") else datetime.now()
|
|
110
|
+
candles.append(
|
|
111
|
+
Candle(
|
|
112
|
+
timestamp=timestamp,
|
|
113
|
+
open=float(row["Open"]),
|
|
114
|
+
high=float(row["High"]),
|
|
115
|
+
low=float(row["Low"]),
|
|
116
|
+
close=float(row["Close"]),
|
|
117
|
+
volume=float(row.get("Volume", 0.0)),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return candles
|
|
121
|
+
|
|
122
|
+
def _news_sync(self, symbol: str, limit: int) -> list[NewsItem]:
|
|
123
|
+
resolved = resolve_yfinance_symbol(symbol)
|
|
124
|
+
ticker = self._ticker(resolved.symbol)
|
|
125
|
+
raw_news = getattr(ticker, "news", []) or []
|
|
126
|
+
items: list[NewsItem] = []
|
|
127
|
+
for item in raw_news[:limit]:
|
|
128
|
+
content = item.get("content", item) if isinstance(item, dict) else {}
|
|
129
|
+
title = str(content.get("title") or item.get("title") or "Untitled")
|
|
130
|
+
provider = content.get("provider") or {}
|
|
131
|
+
source = str(provider.get("displayName") if isinstance(provider, dict) else provider or "yfinance")
|
|
132
|
+
url = content.get("canonicalUrl") or content.get("clickThroughUrl") or item.get("link")
|
|
133
|
+
if isinstance(url, dict):
|
|
134
|
+
url = url.get("url")
|
|
135
|
+
published_at = None
|
|
136
|
+
timestamp = content.get("pubDate") or item.get("providerPublishTime")
|
|
137
|
+
if isinstance(timestamp, int):
|
|
138
|
+
published_at = datetime.fromtimestamp(timestamp)
|
|
139
|
+
elif isinstance(timestamp, str):
|
|
140
|
+
published_at = _parse_datetime(timestamp)
|
|
141
|
+
summary = str(content.get("summary") or "")
|
|
142
|
+
items.append(NewsItem(title=title, source=source, url=url, published_at=published_at, summary=summary))
|
|
143
|
+
return items
|
|
144
|
+
|
|
145
|
+
def _fundamentals_sync(self, symbol: str) -> FundamentalSnapshot:
|
|
146
|
+
resolved = resolve_yfinance_symbol(symbol)
|
|
147
|
+
ticker = self._ticker(resolved.symbol)
|
|
148
|
+
info = ticker.info or {}
|
|
149
|
+
return FundamentalSnapshot(
|
|
150
|
+
symbol=resolved.symbol.upper(),
|
|
151
|
+
provider=self.name,
|
|
152
|
+
currency=str(info.get("financialCurrency") or info.get("currency") or "USD"),
|
|
153
|
+
market_cap=_safe_float(info.get("marketCap")),
|
|
154
|
+
pe_ratio=_safe_float(info.get("trailingPE")),
|
|
155
|
+
eps=_safe_float(info.get("trailingEps")),
|
|
156
|
+
revenue=_safe_float(info.get("totalRevenue")),
|
|
157
|
+
beta=_safe_float(info.get("beta")),
|
|
158
|
+
sector=info.get("sector"),
|
|
159
|
+
industry=info.get("industry"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _yahoo_table_sync(self, symbol: str, section: str, period: str, interval: str) -> YahooTable:
|
|
163
|
+
resolved = resolve_yfinance_symbol(symbol)
|
|
164
|
+
ticker = self._ticker(resolved.symbol)
|
|
165
|
+
normalized = section.lower().strip()
|
|
166
|
+
source_url = _source_url(resolved.symbol, normalized)
|
|
167
|
+
|
|
168
|
+
if normalized in {"history", "historical", "ohlcv"}:
|
|
169
|
+
frame = ticker.history(period=period, interval=interval)
|
|
170
|
+
return _frame_table(resolved.symbol, "history", frame, source_url, max_rows=40)
|
|
171
|
+
|
|
172
|
+
if normalized in {"statistics", "stats", "key-statistics", "key_statistics"}:
|
|
173
|
+
info = ticker.info or {}
|
|
174
|
+
rows = [[label, _format_value(info.get(key))] for label, key in _STATISTIC_FIELDS]
|
|
175
|
+
rows = [row for row in rows if row[1] != "N/A"]
|
|
176
|
+
if not rows:
|
|
177
|
+
rows = _dict_rows(info)
|
|
178
|
+
return YahooTable(resolved.symbol.upper(), "statistics", ["Metric", "Value"], rows, source_url)
|
|
179
|
+
|
|
180
|
+
if normalized == "profile":
|
|
181
|
+
info = ticker.info or {}
|
|
182
|
+
rows = [[label, _format_value(info.get(key))] for label, key in _PROFILE_FIELDS]
|
|
183
|
+
rows = [row for row in rows if row[1] != "N/A"]
|
|
184
|
+
if not rows:
|
|
185
|
+
rows = _dict_rows(info)
|
|
186
|
+
return YahooTable(resolved.symbol.upper(), "profile", ["Field", "Value"], rows, source_url)
|
|
187
|
+
|
|
188
|
+
if normalized in {"financials", "income", "income-statement"}:
|
|
189
|
+
frame = _first_frame(ticker, ("financials", "income_stmt", "quarterly_financials", "quarterly_income_stmt"))
|
|
190
|
+
return _frame_table(resolved.symbol, "financials", frame, source_url, max_rows=30, transpose=False)
|
|
191
|
+
|
|
192
|
+
if normalized in {"balance", "balance-sheet", "balance_sheet"}:
|
|
193
|
+
frame = _first_frame(ticker, ("balance_sheet", "quarterly_balance_sheet"))
|
|
194
|
+
return _frame_table(resolved.symbol, "balance-sheet", frame, source_url, max_rows=30, transpose=False)
|
|
195
|
+
|
|
196
|
+
if normalized in {"cashflow", "cash-flow", "cash_flow"}:
|
|
197
|
+
frame = _first_frame(ticker, ("cashflow", "cash_flow", "quarterly_cashflow", "quarterly_cash_flow"))
|
|
198
|
+
return _frame_table(resolved.symbol, "cashflow", frame, source_url, max_rows=30, transpose=False)
|
|
199
|
+
|
|
200
|
+
if normalized == "analysis":
|
|
201
|
+
return self._analysis_table(ticker, resolved.symbol, source_url)
|
|
202
|
+
|
|
203
|
+
if normalized == "holders":
|
|
204
|
+
return self._holders_table(ticker, resolved.symbol, source_url)
|
|
205
|
+
|
|
206
|
+
if normalized == "news":
|
|
207
|
+
news = self._news_sync(symbol, limit=10)
|
|
208
|
+
rows = [
|
|
209
|
+
[
|
|
210
|
+
item.title,
|
|
211
|
+
item.source,
|
|
212
|
+
item.published_at.isoformat(timespec="seconds") if item.published_at else "unknown",
|
|
213
|
+
item.url or "-",
|
|
214
|
+
]
|
|
215
|
+
for item in news
|
|
216
|
+
]
|
|
217
|
+
return YahooTable(resolved.symbol.upper(), "news", ["Title", "Source", "Published", "URL"], rows, source_url)
|
|
218
|
+
|
|
219
|
+
raise ProviderError(
|
|
220
|
+
f"Yahoo section tidak dikenal: {section}",
|
|
221
|
+
"Gunakan: history, statistics, profile, financials, balance, cashflow, analysis, holders, news.",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def _analysis_table(self, ticker: Any, symbol: str, source_url: str) -> YahooTable:
|
|
225
|
+
rows: list[list[str]] = []
|
|
226
|
+
targets = _safe_get(ticker, "analyst_price_targets") or {}
|
|
227
|
+
if isinstance(targets, dict):
|
|
228
|
+
for key, value in targets.items():
|
|
229
|
+
rows.append(["Price Target", str(key), _format_value(value)])
|
|
230
|
+
|
|
231
|
+
recommendations = _safe_get(ticker, "recommendations")
|
|
232
|
+
rec_rows = _frame_rows(recommendations, max_rows=15)
|
|
233
|
+
rows.extend([["Recommendations", *row] for row in rec_rows])
|
|
234
|
+
|
|
235
|
+
upgrades = _safe_get(ticker, "upgrades_downgrades")
|
|
236
|
+
upgrade_rows = _frame_rows(upgrades, max_rows=15)
|
|
237
|
+
rows.extend([["Upgrades/Downgrades", *row] for row in upgrade_rows])
|
|
238
|
+
|
|
239
|
+
if not rows:
|
|
240
|
+
info = _safe_get(ticker, "info") or {}
|
|
241
|
+
rows = [
|
|
242
|
+
["Analyst Rating", "recommendationKey", _format_value(info.get("recommendationKey"))],
|
|
243
|
+
["Analyst Count", "numberOfAnalystOpinions", _format_value(info.get("numberOfAnalystOpinions"))],
|
|
244
|
+
["Target Mean", "targetMeanPrice", _format_value(info.get("targetMeanPrice"))],
|
|
245
|
+
["Target High", "targetHighPrice", _format_value(info.get("targetHighPrice"))],
|
|
246
|
+
["Target Low", "targetLowPrice", _format_value(info.get("targetLowPrice"))],
|
|
247
|
+
]
|
|
248
|
+
rows = [row for row in rows if row[-1] != "N/A"]
|
|
249
|
+
|
|
250
|
+
return YahooTable(
|
|
251
|
+
symbol.upper(),
|
|
252
|
+
"analysis",
|
|
253
|
+
["Section", "Field", "Value", "Extra 1", "Extra 2", "Extra 3"],
|
|
254
|
+
_pad_rows(rows, 6),
|
|
255
|
+
source_url,
|
|
256
|
+
"Yahoo analysis availability varies by exchange and ticker.",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _holders_table(self, ticker: Any, symbol: str, source_url: str) -> YahooTable:
|
|
260
|
+
rows: list[list[str]] = []
|
|
261
|
+
for label, attr in (
|
|
262
|
+
("Major Holders", "major_holders"),
|
|
263
|
+
("Institutional Holders", "institutional_holders"),
|
|
264
|
+
("Mutual Fund Holders", "mutualfund_holders"),
|
|
265
|
+
("Insider Transactions", "insider_transactions"),
|
|
266
|
+
):
|
|
267
|
+
frame_rows = _frame_rows(_safe_get(ticker, attr), max_rows=15)
|
|
268
|
+
rows.extend([[label, *row] for row in frame_rows])
|
|
269
|
+
return YahooTable(
|
|
270
|
+
symbol.upper(),
|
|
271
|
+
"holders",
|
|
272
|
+
["Section", "Column 1", "Column 2", "Column 3", "Column 4", "Column 5"],
|
|
273
|
+
_pad_rows(rows, 6),
|
|
274
|
+
source_url,
|
|
275
|
+
"Holder data availability varies by exchange and Yahoo coverage.",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
_STATISTIC_FIELDS = (
|
|
280
|
+
("Market Cap", "marketCap"),
|
|
281
|
+
("Enterprise Value", "enterpriseValue"),
|
|
282
|
+
("Trailing P/E", "trailingPE"),
|
|
283
|
+
("Forward P/E", "forwardPE"),
|
|
284
|
+
("PEG Ratio", "pegRatio"),
|
|
285
|
+
("Price/Sales", "priceToSalesTrailing12Months"),
|
|
286
|
+
("Price/Book", "priceToBook"),
|
|
287
|
+
("EPS", "trailingEps"),
|
|
288
|
+
("Forward EPS", "forwardEps"),
|
|
289
|
+
("Revenue", "totalRevenue"),
|
|
290
|
+
("Gross Margins", "grossMargins"),
|
|
291
|
+
("Operating Margins", "operatingMargins"),
|
|
292
|
+
("Profit Margins", "profitMargins"),
|
|
293
|
+
("Return on Assets", "returnOnAssets"),
|
|
294
|
+
("Return on Equity", "returnOnEquity"),
|
|
295
|
+
("Dividend Yield", "dividendYield"),
|
|
296
|
+
("Beta", "beta"),
|
|
297
|
+
("52 Week High", "fiftyTwoWeekHigh"),
|
|
298
|
+
("52 Week Low", "fiftyTwoWeekLow"),
|
|
299
|
+
("Average Volume", "averageVolume"),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
_PROFILE_FIELDS = (
|
|
303
|
+
("Name", "longName"),
|
|
304
|
+
("Symbol", "symbol"),
|
|
305
|
+
("Exchange", "exchange"),
|
|
306
|
+
("Quote Type", "quoteType"),
|
|
307
|
+
("Currency", "currency"),
|
|
308
|
+
("Financial Currency", "financialCurrency"),
|
|
309
|
+
("Country", "country"),
|
|
310
|
+
("Sector", "sector"),
|
|
311
|
+
("Industry", "industry"),
|
|
312
|
+
("Employees", "fullTimeEmployees"),
|
|
313
|
+
("Website", "website"),
|
|
314
|
+
("Address", "address1"),
|
|
315
|
+
("City", "city"),
|
|
316
|
+
("Phone", "phone"),
|
|
317
|
+
("Business Summary", "longBusinessSummary"),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _safe_get(obj: Any, key: str) -> Any:
|
|
322
|
+
try:
|
|
323
|
+
return obj[key]
|
|
324
|
+
except Exception: # noqa: BLE001
|
|
325
|
+
return getattr(obj, key, None)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _source_url(symbol: str, section: str) -> str:
|
|
329
|
+
encoded = symbol.upper().replace("^", "%5E")
|
|
330
|
+
suffix = {
|
|
331
|
+
"history": "history",
|
|
332
|
+
"historical": "history",
|
|
333
|
+
"ohlcv": "history",
|
|
334
|
+
"statistics": "key-statistics",
|
|
335
|
+
"stats": "key-statistics",
|
|
336
|
+
"key-statistics": "key-statistics",
|
|
337
|
+
"key_statistics": "key-statistics",
|
|
338
|
+
"profile": "profile",
|
|
339
|
+
"financials": "financials",
|
|
340
|
+
"income": "financials",
|
|
341
|
+
"income-statement": "financials",
|
|
342
|
+
"balance": "balance-sheet",
|
|
343
|
+
"balance-sheet": "balance-sheet",
|
|
344
|
+
"balance_sheet": "balance-sheet",
|
|
345
|
+
"cashflow": "cash-flow",
|
|
346
|
+
"cash-flow": "cash-flow",
|
|
347
|
+
"cash_flow": "cash-flow",
|
|
348
|
+
"analysis": "analysis",
|
|
349
|
+
"holders": "holders",
|
|
350
|
+
"news": "news",
|
|
351
|
+
}.get(section, section)
|
|
352
|
+
return f"https://finance.yahoo.com/quote/{encoded}/{suffix}/"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _frame_table(
|
|
356
|
+
symbol: str,
|
|
357
|
+
section: str,
|
|
358
|
+
frame: Any,
|
|
359
|
+
source_url: str,
|
|
360
|
+
max_rows: int = 25,
|
|
361
|
+
max_cols: int = 8,
|
|
362
|
+
transpose: bool = False,
|
|
363
|
+
) -> YahooTable:
|
|
364
|
+
if frame is None or getattr(frame, "empty", True):
|
|
365
|
+
return YahooTable(symbol.upper(), section, ["Info"], [["No data returned by yfinance/Yahoo."]], source_url)
|
|
366
|
+
data = frame.T if transpose else frame
|
|
367
|
+
rows = _frame_rows(data, max_rows=max_rows, max_cols=max_cols)
|
|
368
|
+
columns = _frame_columns(data, max_cols=max_cols)
|
|
369
|
+
return YahooTable(symbol.upper(), section, columns, rows, source_url)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _frame_columns(frame: Any, max_cols: int = 8) -> list[str]:
|
|
373
|
+
try:
|
|
374
|
+
reset = frame.reset_index()
|
|
375
|
+
return [_stringify(column) for column in list(reset.columns[:max_cols])]
|
|
376
|
+
except Exception: # noqa: BLE001
|
|
377
|
+
return ["Column 1", "Column 2"]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _frame_rows(frame: Any, max_rows: int = 25, max_cols: int = 8) -> list[list[str]]:
|
|
381
|
+
if frame is None or getattr(frame, "empty", True):
|
|
382
|
+
return []
|
|
383
|
+
try:
|
|
384
|
+
reset = frame.reset_index()
|
|
385
|
+
rows: list[list[str]] = []
|
|
386
|
+
for _, row in reset.head(max_rows).iterrows():
|
|
387
|
+
rows.append([_format_value(value) for value in list(row.iloc[:max_cols])])
|
|
388
|
+
return rows
|
|
389
|
+
except Exception: # noqa: BLE001
|
|
390
|
+
return []
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _first_frame(ticker: Any, attrs: tuple[str, ...]) -> Any:
|
|
394
|
+
for attr in attrs:
|
|
395
|
+
frame = _safe_get(ticker, attr)
|
|
396
|
+
if frame is not None and not getattr(frame, "empty", True):
|
|
397
|
+
return frame
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _dict_rows(data: dict[str, Any], max_rows: int = 40) -> list[list[str]]:
|
|
402
|
+
rows = []
|
|
403
|
+
for key, value in list(data.items())[:max_rows]:
|
|
404
|
+
if value is None or isinstance(value, (dict, list, tuple, set)):
|
|
405
|
+
continue
|
|
406
|
+
rows.append([_stringify(key), _format_value(value)])
|
|
407
|
+
return rows
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _pad_rows(rows: list[list[str]], columns: int) -> list[list[str]]:
|
|
411
|
+
return [row[:columns] + [""] * max(0, columns - len(row)) for row in rows]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _format_value(value: Any) -> str:
|
|
415
|
+
if value is None:
|
|
416
|
+
return "N/A"
|
|
417
|
+
try:
|
|
418
|
+
if hasattr(value, "isoformat"):
|
|
419
|
+
return value.isoformat()
|
|
420
|
+
except Exception: # noqa: BLE001
|
|
421
|
+
pass
|
|
422
|
+
if isinstance(value, float):
|
|
423
|
+
return f"{value:,.4f}"
|
|
424
|
+
if isinstance(value, int):
|
|
425
|
+
return f"{value:,}"
|
|
426
|
+
text = str(value)
|
|
427
|
+
return text[:500] + "..." if len(text) > 500 else text
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _stringify(value: Any) -> str:
|
|
431
|
+
return str(value)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _safe_float(value: Any) -> float | None:
|
|
435
|
+
try:
|
|
436
|
+
if value is None:
|
|
437
|
+
return None
|
|
438
|
+
return float(value)
|
|
439
|
+
except (TypeError, ValueError):
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _parse_datetime(value: str) -> datetime | None:
|
|
444
|
+
try:
|
|
445
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
446
|
+
except ValueError:
|
|
447
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application service layer."""
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Market data service with provider fallback chain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Awaitable
|
|
10
|
+
|
|
11
|
+
from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
|
|
12
|
+
from fincli.app.storage.market_cache import MarketCache
|
|
13
|
+
from fincli.app.utils.errors import ProviderError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MarketDataService:
|
|
17
|
+
"""Fetch market data through a prioritized provider chain."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
providers: list[BaseMarketProvider],
|
|
22
|
+
cache: MarketCache | None = None,
|
|
23
|
+
cache_ttl_seconds: int = 300,
|
|
24
|
+
) -> None:
|
|
25
|
+
if not providers:
|
|
26
|
+
raise ProviderError("MarketDataService membutuhkan minimal satu provider.")
|
|
27
|
+
self.providers = providers
|
|
28
|
+
self.cache = cache
|
|
29
|
+
self.cache_ttl_seconds = cache_ttl_seconds
|
|
30
|
+
self.last_errors: list[str] = []
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def primary_provider(self) -> BaseMarketProvider:
|
|
34
|
+
return self.providers[0]
|
|
35
|
+
|
|
36
|
+
async def quote(self, symbol: str) -> Quote:
|
|
37
|
+
cache_key = self._cache_key(symbol)
|
|
38
|
+
cached = self._cache_get("quote", cache_key)
|
|
39
|
+
if isinstance(cached, dict):
|
|
40
|
+
return _quote_from_payload(cached)
|
|
41
|
+
quote = await self._with_fallback("quote", symbol)
|
|
42
|
+
self._cache_set("quote", cache_key, _quote_to_payload(quote))
|
|
43
|
+
return quote
|
|
44
|
+
|
|
45
|
+
async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
|
|
46
|
+
cache_key = self._cache_key(symbol, period, interval)
|
|
47
|
+
cached = self._cache_get("history", cache_key)
|
|
48
|
+
if isinstance(cached, list):
|
|
49
|
+
return [_candle_from_payload(item) for item in cached if isinstance(item, dict)]
|
|
50
|
+
candles = await self._with_fallback("history", symbol, period, interval)
|
|
51
|
+
if candles:
|
|
52
|
+
self._cache_set("history", cache_key, [_candle_to_payload(candle) for candle in candles])
|
|
53
|
+
return candles
|
|
54
|
+
|
|
55
|
+
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
56
|
+
cache_key = self._cache_key(symbol, str(limit))
|
|
57
|
+
cached = self._cache_get("news", cache_key)
|
|
58
|
+
if isinstance(cached, list):
|
|
59
|
+
return [_news_from_payload(item) for item in cached if isinstance(item, dict)]
|
|
60
|
+
items = await self._with_fallback("news", symbol, limit)
|
|
61
|
+
self._cache_set("news", cache_key, [_news_to_payload(item) for item in items])
|
|
62
|
+
return items
|
|
63
|
+
|
|
64
|
+
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
65
|
+
cache_key = self._cache_key(symbol)
|
|
66
|
+
cached = self._cache_get("fundamentals", cache_key)
|
|
67
|
+
if isinstance(cached, dict):
|
|
68
|
+
return _fundamentals_from_payload(cached)
|
|
69
|
+
snapshot = await self._with_fallback("fundamentals", symbol)
|
|
70
|
+
self._cache_set("fundamentals", cache_key, _fundamentals_to_payload(snapshot))
|
|
71
|
+
return snapshot
|
|
72
|
+
|
|
73
|
+
async def status(self) -> ProviderStatus:
|
|
74
|
+
provider = self.primary_provider
|
|
75
|
+
try:
|
|
76
|
+
return await provider.status()
|
|
77
|
+
except Exception as exc: # noqa: BLE001
|
|
78
|
+
return ProviderStatus(
|
|
79
|
+
name=getattr(provider, "name", "unknown"),
|
|
80
|
+
realtime=False,
|
|
81
|
+
status="unavailable",
|
|
82
|
+
message=str(exc),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def _with_fallback(self, method_name: str, *args: object) -> Any:
|
|
86
|
+
errors: list[str] = []
|
|
87
|
+
for provider in self.providers:
|
|
88
|
+
try:
|
|
89
|
+
method = getattr(provider, method_name)
|
|
90
|
+
return await method(*args)
|
|
91
|
+
except Exception as exc: # noqa: BLE001
|
|
92
|
+
errors.append(f"{getattr(provider, 'name', 'unknown')}: {exc}")
|
|
93
|
+
self.last_errors = errors
|
|
94
|
+
raise ProviderError(
|
|
95
|
+
f"Semua provider gagal untuk {method_name}.",
|
|
96
|
+
"\n".join(errors),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _cache_key(self, symbol: str, *parts: object) -> str:
|
|
100
|
+
provider_chain = ",".join(provider.name for provider in self.providers)
|
|
101
|
+
normalized = [symbol.upper(), *(str(part).lower() for part in parts), f"providers={provider_chain}"]
|
|
102
|
+
return "|".join(normalized)
|
|
103
|
+
|
|
104
|
+
def _cache_get(self, namespace: str, cache_key: str) -> dict[str, Any] | list[Any] | None:
|
|
105
|
+
if self.cache is None:
|
|
106
|
+
return None
|
|
107
|
+
return self.cache.get(namespace, cache_key)
|
|
108
|
+
|
|
109
|
+
def _cache_set(self, namespace: str, cache_key: str, payload: dict[str, Any] | list[Any]) -> None:
|
|
110
|
+
if self.cache is None:
|
|
111
|
+
return
|
|
112
|
+
self.cache.set(namespace, cache_key, payload, self.cache_ttl_seconds)
|
|
113
|
+
|
|
114
|
+
def run(self, awaitable: Awaitable[Any]) -> Any:
|
|
115
|
+
try:
|
|
116
|
+
asyncio.get_running_loop()
|
|
117
|
+
except RuntimeError:
|
|
118
|
+
return asyncio.run(awaitable)
|
|
119
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
120
|
+
future = executor.submit(asyncio.run, awaitable)
|
|
121
|
+
return future.result()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _quote_to_payload(quote: Quote) -> dict[str, Any]:
|
|
125
|
+
payload = asdict(quote)
|
|
126
|
+
payload["timestamp"] = quote.timestamp.isoformat()
|
|
127
|
+
return payload
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _quote_from_payload(payload: dict[str, Any]) -> Quote:
|
|
131
|
+
return Quote(
|
|
132
|
+
symbol=str(payload["symbol"]),
|
|
133
|
+
price=None if payload.get("price") is None else float(payload["price"]),
|
|
134
|
+
currency=str(payload.get("currency", "USD")),
|
|
135
|
+
provider=str(payload.get("provider", "cache")),
|
|
136
|
+
timestamp=_parse_datetime(payload.get("timestamp")),
|
|
137
|
+
status=str(payload.get("status", "cached")),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _candle_to_payload(candle: Candle) -> dict[str, Any]:
|
|
142
|
+
payload = asdict(candle)
|
|
143
|
+
payload["timestamp"] = candle.timestamp.isoformat()
|
|
144
|
+
return payload
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _candle_from_payload(payload: dict[str, Any]) -> Candle:
|
|
148
|
+
return Candle(
|
|
149
|
+
timestamp=_parse_datetime(payload.get("timestamp")),
|
|
150
|
+
open=float(payload["open"]),
|
|
151
|
+
high=float(payload["high"]),
|
|
152
|
+
low=float(payload["low"]),
|
|
153
|
+
close=float(payload["close"]),
|
|
154
|
+
volume=float(payload.get("volume", 0)),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _news_to_payload(item: NewsItem) -> dict[str, Any]:
|
|
159
|
+
payload = asdict(item)
|
|
160
|
+
payload["published_at"] = item.published_at.isoformat() if item.published_at else None
|
|
161
|
+
return payload
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _news_from_payload(payload: dict[str, Any]) -> NewsItem:
|
|
165
|
+
published_at = payload.get("published_at")
|
|
166
|
+
return NewsItem(
|
|
167
|
+
title=str(payload.get("title", "")),
|
|
168
|
+
source=str(payload.get("source", "")),
|
|
169
|
+
url=None if payload.get("url") is None else str(payload.get("url")),
|
|
170
|
+
published_at=None if published_at is None else _parse_datetime(published_at),
|
|
171
|
+
summary=str(payload.get("summary", "")),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _fundamentals_to_payload(snapshot: FundamentalSnapshot) -> dict[str, Any]:
|
|
176
|
+
return asdict(snapshot)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _fundamentals_from_payload(payload: dict[str, Any]) -> FundamentalSnapshot:
|
|
180
|
+
return FundamentalSnapshot(
|
|
181
|
+
symbol=str(payload["symbol"]),
|
|
182
|
+
provider=str(payload.get("provider", "cache")),
|
|
183
|
+
currency=str(payload.get("currency", "USD")),
|
|
184
|
+
market_cap=_optional_float(payload.get("market_cap")),
|
|
185
|
+
pe_ratio=_optional_float(payload.get("pe_ratio")),
|
|
186
|
+
eps=_optional_float(payload.get("eps")),
|
|
187
|
+
revenue=_optional_float(payload.get("revenue")),
|
|
188
|
+
beta=_optional_float(payload.get("beta")),
|
|
189
|
+
sector=None if payload.get("sector") is None else str(payload.get("sector")),
|
|
190
|
+
industry=None if payload.get("industry") is None else str(payload.get("industry")),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _parse_datetime(value: object) -> datetime:
|
|
195
|
+
if isinstance(value, datetime):
|
|
196
|
+
return value
|
|
197
|
+
return datetime.fromisoformat(str(value))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _optional_float(value: object) -> float | None:
|
|
201
|
+
if value is None:
|
|
202
|
+
return None
|
|
203
|
+
return float(value)
|