@drico2008/fincli 0.1.3 → 0.2.2
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 +909 -684
- 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 +23 -16
- package/fincli/app/analysis/analyzer.py +107 -100
- package/fincli/app/analysis/assistant_context.py +187 -160
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/indicators.py +1 -1
- package/fincli/app/analysis/market_structure.py +1 -1
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +105 -77
- package/fincli/app/cli/router.py +2143 -1121
- 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/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/session_history.py +113 -0
- 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/anthropic_provider.py +8 -7
- package/fincli/app/providers/ai/gemini_provider.py +8 -7
- package/fincli/app/providers/ai/groq_provider.py +8 -7
- package/fincli/app/providers/ai/huggingface_provider.py +8 -7
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/ai/openai_provider.py +8 -7
- package/fincli/app/providers/ai/openrouter_provider.py +8 -7
- package/fincli/app/providers/ai/together_provider.py +8 -7
- package/fincli/app/providers/market/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 +85 -2
- package/fincli/app/providers/market/news_provider.py +4 -4
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/market/yfinance_provider.py +1 -1
- package/fincli/app/research/__init__.py +7 -0
- package/fincli/app/research/engine.py +75 -0
- package/fincli/app/research/formatter.py +22 -0
- package/fincli/app/research/models.py +18 -0
- package/fincli/app/research/prompt_builder.py +47 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +203 -203
- package/fincli/app/services/news_aggregator.py +90 -0
- package/fincli/app/services/web_research.py +267 -0
- package/fincli/app/storage/cache.py +2 -2
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +201 -85
- package/fincli/app/storage/secrets.py +12 -3
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +270 -258
- package/fincli/app/tui/market_provider_selector.py +6 -1
- package/fincli/app/tui/model_selector.py +11 -3
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +125 -12
- package/npm/bin/fincli.js +9 -2
- package/package.json +23 -23
- package/pyproject.toml +35 -35
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
|
+
from fincli.app.providers.market.base import SymbolSearchResult
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
FOREX_CURRENCIES = {
|
|
9
11
|
"AUD",
|
|
@@ -129,6 +131,45 @@ class ResolvedSymbol:
|
|
|
129
131
|
asset_class: str
|
|
130
132
|
|
|
131
133
|
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class SymbolAlias:
|
|
136
|
+
symbol: str
|
|
137
|
+
name: str
|
|
138
|
+
asset_class: str
|
|
139
|
+
exchange: str = ""
|
|
140
|
+
currency: str = ""
|
|
141
|
+
aliases: tuple[str, ...] = ()
|
|
142
|
+
notes: str = ""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
SYMBOL_CATALOG: tuple[SymbolAlias, ...] = (
|
|
146
|
+
SymbolAlias("AAPL", "Apple Inc.", "stock", "NASDAQ", "USD", ("APPLE",)),
|
|
147
|
+
SymbolAlias("MSFT", "Microsoft Corporation", "stock", "NASDAQ", "USD", ("MICROSOFT",)),
|
|
148
|
+
SymbolAlias("NVDA", "NVIDIA Corporation", "stock", "NASDAQ", "USD", ("NVIDIA",)),
|
|
149
|
+
SymbolAlias("TSLA", "Tesla Inc.", "stock", "NASDAQ", "USD", ("TESLA",)),
|
|
150
|
+
SymbolAlias("SPY", "SPDR S&P 500 ETF Trust", "etf", "NYSE Arca", "USD", ("S&P ETF",)),
|
|
151
|
+
SymbolAlias("QQQ", "Invesco QQQ Trust", "etf", "NASDAQ", "USD", ("NASDAQ ETF",)),
|
|
152
|
+
SymbolAlias("SPX", "S&P 500 Index", "index", "US", "USD", ("SP500", "S&P500", "^GSPC")),
|
|
153
|
+
SymbolAlias("NASDAQ", "Nasdaq Composite Index", "index", "US", "USD", ("IXIC", "^IXIC")),
|
|
154
|
+
SymbolAlias("DOW", "Dow Jones Industrial Average", "index", "US", "USD", ("DJI", "^DJI")),
|
|
155
|
+
SymbolAlias("DAX", "DAX Performance Index", "index", "Germany", "EUR", ("^GDAXI",)),
|
|
156
|
+
SymbolAlias("NIKKEI", "Nikkei 225 Index", "index", "Japan", "JPY", ("N225", "^N225")),
|
|
157
|
+
SymbolAlias("EURUSD", "Euro / US Dollar", "forex", "FX", "USD", ("EUR/USD", "EURUSD=X")),
|
|
158
|
+
SymbolAlias("GBPUSD", "British Pound / US Dollar", "forex", "FX", "USD", ("GBP/USD", "GBPUSD=X")),
|
|
159
|
+
SymbolAlias("USDJPY", "US Dollar / Japanese Yen", "forex", "FX", "JPY", ("USD/JPY", "USDJPY=X")),
|
|
160
|
+
SymbolAlias("XAUUSD", "Gold Spot / US Dollar", "commodity", "Metals", "USD", ("GOLD", "XAU/USD", "GC=F")),
|
|
161
|
+
SymbolAlias("XAGUSD", "Silver Spot / US Dollar", "commodity", "Metals", "USD", ("SILVER", "XAG/USD", "SI=F")),
|
|
162
|
+
SymbolAlias("WTI", "WTI Crude Oil Futures", "commodity", "NYMEX", "USD", ("CL=F", "OIL")),
|
|
163
|
+
SymbolAlias("BRENT", "Brent Crude Oil Futures", "commodity", "ICE", "USD", ("BZ=F",)),
|
|
164
|
+
SymbolAlias("BTC-USD", "Bitcoin / US Dollar", "crypto", "Crypto", "USD", ("BTCUSD", "BTCUSDT", "BINANCE:BTCUSDT")),
|
|
165
|
+
SymbolAlias("ETH-USD", "Ethereum / US Dollar", "crypto", "Crypto", "USD", ("ETHUSD", "ETHUSDT", "BINANCE:ETHUSDT")),
|
|
166
|
+
SymbolAlias("BBRI", "Bank Rakyat Indonesia", "stock", "IDX", "IDR", ("BBRI.JK",)),
|
|
167
|
+
SymbolAlias("BBCA", "Bank Central Asia", "stock", "IDX", "IDR", ("BBCA.JK",)),
|
|
168
|
+
SymbolAlias("BMRI", "Bank Mandiri", "stock", "IDX", "IDR", ("BMRI.JK",)),
|
|
169
|
+
SymbolAlias("TLKM", "Telkom Indonesia", "stock", "IDX", "IDR", ("TLKM.JK",)),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
132
173
|
def resolve_yfinance_symbol(symbol: str) -> ResolvedSymbol:
|
|
133
174
|
normalized = _normalize(symbol)
|
|
134
175
|
if normalized in YFINANCE_ALIASES:
|
|
@@ -153,6 +194,8 @@ def resolve_twelvedata_symbol(symbol: str) -> ResolvedSymbol:
|
|
|
153
194
|
|
|
154
195
|
def resolve_finnhub_symbol(symbol: str) -> ResolvedSymbol:
|
|
155
196
|
normalized = _normalize(symbol)
|
|
197
|
+
if _is_metal_pair(normalized):
|
|
198
|
+
return ResolvedSymbol(symbol, f"OANDA:{normalized[:3]}_{normalized[3:]}", "commodity")
|
|
156
199
|
if _is_forex_pair(normalized):
|
|
157
200
|
return ResolvedSymbol(symbol, f"OANDA:{normalized[:3]}_{normalized[3:]}", "forex")
|
|
158
201
|
if normalized.startswith("BINANCE:"):
|
|
@@ -162,6 +205,68 @@ def resolve_finnhub_symbol(symbol: str) -> ResolvedSymbol:
|
|
|
162
205
|
return ResolvedSymbol(symbol, symbol.upper(), "stock")
|
|
163
206
|
|
|
164
207
|
|
|
208
|
+
def resolve_provider_symbol(provider: str, symbol: str) -> ResolvedSymbol:
|
|
209
|
+
provider_name = provider.lower().strip()
|
|
210
|
+
if provider_name == "yfinance":
|
|
211
|
+
return resolve_yfinance_symbol(symbol)
|
|
212
|
+
if provider_name == "twelvedata":
|
|
213
|
+
return resolve_twelvedata_symbol(symbol)
|
|
214
|
+
if provider_name == "finnhub":
|
|
215
|
+
return resolve_finnhub_symbol(symbol)
|
|
216
|
+
if provider_name == "alphavantage":
|
|
217
|
+
normalized = _normalize(symbol)
|
|
218
|
+
if _is_metal_pair(normalized):
|
|
219
|
+
return ResolvedSymbol(symbol, normalized, "commodity")
|
|
220
|
+
if _is_forex_pair(normalized):
|
|
221
|
+
return ResolvedSymbol(symbol, normalized, "forex")
|
|
222
|
+
if normalized in IDX_ALIASES:
|
|
223
|
+
return ResolvedSymbol(symbol, normalized, "stock")
|
|
224
|
+
return ResolvedSymbol(symbol, symbol.strip().upper(), _infer_asset_class(normalized))
|
|
225
|
+
if provider_name == "custom":
|
|
226
|
+
normalized = symbol.strip().upper()
|
|
227
|
+
return ResolvedSymbol(symbol, normalized, _infer_asset_class(normalized))
|
|
228
|
+
raise ValueError(f"Unknown market provider: {provider}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def provider_symbol_matrix(symbol: str, providers: tuple[str, ...] | None = None) -> dict[str, ResolvedSymbol]:
|
|
232
|
+
names = providers or ("yfinance", "twelvedata", "finnhub", "alphavantage", "custom")
|
|
233
|
+
return {name: resolve_provider_symbol(name, symbol) for name in names}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def search_symbol_catalog(query: str, limit: int = 12) -> list[SymbolSearchResult]:
|
|
237
|
+
normalized = _normalize(query)
|
|
238
|
+
if not normalized:
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
matches: list[tuple[int, SymbolAlias]] = []
|
|
242
|
+
for item in SYMBOL_CATALOG:
|
|
243
|
+
haystack = [item.symbol, item.name, item.asset_class, item.exchange, item.currency, *item.aliases]
|
|
244
|
+
normalized_haystack = [_normalize(part) for part in haystack if part]
|
|
245
|
+
score = _match_score(normalized, normalized_haystack)
|
|
246
|
+
if score > 0:
|
|
247
|
+
matches.append((score, item))
|
|
248
|
+
|
|
249
|
+
matches.sort(key=lambda pair: (-pair[0], pair[1].symbol))
|
|
250
|
+
results = [_symbol_alias_to_result(item) for _, item in matches[:limit]]
|
|
251
|
+
if results:
|
|
252
|
+
return results
|
|
253
|
+
|
|
254
|
+
guessed = symbol_search_result(query)
|
|
255
|
+
return [guessed] if guessed else []
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def symbol_search_result(symbol: str) -> SymbolSearchResult:
|
|
259
|
+
matrix = provider_symbol_matrix(symbol)
|
|
260
|
+
first = next(iter(matrix.values()))
|
|
261
|
+
return SymbolSearchResult(
|
|
262
|
+
symbol=symbol.upper(),
|
|
263
|
+
name=f"{symbol.upper()} (inferred)",
|
|
264
|
+
asset_class=first.asset_class,
|
|
265
|
+
provider_symbols={provider: resolved.symbol for provider, resolved in matrix.items()},
|
|
266
|
+
notes="Inferred from symbol pattern. Verify exchange/provider entitlement before relying on it.",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
165
270
|
def _normalize(symbol: str) -> str:
|
|
166
271
|
return symbol.strip().upper().replace(" ", "").replace("-", "").replace("_", "").replace("/", "")
|
|
167
272
|
|
|
@@ -180,3 +285,41 @@ def _alias_class(symbol: str) -> str:
|
|
|
180
285
|
if symbol.startswith("XAU") or symbol.startswith("XAG"):
|
|
181
286
|
return "commodity"
|
|
182
287
|
return "index"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _symbol_alias_to_result(item: SymbolAlias) -> SymbolSearchResult:
|
|
291
|
+
matrix = provider_symbol_matrix(item.symbol)
|
|
292
|
+
return SymbolSearchResult(
|
|
293
|
+
symbol=item.symbol,
|
|
294
|
+
name=item.name,
|
|
295
|
+
asset_class=item.asset_class,
|
|
296
|
+
exchange=item.exchange,
|
|
297
|
+
currency=item.currency,
|
|
298
|
+
provider_symbols={provider: resolved.symbol for provider, resolved in matrix.items()},
|
|
299
|
+
notes=item.notes,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _match_score(query: str, haystack: list[str]) -> int:
|
|
304
|
+
score = 0
|
|
305
|
+
for value in haystack:
|
|
306
|
+
if value == query:
|
|
307
|
+
score = max(score, 100)
|
|
308
|
+
elif value.startswith(query):
|
|
309
|
+
score = max(score, 80)
|
|
310
|
+
elif query in value:
|
|
311
|
+
score = max(score, 50)
|
|
312
|
+
return score
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _infer_asset_class(symbol: str) -> str:
|
|
316
|
+
normalized = _normalize(symbol)
|
|
317
|
+
if _is_metal_pair(normalized):
|
|
318
|
+
return "commodity"
|
|
319
|
+
if _is_forex_pair(normalized):
|
|
320
|
+
return "forex"
|
|
321
|
+
if normalized.endswith("USDT") or normalized.endswith("USD") and normalized[:3] in {"BTC", "ETH", "SOL", "BNB"}:
|
|
322
|
+
return "crypto"
|
|
323
|
+
if normalized.startswith("^") or normalized in YFINANCE_ALIASES:
|
|
324
|
+
return "index"
|
|
325
|
+
return "stock"
|
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
"""Twelve Data provider for multi-asset market data."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from datetime import datetime
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
import httpx
|
|
9
|
-
|
|
10
|
-
from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
|
|
11
|
-
from fincli.app.providers.market.symbols import resolve_twelvedata_symbol
|
|
12
|
-
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class TwelveDataProvider:
|
|
16
|
-
name = "twelvedata"
|
|
17
|
-
|
|
18
|
-
def __init__(
|
|
19
|
-
self,
|
|
20
|
-
api_key: str | None,
|
|
21
|
-
base_url: str = "https://api.twelvedata.com",
|
|
22
|
-
client: httpx.AsyncClient | None = None,
|
|
23
|
-
) -> None:
|
|
24
|
-
self.api_key = api_key or ""
|
|
25
|
-
self.base_url = base_url.rstrip("/")
|
|
26
|
-
self._client = client
|
|
27
|
-
|
|
28
|
-
async def quote(self, symbol: str) -> Quote:
|
|
29
|
-
resolved = resolve_twelvedata_symbol(symbol)
|
|
30
|
-
data = await self._get("/quote", {"symbol": resolved.symbol})
|
|
31
|
-
price = _safe_float(data.get("close") or data.get("price"))
|
|
32
|
-
if price is None:
|
|
33
|
-
raise ProviderError(f"Twelve Data tidak mengembalikan quote valid untuk {symbol}.")
|
|
34
|
-
return Quote(
|
|
35
|
-
symbol=str(data.get("symbol") or resolved.symbol).upper(),
|
|
36
|
-
price=price,
|
|
37
|
-
currency=str(data.get("currency") or "USD"),
|
|
38
|
-
provider=self.name,
|
|
39
|
-
timestamp=_parse_datetime(data.get("datetime")) or datetime.now(),
|
|
40
|
-
status="realtime",
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
|
|
44
|
-
resolved = resolve_twelvedata_symbol(symbol)
|
|
45
|
-
data = await self._get(
|
|
46
|
-
"/time_series",
|
|
47
|
-
{
|
|
48
|
-
"symbol": resolved.symbol,
|
|
49
|
-
"interval": _interval_to_twelvedata(interval),
|
|
50
|
-
"outputsize": _period_to_outputsize(period, interval),
|
|
51
|
-
"timezone": "UTC",
|
|
52
|
-
},
|
|
53
|
-
)
|
|
54
|
-
values = data.get("values") if isinstance(data, dict) else None
|
|
55
|
-
if not isinstance(values, list) or not values:
|
|
56
|
-
message = data.get("message") if isinstance(data, dict) else None
|
|
57
|
-
raise ProviderError(f"Twelve Data OHLCV kosong untuk {symbol}.", str(message) if message else None)
|
|
58
|
-
|
|
59
|
-
candles = [_parse_candle(item) for item in values if isinstance(item, dict)]
|
|
60
|
-
candles.sort(key=lambda candle: candle.timestamp)
|
|
61
|
-
if not candles:
|
|
62
|
-
raise ProviderError(f"Twelve Data OHLCV kosong untuk {symbol}.")
|
|
63
|
-
return candles
|
|
64
|
-
|
|
65
|
-
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
66
|
-
return []
|
|
67
|
-
|
|
68
|
-
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
69
|
-
resolved = resolve_twelvedata_symbol(symbol)
|
|
70
|
-
return FundamentalSnapshot(symbol=resolved.symbol.upper(), provider=self.name, currency="USD")
|
|
71
|
-
|
|
72
|
-
async def status(self) -> ProviderStatus:
|
|
73
|
-
status = "configured" if self.api_key else "unavailable"
|
|
74
|
-
message = "Twelve Data provider configured." if self.api_key else "Requires TWELVE_DATA_API_KEY."
|
|
75
|
-
return ProviderStatus(name=self.name, realtime=True, status=status, message=message)
|
|
76
|
-
|
|
77
|
-
async def _get(self, path: str, params: dict[str, object]) -> Any:
|
|
78
|
-
if not self.api_key:
|
|
79
|
-
raise ProviderError("API key Twelve Data belum diatur.", "Gunakan /news_model key twelvedata <api_key>.")
|
|
80
|
-
close_client = self._client is None
|
|
81
|
-
client = self._client or httpx.AsyncClient(timeout=30)
|
|
82
|
-
try:
|
|
83
|
-
response = await client.get(f"{self.base_url}{path}", params={**params, "apikey": self.api_key})
|
|
84
|
-
if response.status_code == 429:
|
|
85
|
-
raise RateLimitError("Twelve Data terkena rate limit.")
|
|
86
|
-
response.raise_for_status()
|
|
87
|
-
data = response.json()
|
|
88
|
-
if isinstance(data, dict) and data.get("status") == "error":
|
|
89
|
-
raise ProviderError(f"Twelve Data gagal: {data.get('message') or 'unknown error'}")
|
|
90
|
-
return data
|
|
91
|
-
except httpx.TimeoutException as exc:
|
|
92
|
-
raise ProviderError("Twelve Data timeout.") from exc
|
|
93
|
-
except httpx.HTTPStatusError as exc:
|
|
94
|
-
raise ProviderError(f"Twelve Data gagal: HTTP {exc.response.status_code}.") from exc
|
|
95
|
-
except ValueError as exc:
|
|
96
|
-
raise ProviderError("Response Twelve Data bukan JSON valid.") from exc
|
|
97
|
-
finally:
|
|
98
|
-
if close_client:
|
|
99
|
-
await client.aclose()
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _parse_candle(item: dict[str, Any]) -> Candle:
|
|
103
|
-
return Candle(
|
|
104
|
-
timestamp=_parse_datetime(item.get("datetime")) or datetime.now(),
|
|
105
|
-
open=float(item["open"]),
|
|
106
|
-
high=float(item["high"]),
|
|
107
|
-
low=float(item["low"]),
|
|
108
|
-
close=float(item["close"]),
|
|
109
|
-
volume=float(item.get("volume") or 0),
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _safe_float(value: Any) -> float | None:
|
|
114
|
-
try:
|
|
115
|
-
if value is None:
|
|
116
|
-
return None
|
|
117
|
-
return float(value)
|
|
118
|
-
except (TypeError, ValueError):
|
|
119
|
-
return None
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _parse_datetime(value: object) -> datetime | None:
|
|
123
|
-
if not value:
|
|
124
|
-
return None
|
|
125
|
-
text = str(value).replace("Z", "+00:00")
|
|
126
|
-
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
127
|
-
try:
|
|
128
|
-
return datetime.strptime(text, fmt)
|
|
129
|
-
except ValueError:
|
|
130
|
-
continue
|
|
131
|
-
try:
|
|
132
|
-
return datetime.fromisoformat(text)
|
|
133
|
-
except ValueError:
|
|
134
|
-
return None
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _interval_to_twelvedata(interval: str) -> str:
|
|
138
|
-
mapping = {
|
|
139
|
-
"1m": "1min",
|
|
140
|
-
"5m": "5min",
|
|
141
|
-
"15m": "15min",
|
|
142
|
-
"30m": "30min",
|
|
143
|
-
"1h": "1h",
|
|
144
|
-
"4h": "4h",
|
|
145
|
-
"1d": "1day",
|
|
146
|
-
"d": "1day",
|
|
147
|
-
"1w": "1week",
|
|
148
|
-
"w": "1week",
|
|
149
|
-
"1mo": "1month",
|
|
150
|
-
}
|
|
151
|
-
return mapping.get(interval.lower(), interval)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _period_to_outputsize(period: str, interval: str) -> int:
|
|
155
|
-
normalized = period.lower()
|
|
156
|
-
interval_normalized = interval.lower()
|
|
157
|
-
if normalized.endswith("mo"):
|
|
158
|
-
days = 30 * int(normalized[:-2] or 6)
|
|
159
|
-
elif normalized.endswith("y"):
|
|
160
|
-
days = 365 * int(normalized[:-1] or 1)
|
|
161
|
-
elif normalized.endswith("d"):
|
|
162
|
-
days = int(normalized[:-1] or 180)
|
|
163
|
-
else:
|
|
164
|
-
days = 180
|
|
165
|
-
if interval_normalized in {"1m", "5m", "15m", "30m", "1h", "4h"}:
|
|
166
|
-
return min(5000, max(120, days * 24))
|
|
167
|
-
return min(5000, max(30, days))
|
|
1
|
+
"""Twelve Data provider for multi-asset market data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
|
|
11
|
+
from fincli.app.providers.market.symbols import resolve_twelvedata_symbol
|
|
12
|
+
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TwelveDataProvider:
|
|
16
|
+
name = "twelvedata"
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
api_key: str | None,
|
|
21
|
+
base_url: str = "https://api.twelvedata.com",
|
|
22
|
+
client: httpx.AsyncClient | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.api_key = api_key or ""
|
|
25
|
+
self.base_url = base_url.rstrip("/")
|
|
26
|
+
self._client = client
|
|
27
|
+
|
|
28
|
+
async def quote(self, symbol: str) -> Quote:
|
|
29
|
+
resolved = resolve_twelvedata_symbol(symbol)
|
|
30
|
+
data = await self._get("/quote", {"symbol": resolved.symbol})
|
|
31
|
+
price = _safe_float(data.get("close") or data.get("price"))
|
|
32
|
+
if price is None:
|
|
33
|
+
raise ProviderError(f"Twelve Data tidak mengembalikan quote valid untuk {symbol}.")
|
|
34
|
+
return Quote(
|
|
35
|
+
symbol=str(data.get("symbol") or resolved.symbol).upper(),
|
|
36
|
+
price=price,
|
|
37
|
+
currency=str(data.get("currency") or "USD"),
|
|
38
|
+
provider=self.name,
|
|
39
|
+
timestamp=_parse_datetime(data.get("datetime")) or datetime.now(),
|
|
40
|
+
status="realtime",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
|
|
44
|
+
resolved = resolve_twelvedata_symbol(symbol)
|
|
45
|
+
data = await self._get(
|
|
46
|
+
"/time_series",
|
|
47
|
+
{
|
|
48
|
+
"symbol": resolved.symbol,
|
|
49
|
+
"interval": _interval_to_twelvedata(interval),
|
|
50
|
+
"outputsize": _period_to_outputsize(period, interval),
|
|
51
|
+
"timezone": "UTC",
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
values = data.get("values") if isinstance(data, dict) else None
|
|
55
|
+
if not isinstance(values, list) or not values:
|
|
56
|
+
message = data.get("message") if isinstance(data, dict) else None
|
|
57
|
+
raise ProviderError(f"Twelve Data OHLCV kosong untuk {symbol}.", str(message) if message else None)
|
|
58
|
+
|
|
59
|
+
candles = [_parse_candle(item) for item in values if isinstance(item, dict)]
|
|
60
|
+
candles.sort(key=lambda candle: candle.timestamp)
|
|
61
|
+
if not candles:
|
|
62
|
+
raise ProviderError(f"Twelve Data OHLCV kosong untuk {symbol}.")
|
|
63
|
+
return candles
|
|
64
|
+
|
|
65
|
+
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
69
|
+
resolved = resolve_twelvedata_symbol(symbol)
|
|
70
|
+
return FundamentalSnapshot(symbol=resolved.symbol.upper(), provider=self.name, currency="USD")
|
|
71
|
+
|
|
72
|
+
async def status(self) -> ProviderStatus:
|
|
73
|
+
status = "configured" if self.api_key else "unavailable"
|
|
74
|
+
message = "Twelve Data provider configured." if self.api_key else "Requires TWELVE_DATA_API_KEY."
|
|
75
|
+
return ProviderStatus(name=self.name, realtime=True, status=status, message=message)
|
|
76
|
+
|
|
77
|
+
async def _get(self, path: str, params: dict[str, object]) -> Any:
|
|
78
|
+
if not self.api_key:
|
|
79
|
+
raise ProviderError("API key Twelve Data belum diatur.", "Gunakan /news_model key twelvedata <api_key>.")
|
|
80
|
+
close_client = self._client is None
|
|
81
|
+
client = self._client or httpx.AsyncClient(timeout=30)
|
|
82
|
+
try:
|
|
83
|
+
response = await client.get(f"{self.base_url}{path}", params={**params, "apikey": self.api_key})
|
|
84
|
+
if response.status_code == 429:
|
|
85
|
+
raise RateLimitError("Twelve Data terkena rate limit.")
|
|
86
|
+
response.raise_for_status()
|
|
87
|
+
data = response.json()
|
|
88
|
+
if isinstance(data, dict) and data.get("status") == "error":
|
|
89
|
+
raise ProviderError(f"Twelve Data gagal: {data.get('message') or 'unknown error'}")
|
|
90
|
+
return data
|
|
91
|
+
except httpx.TimeoutException as exc:
|
|
92
|
+
raise ProviderError("Twelve Data timeout.") from exc
|
|
93
|
+
except httpx.HTTPStatusError as exc:
|
|
94
|
+
raise ProviderError(f"Twelve Data gagal: HTTP {exc.response.status_code}.") from exc
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise ProviderError("Response Twelve Data bukan JSON valid.") from exc
|
|
97
|
+
finally:
|
|
98
|
+
if close_client:
|
|
99
|
+
await client.aclose()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_candle(item: dict[str, Any]) -> Candle:
|
|
103
|
+
return Candle(
|
|
104
|
+
timestamp=_parse_datetime(item.get("datetime")) or datetime.now(),
|
|
105
|
+
open=float(item["open"]),
|
|
106
|
+
high=float(item["high"]),
|
|
107
|
+
low=float(item["low"]),
|
|
108
|
+
close=float(item["close"]),
|
|
109
|
+
volume=float(item.get("volume") or 0),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _safe_float(value: Any) -> float | None:
|
|
114
|
+
try:
|
|
115
|
+
if value is None:
|
|
116
|
+
return None
|
|
117
|
+
return float(value)
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_datetime(value: object) -> datetime | None:
|
|
123
|
+
if not value:
|
|
124
|
+
return None
|
|
125
|
+
text = str(value).replace("Z", "+00:00")
|
|
126
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
127
|
+
try:
|
|
128
|
+
return datetime.strptime(text, fmt)
|
|
129
|
+
except ValueError:
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
return datetime.fromisoformat(text)
|
|
133
|
+
except ValueError:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _interval_to_twelvedata(interval: str) -> str:
|
|
138
|
+
mapping = {
|
|
139
|
+
"1m": "1min",
|
|
140
|
+
"5m": "5min",
|
|
141
|
+
"15m": "15min",
|
|
142
|
+
"30m": "30min",
|
|
143
|
+
"1h": "1h",
|
|
144
|
+
"4h": "4h",
|
|
145
|
+
"1d": "1day",
|
|
146
|
+
"d": "1day",
|
|
147
|
+
"1w": "1week",
|
|
148
|
+
"w": "1week",
|
|
149
|
+
"1mo": "1month",
|
|
150
|
+
}
|
|
151
|
+
return mapping.get(interval.lower(), interval)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _period_to_outputsize(period: str, interval: str) -> int:
|
|
155
|
+
normalized = period.lower()
|
|
156
|
+
interval_normalized = interval.lower()
|
|
157
|
+
if normalized.endswith("mo"):
|
|
158
|
+
days = 30 * int(normalized[:-2] or 6)
|
|
159
|
+
elif normalized.endswith("y"):
|
|
160
|
+
days = 365 * int(normalized[:-1] or 1)
|
|
161
|
+
elif normalized.endswith("d"):
|
|
162
|
+
days = int(normalized[:-1] or 180)
|
|
163
|
+
else:
|
|
164
|
+
days = 180
|
|
165
|
+
if interval_normalized in {"1m", "5m", "15m", "30m", "1h", "4h"}:
|
|
166
|
+
return min(5000, max(120, days * 24))
|
|
167
|
+
return min(5000, max(30, days))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Research workspace package."""
|
|
2
|
+
|
|
3
|
+
from fincli.app.research.engine import ResearchEngine
|
|
4
|
+
from fincli.app.research.formatter import format_research_brief
|
|
5
|
+
from fincli.app.research.models import ResearchBrief
|
|
6
|
+
|
|
7
|
+
__all__ = ["ResearchBrief", "ResearchEngine", "format_research_brief"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Research workspace orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fincli.app.providers.ai.base import AIRequest, BaseAIProvider
|
|
6
|
+
from fincli.app.research.models import ResearchBrief
|
|
7
|
+
from fincli.app.research.prompt_builder import build_research_prompt
|
|
8
|
+
from fincli.app.services.market_data import MarketDataService
|
|
9
|
+
from fincli.app.services.market_overview import MarketOverview, build_market_overview
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResearchEngine:
|
|
13
|
+
"""Build compact research briefs around the existing market overview service."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, market_service: MarketDataService, ai_provider: BaseAIProvider | None = None, model: str = "") -> None:
|
|
16
|
+
self.market_service = market_service
|
|
17
|
+
self.ai_provider = ai_provider
|
|
18
|
+
self.model = model
|
|
19
|
+
|
|
20
|
+
async def build(self, symbol: str, timeframe: str = "1d", mode: str = "quick") -> ResearchBrief:
|
|
21
|
+
overview = await build_market_overview(symbol.upper(), self.market_service, timeframe)
|
|
22
|
+
brief = _brief_from_overview(overview, mode)
|
|
23
|
+
if mode == "deep" and self.ai_provider is not None:
|
|
24
|
+
prompt = build_research_prompt(brief)
|
|
25
|
+
response = await self.ai_provider.complete(AIRequest(prompt=prompt, model=self.model))
|
|
26
|
+
return ResearchBrief(
|
|
27
|
+
symbol=brief.symbol,
|
|
28
|
+
mode=brief.mode,
|
|
29
|
+
overview=brief.overview,
|
|
30
|
+
decision_points=brief.decision_points,
|
|
31
|
+
risks=brief.risks,
|
|
32
|
+
final_summary=brief.final_summary,
|
|
33
|
+
ai_summary=response.content,
|
|
34
|
+
)
|
|
35
|
+
return brief
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _brief_from_overview(overview: MarketOverview, mode: str) -> ResearchBrief:
|
|
39
|
+
technical = overview.technical
|
|
40
|
+
structure = overview.structure
|
|
41
|
+
fundamentals = overview.fundamentals
|
|
42
|
+
decision_points = [
|
|
43
|
+
f"Price {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status}).",
|
|
44
|
+
f"Trend bias {technical.trend_bias}; structure {structure.trend} with {structure.latest_pattern}.",
|
|
45
|
+
f"Key levels: support {technical.support}, resistance {technical.resistance}, ATR {technical.atr}.",
|
|
46
|
+
f"Momentum: RSI {technical.rsi}, MACD {technical.macd}/{technical.macd_signal}.",
|
|
47
|
+
]
|
|
48
|
+
if fundamentals is not None:
|
|
49
|
+
decision_points.append(
|
|
50
|
+
f"Fundamentals: P/E {fundamentals.pe_ratio}, EPS {fundamentals.eps}, sector {fundamentals.sector or 'N/A'}."
|
|
51
|
+
)
|
|
52
|
+
if overview.news:
|
|
53
|
+
decision_points.append(f"Latest news: {overview.news[0].title} ({overview.news[0].source}).")
|
|
54
|
+
|
|
55
|
+
risks = [
|
|
56
|
+
f"Data quality {overview.data_quality.score}/100; provider label {overview.data_quality.provider}.",
|
|
57
|
+
"Use confirmation and invalidation; do not treat this brief as financial advice.",
|
|
58
|
+
]
|
|
59
|
+
if structure.change_of_character:
|
|
60
|
+
risks.append("Change of character detected; directional confidence should be reduced.")
|
|
61
|
+
if technical.rsi is not None and (technical.rsi > 75 or technical.rsi < 25):
|
|
62
|
+
risks.append("RSI is at an extreme; avoid chasing without confirmation.")
|
|
63
|
+
|
|
64
|
+
final_summary = (
|
|
65
|
+
f"{overview.symbol} is a {technical.trend_bias} / {structure.trend} setup with "
|
|
66
|
+
f"{overview.data_quality.score}/100 data quality. Focus on support/resistance reaction and news/fundamental confirmation."
|
|
67
|
+
)
|
|
68
|
+
return ResearchBrief(
|
|
69
|
+
symbol=overview.symbol,
|
|
70
|
+
mode=mode,
|
|
71
|
+
overview=overview,
|
|
72
|
+
decision_points=decision_points[:6],
|
|
73
|
+
risks=risks[:4],
|
|
74
|
+
final_summary=final_summary,
|
|
75
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Rich renderers for research workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from fincli.app.research.models import ResearchBrief
|
|
8
|
+
from fincli.app.utils.formatting import semantic_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_research_brief(brief: ResearchBrief) -> Table:
|
|
12
|
+
table = Table(title=f"Research Brief: {brief.symbol} | {brief.mode}", expand=True)
|
|
13
|
+
table.add_column("Section", style="cyan", no_wrap=True)
|
|
14
|
+
table.add_column("Description", overflow="fold")
|
|
15
|
+
table.add_row("Data Quality", semantic_text(f"{brief.overview.data_quality.score}/100 | {brief.overview.data_quality.provider}"))
|
|
16
|
+
table.add_row("Decision Points", semantic_text("\n".join(f"- {point}" for point in brief.decision_points)))
|
|
17
|
+
table.add_row("Risks", semantic_text("\n".join(f"- {risk}" for risk in brief.risks)))
|
|
18
|
+
if brief.ai_summary:
|
|
19
|
+
table.add_row("AI Summary", brief.ai_summary)
|
|
20
|
+
table.add_row("Final Summary", semantic_text(brief.final_summary))
|
|
21
|
+
table.caption = "Research output is informational only, not financial advice."
|
|
22
|
+
return table
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Models for FinCLI research workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fincli.app.services.market_overview import MarketOverview
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class ResearchBrief:
|
|
12
|
+
symbol: str
|
|
13
|
+
mode: str
|
|
14
|
+
overview: MarketOverview
|
|
15
|
+
decision_points: list[str]
|
|
16
|
+
risks: list[str]
|
|
17
|
+
final_summary: str
|
|
18
|
+
ai_summary: str = ""
|