@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,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))
|