@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.
Files changed (69) hide show
  1. package/README.md +644 -0
  2. package/fincli/__init__.py +3 -0
  3. package/fincli/app/__init__.py +1 -0
  4. package/fincli/app/analysis/__init__.py +1 -0
  5. package/fincli/app/analysis/ai_prompts.py +33 -0
  6. package/fincli/app/analysis/analyzer.py +119 -0
  7. package/fincli/app/analysis/assistant_context.py +161 -0
  8. package/fincli/app/analysis/indicators.py +143 -0
  9. package/fincli/app/analysis/market_structure.py +106 -0
  10. package/fincli/app/analysis/technical_debate.py +251 -0
  11. package/fincli/app/analysis/technical_signal.py +203 -0
  12. package/fincli/app/cli/__init__.py +1 -0
  13. package/fincli/app/cli/autocomplete.py +17 -0
  14. package/fincli/app/cli/commands.py +82 -0
  15. package/fincli/app/cli/router.py +1257 -0
  16. package/fincli/app/main.py +16 -0
  17. package/fincli/app/modules/__init__.py +1 -0
  18. package/fincli/app/modules/economic_calendar.py +139 -0
  19. package/fincli/app/modules/exporter.py +51 -0
  20. package/fincli/app/modules/journal.py +65 -0
  21. package/fincli/app/modules/journal_analytics.py +70 -0
  22. package/fincli/app/modules/portfolio.py +34 -0
  23. package/fincli/app/modules/scanner.py +105 -0
  24. package/fincli/app/modules/transactions.py +84 -0
  25. package/fincli/app/modules/watchlist.py +25 -0
  26. package/fincli/app/providers/__init__.py +1 -0
  27. package/fincli/app/providers/ai/__init__.py +1 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +11 -0
  29. package/fincli/app/providers/ai/base.py +29 -0
  30. package/fincli/app/providers/ai/gemini_provider.py +11 -0
  31. package/fincli/app/providers/ai/groq_provider.py +11 -0
  32. package/fincli/app/providers/ai/http_provider.py +145 -0
  33. package/fincli/app/providers/ai/huggingface_provider.py +11 -0
  34. package/fincli/app/providers/ai/manager.py +60 -0
  35. package/fincli/app/providers/ai/openai_provider.py +11 -0
  36. package/fincli/app/providers/ai/openrouter_provider.py +11 -0
  37. package/fincli/app/providers/ai/together_provider.py +11 -0
  38. package/fincli/app/providers/market/__init__.py +1 -0
  39. package/fincli/app/providers/market/base.py +77 -0
  40. package/fincli/app/providers/market/custom_provider.py +169 -0
  41. package/fincli/app/providers/market/finnhub_provider.py +187 -0
  42. package/fincli/app/providers/market/manager.py +123 -0
  43. package/fincli/app/providers/market/news_provider.py +28 -0
  44. package/fincli/app/providers/market/symbols.py +182 -0
  45. package/fincli/app/providers/market/twelvedata_provider.py +167 -0
  46. package/fincli/app/providers/market/yfinance_provider.py +447 -0
  47. package/fincli/app/services/__init__.py +1 -0
  48. package/fincli/app/services/market_data.py +203 -0
  49. package/fincli/app/services/market_overview.py +111 -0
  50. package/fincli/app/storage/__init__.py +1 -0
  51. package/fincli/app/storage/cache.py +38 -0
  52. package/fincli/app/storage/config.py +114 -0
  53. package/fincli/app/storage/database.py +101 -0
  54. package/fincli/app/storage/market_cache.py +92 -0
  55. package/fincli/app/tui/__init__.py +1 -0
  56. package/fincli/app/tui/components.py +55 -0
  57. package/fincli/app/tui/layout.py +261 -0
  58. package/fincli/app/tui/market_provider_selector.py +267 -0
  59. package/fincli/app/tui/model_selector.py +412 -0
  60. package/fincli/app/tui/theme.py +157 -0
  61. package/fincli/app/utils/__init__.py +1 -0
  62. package/fincli/app/utils/errors.py +33 -0
  63. package/fincli/app/utils/formatting.py +17 -0
  64. package/fincli/app/utils/logger.py +19 -0
  65. package/npm/bin/fincli.js +35 -0
  66. package/npm/postinstall.js +72 -0
  67. package/package.json +23 -0
  68. package/pyproject.toml +31 -0
  69. package/requirements.txt +9 -0
