@drico2008/fincli 0.1.9 → 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 -718
- 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 -186
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- 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 -83
- package/fincli/app/cli/router.py +2123 -1294
- 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/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/manager.py +60 -60
- 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 +84 -1
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- 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 -267
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +200 -101
- package/fincli/app/storage/secrets.py +8 -2
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +269 -258
- package/fincli/app/tui/market_provider_selector.py +3 -1
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +123 -60
- package/package.json +23 -23
- package/pyproject.toml +35 -35
|
@@ -1,169 +1,186 @@
|
|
|
1
|
-
"""Custom HTTP market API provider.
|
|
2
|
-
|
|
3
|
-
Expected endpoint contract:
|
|
4
|
-
- GET /quote/{symbol}
|
|
5
|
-
- GET /history/{symbol}?period=6mo&interval=1d
|
|
6
|
-
- GET /news/{symbol}?limit=5
|
|
7
|
-
- GET /fundamentals/{symbol}
|
|
8
|
-
|
|
9
|
-
The provider accepts common JSON key variants so users can adapt simple APIs
|
|
10
|
-
without changing FinCLI core.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
from datetime import datetime
|
|
16
|
-
from typing import Any
|
|
17
|
-
|
|
18
|
-
import httpx
|
|
19
|
-
|
|
20
|
-
from fincli.app.providers.market.base import (
|
|
21
|
-
BaseMarketProvider,
|
|
22
|
-
Candle,
|
|
23
|
-
FundamentalSnapshot,
|
|
24
|
-
NewsItem,
|
|
25
|
-
ProviderStatus,
|
|
26
|
-
Quote,
|
|
27
|
-
)
|
|
28
|
-
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class CustomMarketProvider(BaseMarketProvider):
|
|
32
|
-
name = "custom"
|
|
33
|
-
|
|
34
|
-
def __init__(
|
|
35
|
-
self,
|
|
36
|
-
api_key: str | None,
|
|
37
|
-
base_url: str,
|
|
38
|
-
client: httpx.AsyncClient | None = None,
|
|
39
|
-
) -> None:
|
|
40
|
-
self.api_key = api_key or ""
|
|
41
|
-
self.base_url = base_url.rstrip("/")
|
|
42
|
-
self._client = client
|
|
43
|
-
|
|
44
|
-
async def quote(self, symbol: str) -> Quote:
|
|
45
|
-
data = await self._get(f"/quote/{symbol.upper()}")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
response.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
except
|
|
144
|
-
raise ProviderError("
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
1
|
+
"""Custom HTTP market API provider.
|
|
2
|
+
|
|
3
|
+
Expected endpoint contract:
|
|
4
|
+
- GET /quote/{symbol}
|
|
5
|
+
- GET /history/{symbol}?period=6mo&interval=1d
|
|
6
|
+
- GET /news/{symbol}?limit=5
|
|
7
|
+
- GET /fundamentals/{symbol}
|
|
8
|
+
|
|
9
|
+
The provider accepts common JSON key variants so users can adapt simple APIs
|
|
10
|
+
without changing FinCLI core.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from fincli.app.providers.market.base import (
|
|
21
|
+
BaseMarketProvider,
|
|
22
|
+
Candle,
|
|
23
|
+
FundamentalSnapshot,
|
|
24
|
+
NewsItem,
|
|
25
|
+
ProviderStatus,
|
|
26
|
+
Quote,
|
|
27
|
+
)
|
|
28
|
+
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CustomMarketProvider(BaseMarketProvider):
|
|
32
|
+
name = "custom"
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_key: str | None,
|
|
37
|
+
base_url: str,
|
|
38
|
+
client: httpx.AsyncClient | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.api_key = api_key or ""
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self._client = client
|
|
43
|
+
|
|
44
|
+
async def quote(self, symbol: str) -> Quote:
|
|
45
|
+
data = await self._get(f"/quote/{symbol.upper()}")
|
|
46
|
+
data = _require_mapping(data, "quote")
|
|
47
|
+
price = _required_float(data.get("price") or data.get("last") or data.get("last_price"), "quote.price")
|
|
48
|
+
return Quote(
|
|
49
|
+
symbol=str(data.get("symbol") or symbol).upper(),
|
|
50
|
+
price=price,
|
|
51
|
+
currency=str(data.get("currency") or "USD"),
|
|
52
|
+
provider=self.name,
|
|
53
|
+
timestamp=_parse_datetime(data.get("timestamp")) or datetime.now(),
|
|
54
|
+
status=str(data.get("status") or "custom"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
|
|
58
|
+
data = await self._get(f"/history/{symbol.upper()}", params={"period": period, "interval": interval})
|
|
59
|
+
raw_items = data.get("candles") if isinstance(data, dict) else data
|
|
60
|
+
if not isinstance(raw_items, list):
|
|
61
|
+
raise ProviderError("Response history custom provider tidak valid.")
|
|
62
|
+
candles: list[Candle] = []
|
|
63
|
+
for item in raw_items:
|
|
64
|
+
if not isinstance(item, dict):
|
|
65
|
+
continue
|
|
66
|
+
candles.append(
|
|
67
|
+
Candle(
|
|
68
|
+
timestamp=_parse_datetime(item.get("timestamp") or item.get("date")) or datetime.now(),
|
|
69
|
+
open=_required_float(item.get("open") or item.get("o"), "history.open"),
|
|
70
|
+
high=_required_float(item.get("high") or item.get("h"), "history.high"),
|
|
71
|
+
low=_required_float(item.get("low") or item.get("l"), "history.low"),
|
|
72
|
+
close=_required_float(item.get("close") or item.get("c"), "history.close"),
|
|
73
|
+
volume=float(item.get("volume") or item.get("v") or 0),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
if not candles:
|
|
77
|
+
raise ProviderError(f"Data OHLCV kosong untuk {symbol}.")
|
|
78
|
+
return candles
|
|
79
|
+
|
|
80
|
+
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
81
|
+
data = await self._get(f"/news/{symbol.upper()}", params={"limit": limit})
|
|
82
|
+
raw_items = data.get("news") if isinstance(data, dict) else data
|
|
83
|
+
if not isinstance(raw_items, list):
|
|
84
|
+
raise ProviderError("Response news custom provider tidak valid.")
|
|
85
|
+
items: list[NewsItem] = []
|
|
86
|
+
for item in raw_items[:limit]:
|
|
87
|
+
if not isinstance(item, dict):
|
|
88
|
+
continue
|
|
89
|
+
items.append(
|
|
90
|
+
NewsItem(
|
|
91
|
+
title=str(item.get("title") or "Untitled"),
|
|
92
|
+
source=str(item.get("source") or self.name),
|
|
93
|
+
url=item.get("url"),
|
|
94
|
+
published_at=_parse_datetime(item.get("published_at") or item.get("timestamp")),
|
|
95
|
+
summary=str(item.get("summary") or ""),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return items
|
|
99
|
+
|
|
100
|
+
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
101
|
+
data = await self._get(f"/fundamentals/{symbol.upper()}")
|
|
102
|
+
data = _require_mapping(data, "fundamentals")
|
|
103
|
+
return FundamentalSnapshot(
|
|
104
|
+
symbol=str(data.get("symbol") or symbol).upper(),
|
|
105
|
+
provider=self.name,
|
|
106
|
+
currency=str(data.get("currency") or "USD"),
|
|
107
|
+
market_cap=_safe_float(data.get("market_cap") or data.get("marketCap")),
|
|
108
|
+
pe_ratio=_safe_float(data.get("pe_ratio") or data.get("trailingPE")),
|
|
109
|
+
eps=_safe_float(data.get("eps") or data.get("trailingEps")),
|
|
110
|
+
revenue=_safe_float(data.get("revenue") or data.get("totalRevenue")),
|
|
111
|
+
beta=_safe_float(data.get("beta")),
|
|
112
|
+
sector=data.get("sector"),
|
|
113
|
+
industry=data.get("industry"),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def status(self) -> ProviderStatus:
|
|
117
|
+
status = "configured" if self.api_key else "unavailable"
|
|
118
|
+
message = "Custom provider configured." if self.api_key else "Requires MARKET_DATA_API_KEY."
|
|
119
|
+
return ProviderStatus(name=self.name, realtime=True, status=status, message=message)
|
|
120
|
+
|
|
121
|
+
async def _get(self, path: str, params: dict[str, object] | None = None) -> Any:
|
|
122
|
+
if not self.api_key:
|
|
123
|
+
raise ProviderError(
|
|
124
|
+
"API key custom market provider belum diatur.",
|
|
125
|
+
"Gunakan /news_model key custom <api_key> <base_url>.",
|
|
126
|
+
)
|
|
127
|
+
if not self.base_url:
|
|
128
|
+
raise ProviderError(
|
|
129
|
+
"Base URL custom market provider belum diatur.",
|
|
130
|
+
"Gunakan /news_model key custom <api_key> <base_url>.",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
close_client = self._client is None
|
|
134
|
+
client = self._client or httpx.AsyncClient(timeout=30)
|
|
135
|
+
headers = {"X-API-Key": self.api_key, "Authorization": f"Bearer {self.api_key}"}
|
|
136
|
+
try:
|
|
137
|
+
response = await client.get(f"{self.base_url}{path}", params=params, headers=headers)
|
|
138
|
+
if response.status_code == 429:
|
|
139
|
+
raise RateLimitError("Custom market provider terkena rate limit.")
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
data = response.json()
|
|
142
|
+
return data
|
|
143
|
+
except httpx.TimeoutException as exc:
|
|
144
|
+
raise ProviderError("Custom market provider timeout.") from exc
|
|
145
|
+
except httpx.HTTPStatusError as exc:
|
|
146
|
+
raise ProviderError(f"Custom market provider gagal: HTTP {exc.response.status_code}.") from exc
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
raise ProviderError("Response custom market provider bukan JSON valid.") from exc
|
|
149
|
+
finally:
|
|
150
|
+
if close_client:
|
|
151
|
+
await client.aclose()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _safe_float(value: Any) -> float | None:
|
|
155
|
+
try:
|
|
156
|
+
if value is None:
|
|
157
|
+
return None
|
|
158
|
+
return float(value)
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _required_float(value: Any, field_name: str) -> float:
|
|
164
|
+
number = _safe_float(value)
|
|
165
|
+
if number is None:
|
|
166
|
+
raise ProviderError(f"Response custom provider tidak valid: {field_name} wajib berupa angka.")
|
|
167
|
+
return number
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _require_mapping(value: Any, section: str) -> dict[str, Any]:
|
|
171
|
+
if not isinstance(value, dict):
|
|
172
|
+
raise ProviderError(f"Response {section} custom provider tidak valid: root JSON harus object.")
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
177
|
+
if value is None:
|
|
178
|
+
return None
|
|
179
|
+
if isinstance(value, (int, float)):
|
|
180
|
+
return datetime.fromtimestamp(value)
|
|
181
|
+
if isinstance(value, str):
|
|
182
|
+
try:
|
|
183
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
184
|
+
except ValueError:
|
|
185
|
+
return None
|
|
186
|
+
return None
|
|
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
import os
|
|
7
7
|
|
|
8
|
-
from fincli.app.providers.market.base import BaseMarketProvider
|
|
8
|
+
from fincli.app.providers.market.base import BaseMarketProvider, ProviderEntitlement
|
|
9
|
+
from fincli.app.providers.market.alphavantage_provider import AlphaVantageProvider
|
|
9
10
|
from fincli.app.providers.market.custom_provider import CustomMarketProvider
|
|
10
11
|
from fincli.app.providers.market.finnhub_provider import FinnhubProvider
|
|
11
12
|
from fincli.app.providers.market.twelvedata_provider import TwelveDataProvider
|
|
@@ -46,6 +47,12 @@ MARKET_PROVIDERS: dict[str, MarketProviderInfo] = {
|
|
|
46
47
|
status="configured",
|
|
47
48
|
notes="Multi-asset provider for stocks, forex, ETFs, indices, commodities, and crypto. Requires TWELVE_DATA_API_KEY.",
|
|
48
49
|
),
|
|
50
|
+
"alphavantage": MarketProviderInfo(
|
|
51
|
+
name="alphavantage",
|
|
52
|
+
realtime=False,
|
|
53
|
+
status="configured",
|
|
54
|
+
notes="Alpha Vantage adapter for stocks, FX, news sentiment, and company overview. Requires ALPHA_VANTAGE_API_KEY.",
|
|
55
|
+
),
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
|
|
@@ -71,6 +78,8 @@ class MarketProviderManager:
|
|
|
71
78
|
return FinnhubProvider(api_key=os.getenv("FINNHUB_API_KEY"))
|
|
72
79
|
if provider_name == "twelvedata":
|
|
73
80
|
return TwelveDataProvider(api_key=os.getenv("TWELVE_DATA_API_KEY"))
|
|
81
|
+
if provider_name == "alphavantage":
|
|
82
|
+
return AlphaVantageProvider(api_key=os.getenv("ALPHA_VANTAGE_API_KEY"))
|
|
74
83
|
raise ValueError(f"Market provider tidak dikenal: {name}")
|
|
75
84
|
|
|
76
85
|
def create_many(self, names: list[str]) -> list[BaseMarketProvider]:
|
|
@@ -113,6 +122,80 @@ class MarketProviderManager:
|
|
|
113
122
|
"status": _mask_status(os.getenv("TWELVE_DATA_API_KEY")),
|
|
114
123
|
"source": secret_source("TWELVE_DATA_API_KEY"),
|
|
115
124
|
},
|
|
125
|
+
{
|
|
126
|
+
"provider": "alphavantage",
|
|
127
|
+
"key": "ALPHA_VANTAGE_API_KEY",
|
|
128
|
+
"status": _mask_status(os.getenv("ALPHA_VANTAGE_API_KEY")),
|
|
129
|
+
"source": secret_source("ALPHA_VANTAGE_API_KEY"),
|
|
130
|
+
},
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
def entitlements(self) -> list[ProviderEntitlement]:
|
|
134
|
+
return [
|
|
135
|
+
ProviderEntitlement(
|
|
136
|
+
provider="yfinance",
|
|
137
|
+
status="available",
|
|
138
|
+
realtime_label="delayed/fallback",
|
|
139
|
+
asset_classes=("stocks", "ETFs", "indices", "forex", "crypto", "commodities", "funds"),
|
|
140
|
+
capabilities=("quote", "history", "news", "fundamentals", "yahoo tables"),
|
|
141
|
+
limitations=(
|
|
142
|
+
"No API key required.",
|
|
143
|
+
"Data may be delayed and Yahoo coverage varies by exchange.",
|
|
144
|
+
"Not suitable for guaranteed realtime execution workflows.",
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
ProviderEntitlement(
|
|
148
|
+
provider="twelvedata",
|
|
149
|
+
status="configured" if os.getenv("TWELVE_DATA_API_KEY") else "missing key",
|
|
150
|
+
realtime_label="plan-dependent",
|
|
151
|
+
asset_classes=("stocks", "ETFs", "indices", "forex", "crypto", "commodities"),
|
|
152
|
+
capabilities=("quote", "history"),
|
|
153
|
+
limitations=(
|
|
154
|
+
"Requires TWELVE_DATA_API_KEY.",
|
|
155
|
+
"Realtime access depends on plan and exchange entitlement.",
|
|
156
|
+
"News/fundamentals are not implemented in this adapter yet.",
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
ProviderEntitlement(
|
|
160
|
+
provider="finnhub",
|
|
161
|
+
status="configured" if os.getenv("FINNHUB_API_KEY") else "missing key",
|
|
162
|
+
realtime_label="plan-dependent",
|
|
163
|
+
asset_classes=("stocks", "forex", "crypto"),
|
|
164
|
+
capabilities=("quote", "history", "news", "fundamentals", "economic calendar"),
|
|
165
|
+
limitations=(
|
|
166
|
+
"Requires FINNHUB_API_KEY.",
|
|
167
|
+
"Forex/crypto support is strongest for candles.",
|
|
168
|
+
"News/fundamentals coverage is strongest for equities.",
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
ProviderEntitlement(
|
|
172
|
+
provider="custom",
|
|
173
|
+
status=(
|
|
174
|
+
"configured"
|
|
175
|
+
if os.getenv("MARKET_DATA_API_KEY") and os.getenv("MARKET_DATA_BASE_URL")
|
|
176
|
+
else "missing key/base url"
|
|
177
|
+
),
|
|
178
|
+
realtime_label="custom",
|
|
179
|
+
asset_classes=("provider-defined",),
|
|
180
|
+
capabilities=("quote", "history", "news", "fundamentals"),
|
|
181
|
+
limitations=(
|
|
182
|
+
"Requires MARKET_DATA_API_KEY and MARKET_DATA_BASE_URL.",
|
|
183
|
+
"Realtime/delayed status must be supplied by the custom API payload.",
|
|
184
|
+
"Payloads are validated by FinCLI before being accepted.",
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
ProviderEntitlement(
|
|
188
|
+
provider="alphavantage",
|
|
189
|
+
status="configured" if os.getenv("ALPHA_VANTAGE_API_KEY") else "missing key",
|
|
190
|
+
realtime_label="delayed/plan-dependent",
|
|
191
|
+
asset_classes=("stocks", "forex", "selected crypto/commodities via provider functions"),
|
|
192
|
+
capabilities=("quote", "history", "news", "fundamentals"),
|
|
193
|
+
limitations=(
|
|
194
|
+
"Requires ALPHA_VANTAGE_API_KEY.",
|
|
195
|
+
"Free plans are heavily rate-limited.",
|
|
196
|
+
"Realtime availability and exchange coverage depend on Alpha Vantage plan.",
|
|
197
|
+
),
|
|
198
|
+
),
|
|
116
199
|
]
|
|
117
200
|
|
|
118
201
|
|
|
@@ -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"
|