@@ -0,0 +1,187 @@
1
+ """Finnhub market provider.
2
+
3
+ Finnhub supports real-time REST/WebSocket APIs for stocks, forex, and crypto,
4
+ plus company fundamentals/news depending on endpoint and plan. This provider
5
+ implements the endpoints FinCLI needs for stock-style symbols first:
6
+ - /quote
7
+ - /stock/candle
8
+ - /company-news
9
+ - /stock/profile2
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime, timedelta
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
20
+ from fincli.app.providers.market.symbols import resolve_finnhub_symbol
21
+ from fincli.app.utils.errors import ProviderError, RateLimitError
22
+
23
+
24
+ class FinnhubProvider:
25
+ name = "finnhub"
26
+
27
+ def __init__(
28
+ self,
29
+ api_key: str | None,
30
+ base_url: str = "https://finnhub.io/api/v1",
31
+ client: httpx.AsyncClient | None = None,
32
+ ) -> None:
33
+ self.api_key = api_key or ""
34
+ self.base_url = base_url.rstrip("/")
35
+ self._client = client
36
+
37
+ async def quote(self, symbol: str) -> Quote:
38
+ resolved = resolve_finnhub_symbol(symbol)
39
+ if resolved.asset_class in {"forex", "crypto"}:
40
+ candles = await self.history(symbol, period="5d", interval="1d")
41
+ if not candles:
42
+ raise ProviderError(f"Finnhub tidak mengembalikan harga valid untuk {symbol}.")
43
+ latest = candles[-1]
44
+ return Quote(
45
+ symbol=resolved.symbol,
46
+ price=latest.close,
47
+ currency="USD",
48
+ provider=self.name,
49
+ timestamp=latest.timestamp,
50
+ status="delayed",
51
+ )
52
+ data = await self._get("/quote", {"symbol": symbol.upper()})
53
+ price = _safe_float(data.get("c"))
54
+ if price is None or price == 0:
55
+ raise ProviderError(f"Finnhub tidak mengembalikan harga valid untuk {symbol}.")
56
+ return Quote(
57
+ symbol=symbol.upper(),
58
+ price=price,
59
+ currency="USD",
60
+ provider=self.name,
61
+ timestamp=datetime.fromtimestamp(int(data.get("t") or datetime.now().timestamp())),
62
+ status="realtime",
63
+ )
64
+
65
+ async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
66
+ resolved = resolve_finnhub_symbol(symbol)
67
+ now = datetime.now()
68
+ start = now - _period_to_delta(period)
69
+ path = {
70
+ "forex": "/forex/candle",
71
+ "crypto": "/crypto/candle",
72
+ }.get(resolved.asset_class, "/stock/candle")
73
+ data = await self._get(
74
+ path,
75
+ {
76
+ "symbol": resolved.symbol,
77
+ "resolution": _interval_to_resolution(interval),
78
+ "from": int(start.timestamp()),
79
+ "to": int(now.timestamp()),
80
+ },
81
+ )
82
+ if data.get("s") != "ok":
83
+ raise ProviderError(f"Finnhub candle data kosong untuk {symbol} ({resolved.symbol}).")
84
+ timestamps = data.get("t") or []
85
+ candles = [
86
+ Candle(
87
+ timestamp=datetime.fromtimestamp(int(ts)),
88
+ open=float(data["o"][index]),
89
+ high=float(data["h"][index]),
90
+ low=float(data["l"][index]),
91
+ close=float(data["c"][index]),
92
+ volume=float(data["v"][index]),
93
+ )
94
+ for index, ts in enumerate(timestamps)
95
+ ]
96
+ if not candles:
97
+ raise ProviderError(f"Finnhub candle data kosong untuk {symbol} ({resolved.symbol}).")
98
+ return candles
99
+
100
+ async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
101
+ today = datetime.now().date()
102
+ start = today - timedelta(days=14)
103
+ data = await self._get(
104
+ "/company-news",
105
+ {"symbol": symbol.upper(), "from": start.isoformat(), "to": today.isoformat()},
106
+ )
107
+ if not isinstance(data, list):
108
+ raise ProviderError("Response Finnhub news tidak valid.")
109
+ items: list[NewsItem] = []
110
+ for item in data[:limit]:
111
+ if not isinstance(item, dict):
112
+ continue
113
+ timestamp = item.get("datetime")
114
+ published_at = datetime.fromtimestamp(timestamp) if isinstance(timestamp, int) else None
115
+ items.append(
116
+ NewsItem(
117
+ title=str(item.get("headline") or item.get("title") or "Untitled"),
118
+ source=str(item.get("source") or self.name),
119
+ url=item.get("url"),
120
+ published_at=published_at,
121
+ summary=str(item.get("summary") or ""),
122
+ )
123
+ )
124
+ return items
125
+
126
+ async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
127
+ data = await self._get("/stock/profile2", {"symbol": symbol.upper()})
128
+ return FundamentalSnapshot(
129
+ symbol=str(data.get("ticker") or symbol).upper(),
130
+ provider=self.name,
131
+ currency=str(data.get("currency") or "USD"),
132
+ market_cap=_safe_float(data.get("marketCapitalization")),
133
+ sector=data.get("finnhubIndustry"),
134
+ industry=data.get("finnhubIndustry"),
135
+ )
136
+
137
+ async def status(self) -> ProviderStatus:
138
+ status = "configured" if self.api_key else "unavailable"
139
+ message = "Finnhub provider configured." if self.api_key else "Requires FINNHUB_API_KEY."
140
+ return ProviderStatus(name=self.name, realtime=True, status=status, message=message)
141
+
142
+ async def _get(self, path: str, params: dict[str, object]) -> Any:
143
+ if not self.api_key:
144
+ raise ProviderError("API key Finnhub belum diatur.", "Isi FINNHUB_API_KEY di .env.")
145
+ close_client = self._client is None
146
+ client = self._client or httpx.AsyncClient(timeout=30)
147
+ query = {**params, "token": self.api_key}
148
+ try:
149
+ response = await client.get(f"{self.base_url}{path}", params=query)
150
+ if response.status_code == 429:
151
+ raise RateLimitError("Finnhub terkena rate limit.")
152
+ response.raise_for_status()
153
+ return response.json()
154
+ except httpx.TimeoutException as exc:
155
+ raise ProviderError("Finnhub timeout.") from exc
156
+ except httpx.HTTPStatusError as exc:
157
+ raise ProviderError(f"Finnhub gagal: HTTP {exc.response.status_code}.") from exc
158
+ except ValueError as exc:
159
+ raise ProviderError("Response Finnhub bukan JSON valid.") from exc
160
+ finally:
161
+ if close_client:
162
+ await client.aclose()
163
+
164
+
165
+ def _safe_float(value: Any) -> float | None:
166
+ try:
167
+ if value is None:
168
+ return None
169
+ return float(value)
170
+ except (TypeError, ValueError):
171
+ return None
172
+
173
+
174
+ def _period_to_delta(period: str) -> timedelta:
175
+ normalized = period.lower()
176
+ if normalized.endswith("mo"):
177
+ return timedelta(days=30 * int(normalized[:-2] or 6))
178
+ if normalized.endswith("y"):
179
+ return timedelta(days=365 * int(normalized[:-1] or 1))
180
+ if normalized.endswith("d"):
181
+ return timedelta(days=int(normalized[:-1] or 30))
182
+ return timedelta(days=180)
183
+
184
+
185
+ def _interval_to_resolution(interval: str) -> str:
186
+ mapping = {"1d": "D", "d": "D", "1w": "W", "w": "W", "1m": "M"}
187
+ return mapping.get(interval.lower(), interval)
@@ -0,0 +1,123 @@
1
+ """Market/news provider catalog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+
8
+ from fincli.app.providers.market.base import BaseMarketProvider
9
+ from fincli.app.providers.market.custom_provider import CustomMarketProvider
10
+ from fincli.app.providers.market.finnhub_provider import FinnhubProvider
11
+ from fincli.app.providers.market.twelvedata_provider import TwelveDataProvider
12
+ from fincli.app.providers.market.yfinance_provider import YFinanceProvider
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class MarketProviderInfo:
17
+ name: str
18
+ realtime: bool
19
+ status: str
20
+ notes: str
21
+
22
+
23
+ MARKET_PROVIDERS: dict[str, MarketProviderInfo] = {
24
+ "yfinance": MarketProviderInfo(
25
+ name="yfinance",
26
+ realtime=False,
27
+ status="fallback",
28
+ notes="Fallback delayed data. No API key required.",
29
+ ),
30
+ "custom": MarketProviderInfo(
31
+ name="custom",
32
+ realtime=True,
33
+ status="configured",
34
+ notes="Custom API provider scaffold. Requires MARKET_DATA_API_KEY.",
35
+ ),
36
+ "finnhub": MarketProviderInfo(
37
+ name="finnhub",
38
+ realtime=True,
39
+ status="configured",
40
+ notes="Finnhub REST provider. Requires FINNHUB_API_KEY.",
41
+ ),
42
+ "twelvedata": MarketProviderInfo(
43
+ name="twelvedata",
44
+ realtime=True,
45
+ status="configured",
46
+ notes="Multi-asset provider for stocks, forex, ETFs, indices, commodities, and crypto. Requires TWELVE_DATA_API_KEY.",
47
+ ),
48
+ }
49
+
50
+
51
+ class MarketProviderManager:
52
+ """Market provider catalog and factory."""
53
+
54
+ def list_providers(self) -> list[MarketProviderInfo]:
55
+ return list(MARKET_PROVIDERS.values())
56
+
57
+ def get(self, name: str) -> MarketProviderInfo | None:
58
+ return MARKET_PROVIDERS.get(name.lower())
59
+
60
+ def create(self, name: str) -> BaseMarketProvider:
61
+ provider_name = name.lower()
62
+ if provider_name == "yfinance":
63
+ return YFinanceProvider()
64
+ if provider_name == "custom":
65
+ return CustomMarketProvider(
66
+ api_key=os.getenv("MARKET_DATA_API_KEY"),
67
+ base_url=os.getenv("MARKET_DATA_BASE_URL", ""),
68
+ )
69
+ if provider_name == "finnhub":
70
+ return FinnhubProvider(api_key=os.getenv("FINNHUB_API_KEY"))
71
+ if provider_name == "twelvedata":
72
+ return TwelveDataProvider(api_key=os.getenv("TWELVE_DATA_API_KEY"))
73
+ raise ValueError(f"Market provider tidak dikenal: {name}")
74
+
75
+ def create_many(self, names: list[str]) -> list[BaseMarketProvider]:
76
+ providers: list[BaseMarketProvider] = []
77
+ seen: set[str] = set()
78
+ for name in names:
79
+ normalized = name.lower().strip()
80
+ if not normalized or normalized in seen:
81
+ continue
82
+ providers.append(self.create(normalized))
83
+ seen.add(normalized)
84
+ if not providers:
85
+ providers.append(self.create("yfinance"))
86
+ return providers
87
+
88
+ def key_status(self) -> list[dict[str, str]]:
89
+ return [
90
+ {"provider": "yfinance", "key": "-", "status": "not required", "source": "-"},
91
+ {
92
+ "provider": "custom",
93
+ "key": "MARKET_DATA_API_KEY",
94
+ "status": _mask_status(os.getenv("MARKET_DATA_API_KEY")),
95
+ "source": ".env" if os.getenv("MARKET_DATA_API_KEY") else "-",
96
+ },
97
+ {
98
+ "provider": "custom",
99
+ "key": "MARKET_DATA_BASE_URL",
100
+ "status": _mask_status(os.getenv("MARKET_DATA_BASE_URL")),
101
+ "source": ".env" if os.getenv("MARKET_DATA_BASE_URL") else "-",
102
+ },
103
+ {
104
+ "provider": "finnhub",
105
+ "key": "FINNHUB_API_KEY",
106
+ "status": _mask_status(os.getenv("FINNHUB_API_KEY")),
107
+ "source": ".env" if os.getenv("FINNHUB_API_KEY") else "-",
108
+ },
109
+ {
110
+ "provider": "twelvedata",
111
+ "key": "TWELVE_DATA_API_KEY",
112
+ "status": _mask_status(os.getenv("TWELVE_DATA_API_KEY")),
113
+ "source": ".env" if os.getenv("TWELVE_DATA_API_KEY") else "-",
114
+ },
115
+ ]
116
+
117
+
118
+ def _mask_status(value: str | None) -> str:
119
+ if not value:
120
+ return "not set"
121
+ if len(value) <= 8:
122
+ return "set"
123
+ return f"{value[:4]}...{value[-4:]}"
@@ -0,0 +1,28 @@
1
+ """News provider placeholder for Phase 2."""
2
+
3
+ from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
4
+ from fincli.app.utils.errors import ProviderError
5
+
6
+
7
+ class NewsProvider(BaseMarketProvider):
8
+ name = "news"
9
+
10
+ async def quote(self, symbol: str) -> Quote:
11
+ raise ProviderError("News provider tidak menyediakan quote.")
12
+
13
+ async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
14
+ raise ProviderError("News provider tidak menyediakan OHLCV.")
15
+
16
+ async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
17
+ raise ProviderError("News fetching belum diimplementasi di Phase 2.")
18
+
19
+ async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
20
+ raise ProviderError("News provider tidak menyediakan fundamental.")
21
+
22
+ async def status(self) -> ProviderStatus:
23
+ return ProviderStatus(
24
+ name=self.name,
25
+ realtime=False,
26
+ status="configured",
27
+ message="News fetching pending in Phase 2.",
28
+ )
@@ -0,0 +1,182 @@
1
+ """Provider-specific symbol normalization for multi-asset market data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ FOREX_CURRENCIES = {
9
+ "AUD",
10
+ "CAD",
11
+ "CHF",
12
+ "CNH",
13
+ "CNY",
14
+ "EUR",
15
+ "GBP",
16
+ "HKD",
17
+ "JPY",
18
+ "MXN",
19
+ "NOK",
20
+ "NZD",
21
+ "SEK",
22
+ "SGD",
23
+ "USD",
24
+ "ZAR",
25
+ }
26
+
27
+ YFINANCE_ALIASES = {
28
+ "SPX": "^GSPC",
29
+ "SP500": "^GSPC",
30
+ "S&P500": "^GSPC",
31
+ "NASDAQ": "^IXIC",
32
+ "IXIC": "^IXIC",
33
+ "NDX": "^NDX",
34
+ "DOW": "^DJI",
35
+ "DJI": "^DJI",
36
+ "RUSSELL2000": "^RUT",
37
+ "VIX": "^VIX",
38
+ "DAX": "^GDAXI",
39
+ "FTSE": "^FTSE",
40
+ "CAC40": "^FCHI",
41
+ "NIKKEI": "^N225",
42
+ "N225": "^N225",
43
+ "HSI": "^HSI",
44
+ "HANGSENG": "^HSI",
45
+ "STI": "^STI",
46
+ "KOSPI": "^KS11",
47
+ "ASX200": "^AXJO",
48
+ "STOXX50": "^STOXX50E",
49
+ "GOLD": "GC=F",
50
+ "SILVER": "SI=F",
51
+ "WTI": "CL=F",
52
+ "BRENT": "BZ=F",
53
+ "NATGAS": "NG=F",
54
+ "COPPER": "HG=F",
55
+ "CORN": "ZC=F",
56
+ "SOYBEAN": "ZS=F",
57
+ }
58
+
59
+ IDX_ALIASES = {
60
+ "ACES",
61
+ "ADRO",
62
+ "AKRA",
63
+ "AMMN",
64
+ "ANTM",
65
+ "ARTO",
66
+ "ASII",
67
+ "BBCA",
68
+ "BBNI",
69
+ "BBRI",
70
+ "BBTN",
71
+ "BMRI",
72
+ "BRIS",
73
+ "BRPT",
74
+ "BUKA",
75
+ "CPIN",
76
+ "EMTK",
77
+ "ESSA",
78
+ "EXCL",
79
+ "GGRM",
80
+ "GOTO",
81
+ "HRUM",
82
+ "ICBP",
83
+ "INCO",
84
+ "INDF",
85
+ "INKP",
86
+ "INTP",
87
+ "ITMG",
88
+ "JPFA",
89
+ "JSMR",
90
+ "KLBF",
91
+ "MDKA",
92
+ "MEDC",
93
+ "PGAS",
94
+ "PTBA",
95
+ "SIDO",
96
+ "SMGR",
97
+ "TINS",
98
+ "TLKM",
99
+ "TOWR",
100
+ "UNTR",
101
+ "UNVR",
102
+ }
103
+
104
+ TWELVEDATA_ALIASES = {
105
+ "SPX": "SPX",
106
+ "SP500": "SPX",
107
+ "S&P500": "SPX",
108
+ "NASDAQ": "IXIC",
109
+ "IXIC": "IXIC",
110
+ "NDX": "NDX",
111
+ "DOW": "DJI",
112
+ "DJI": "DJI",
113
+ "DAX": "DAX",
114
+ "FTSE": "FTSE",
115
+ "CAC40": "CAC",
116
+ "NIKKEI": "N225",
117
+ "HSI": "HSI",
118
+ "GOLD": "XAU/USD",
119
+ "SILVER": "XAG/USD",
120
+ "WTI": "WTI/USD",
121
+ "BRENT": "BRENT/USD",
122
+ }
123
+
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class ResolvedSymbol:
127
+ original: str
128
+ symbol: str
129
+ asset_class: str
130
+
131
+
132
+ def resolve_yfinance_symbol(symbol: str) -> ResolvedSymbol:
133
+ normalized = _normalize(symbol)
134
+ if normalized in YFINANCE_ALIASES:
135
+ return ResolvedSymbol(symbol, YFINANCE_ALIASES[normalized], _alias_class(normalized))
136
+ if normalized in IDX_ALIASES:
137
+ return ResolvedSymbol(symbol, f"{normalized}.JK", "stock")
138
+ if _is_metal_pair(normalized) or _is_forex_pair(normalized):
139
+ return ResolvedSymbol(symbol, f"{normalized}=X", "forex")
140
+ return ResolvedSymbol(symbol, symbol.upper(), "stock")
141
+
142
+
143
+ def resolve_twelvedata_symbol(symbol: str) -> ResolvedSymbol:
144
+ normalized = _normalize(symbol)
145
+ if normalized in TWELVEDATA_ALIASES:
146
+ return ResolvedSymbol(symbol, TWELVEDATA_ALIASES[normalized], _alias_class(normalized))
147
+ if _is_metal_pair(normalized) or _is_forex_pair(normalized):
148
+ return ResolvedSymbol(symbol, f"{normalized[:3]}/{normalized[3:]}", "forex")
149
+ if "/" in symbol or ":" in symbol:
150
+ return ResolvedSymbol(symbol, symbol.upper(), "custom")
151
+ return ResolvedSymbol(symbol, symbol.upper(), "stock")
152
+
153
+
154
+ def resolve_finnhub_symbol(symbol: str) -> ResolvedSymbol:
155
+ normalized = _normalize(symbol)
156
+ if _is_forex_pair(normalized):
157
+ return ResolvedSymbol(symbol, f"OANDA:{normalized[:3]}_{normalized[3:]}", "forex")
158
+ if normalized.startswith("BINANCE:"):
159
+ return ResolvedSymbol(symbol, normalized, "crypto")
160
+ if normalized.endswith("USDT") and len(normalized) > 6:
161
+ return ResolvedSymbol(symbol, f"BINANCE:{normalized}", "crypto")
162
+ return ResolvedSymbol(symbol, symbol.upper(), "stock")
163
+
164
+
165
+ def _normalize(symbol: str) -> str:
166
+ return symbol.strip().upper().replace(" ", "").replace("-", "").replace("_", "").replace("/", "")
167
+
168
+
169
+ def _is_forex_pair(symbol: str) -> bool:
170
+ return len(symbol) == 6 and symbol[:3] in FOREX_CURRENCIES and symbol[3:] in FOREX_CURRENCIES
171
+
172
+
173
+ def _is_metal_pair(symbol: str) -> bool:
174
+ return len(symbol) == 6 and symbol[:3] in {"XAU", "XAG", "XPT", "XPD"} and symbol[3:] in FOREX_CURRENCIES
175
+
176
+
177
+ def _alias_class(symbol: str) -> str:
178
+ if symbol in {"GOLD", "SILVER", "WTI", "BRENT", "NATGAS", "COPPER", "CORN", "SOYBEAN"}:
179
+ return "commodity"
180
+ if symbol.startswith("XAU") or symbol.startswith("XAG"):
181
+ return "commodity"
182
+ return "index"
@@ -0,0 +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.", "Isi TWELVE_DATA_API_KEY di .env.")
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))