@drico2008/fincli 0.3.0 → 0.4.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 (37) hide show
  1. package/README.md +217 -217
  2. package/fincli/__init__.py +1 -1
  3. package/fincli/app/analysis/ai_prompts.py +29 -27
  4. package/fincli/app/analysis/analyzer.py +34 -34
  5. package/fincli/app/analysis/assistant_context.py +3 -3
  6. package/fincli/app/cli/commands.py +33 -27
  7. package/fincli/app/cli/router.py +1633 -1105
  8. package/fincli/app/diagnostics/__init__.py +2 -0
  9. package/fincli/app/diagnostics/capabilities.py +44 -0
  10. package/fincli/app/diagnostics/runtime.py +106 -0
  11. package/fincli/app/main.py +6 -1
  12. package/fincli/app/modules/economic_calendar.py +512 -512
  13. package/fincli/app/modules/portfolio_risk.py +305 -305
  14. package/fincli/app/modules/trading.py +142 -0
  15. package/fincli/app/plugins/loader.py +72 -72
  16. package/fincli/app/providers/market/finnhub_provider.py +51 -2
  17. package/fincli/app/providers/market/symbols.py +95 -2
  18. package/fincli/app/providers/reliability.py +82 -65
  19. package/fincli/app/research/__init__.py +8 -8
  20. package/fincli/app/research/engine.py +119 -112
  21. package/fincli/app/research/exporter.py +91 -91
  22. package/fincli/app/research/formatter.py +25 -24
  23. package/fincli/app/research/models.py +22 -21
  24. package/fincli/app/research/prompt_builder.py +53 -51
  25. package/fincli/app/services/data_quality.py +27 -0
  26. package/fincli/app/services/data_trust.py +117 -0
  27. package/fincli/app/services/macro_data.py +158 -50
  28. package/fincli/app/services/market_data.py +183 -79
  29. package/fincli/app/services/market_overview.py +131 -142
  30. package/fincli/app/services/news_aggregator.py +95 -95
  31. package/fincli/app/storage/config.py +6 -3
  32. package/fincli/app/storage/database.py +130 -117
  33. package/fincli/app/storage/provider_metrics.py +61 -61
  34. package/fincli/app/storage/secrets.py +128 -128
  35. package/npm/bin/fincli.js +65 -42
  36. package/package.json +7 -7
  37. package/pyproject.toml +1 -1
@@ -0,0 +1,142 @@
1
+ """Trading capability catalog and local paper trading engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.storage.database import FinCLIDatabase
8
+ from fincli.app.utils.errors import CommandError
9
+ from fincli.app.utils.formatting import normalize_symbol
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class RealtimeConnector:
14
+ name: str
15
+ transport: str
16
+ asset_classes: tuple[str, ...]
17
+ status: str
18
+ note: str
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class BrokerIntegration:
23
+ name: str
24
+ region: str
25
+ asset_classes: tuple[str, ...]
26
+ mode: str
27
+ note: str
28
+
29
+
30
+ class RealtimeConnectorCatalog:
31
+ """Connector catalog for realtime/push market data capability planning."""
32
+
33
+ def all(self) -> tuple[RealtimeConnector, ...]:
34
+ return (
35
+ RealtimeConnector(
36
+ "Kraken WebSocket",
37
+ "websocket",
38
+ ("crypto",),
39
+ "adapter_stub",
40
+ "Crypto realtime feed scaffold. Requires Kraken WS adapter before live streaming.",
41
+ ),
42
+ RealtimeConnector(
43
+ "HyperLiquid WebSocket",
44
+ "websocket",
45
+ ("crypto", "perpetuals"),
46
+ "adapter_stub",
47
+ "HyperLiquid realtime/orderbook scaffold. No live execution in v0.4.0.",
48
+ ),
49
+ RealtimeConnector(
50
+ "Equity Quote Feed",
51
+ "polling/provider",
52
+ ("equity", "etf", "index"),
53
+ "provider_backed",
54
+ "Uses configured market providers; realtime depends on provider entitlement.",
55
+ ),
56
+ )
57
+
58
+
59
+ class BrokerCatalog:
60
+ """Catalog of planned broker integrations with safe non-live defaults."""
61
+
62
+ def all(self) -> tuple[BrokerIntegration, ...]:
63
+ india = ("equity", "fno", "mutual_fund")
64
+ global_assets = ("equity", "options", "etf")
65
+ return (
66
+ BrokerIntegration("Zerodha", "India", india, "adapter_stub", "Kite Connect adapter planned."),
67
+ BrokerIntegration("Angel One", "India", india, "adapter_stub", "SmartAPI adapter planned."),
68
+ BrokerIntegration("Upstox", "India", india, "adapter_stub", "Upstox API adapter planned."),
69
+ BrokerIntegration("Fyers", "India", india, "adapter_stub", "Fyers API adapter planned."),
70
+ BrokerIntegration("Dhan", "India", india, "adapter_stub", "Dhan API adapter planned."),
71
+ BrokerIntegration("Groww", "India", ("equity", "mutual_fund"), "adapter_stub", "Broker API availability varies."),
72
+ BrokerIntegration("Kotak", "India", india, "adapter_stub", "Kotak Neo adapter planned."),
73
+ BrokerIntegration("IIFL", "India", india, "adapter_stub", "IIFL API adapter planned."),
74
+ BrokerIntegration("5paisa", "India", india, "adapter_stub", "5paisa API adapter planned."),
75
+ BrokerIntegration("AliceBlue", "India", india, "adapter_stub", "Ant API adapter planned."),
76
+ BrokerIntegration("Shoonya", "India", india, "adapter_stub", "Shoonya/Noren adapter planned."),
77
+ BrokerIntegration("Motilal", "India", india, "adapter_stub", "Motilal Oswal adapter planned."),
78
+ BrokerIntegration("IBKR", "Global", ("equity", "options", "futures", "forex"), "adapter_stub", "TWS/Gateway adapter planned."),
79
+ BrokerIntegration("Alpaca", "US", global_assets, "paper_ready", "Paper/live adapter candidate; v0.4.0 remains local paper only."),
80
+ BrokerIntegration("Tradier", "US", global_assets, "adapter_stub", "Tradier brokerage adapter planned."),
81
+ BrokerIntegration("Saxo", "Global", ("equity", "forex", "cfd", "futures"), "adapter_stub", "OpenAPI adapter planned."),
82
+ )
83
+
84
+
85
+ class PaperTradingEngine:
86
+ """Local paper trading engine. It never sends live broker orders."""
87
+
88
+ def __init__(self, db: FinCLIDatabase) -> None:
89
+ self.db = db
90
+
91
+ def place_order(
92
+ self,
93
+ side: str,
94
+ symbol: str,
95
+ quantity: float,
96
+ order_type: str = "market",
97
+ price: float | None = None,
98
+ strategy: str = "manual",
99
+ ) -> dict[str, object]:
100
+ normalized_side = side.strip().lower()
101
+ normalized_type = order_type.strip().lower()
102
+ normalized_symbol = normalize_symbol(symbol)
103
+ if normalized_side not in {"buy", "sell"}:
104
+ raise CommandError("Paper order side harus buy atau sell.")
105
+ if normalized_type not in {"market", "limit"}:
106
+ raise CommandError("Paper order type harus market atau limit.")
107
+ if quantity <= 0:
108
+ raise CommandError("Paper order quantity harus lebih besar dari 0.")
109
+ if price is not None and price <= 0:
110
+ raise CommandError("Paper order price harus lebih besar dari 0.")
111
+
112
+ status = "filled" if normalized_type == "market" or price is not None else "queued"
113
+ notional = float(quantity) * float(price or 0)
114
+ self.db.execute(
115
+ """
116
+ INSERT INTO paper_orders(side, symbol, quantity, order_type, price, notional, status, strategy)
117
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
118
+ """,
119
+ (normalized_side, normalized_symbol, quantity, normalized_type, price, notional, status, strategy),
120
+ )
121
+ return {
122
+ "side": normalized_side,
123
+ "symbol": normalized_symbol,
124
+ "quantity": quantity,
125
+ "order_type": normalized_type,
126
+ "price": price,
127
+ "notional": notional,
128
+ "status": status,
129
+ "strategy": strategy,
130
+ }
131
+
132
+ def list_orders(self, limit: int = 50) -> list[dict[str, object]]:
133
+ rows = self.db.query(
134
+ """
135
+ SELECT id, side, symbol, quantity, order_type, price, notional, status, strategy, created_at
136
+ FROM paper_orders
137
+ ORDER BY id DESC
138
+ LIMIT ?
139
+ """,
140
+ (limit,),
141
+ )
142
+ return [dict(row) for row in rows]
@@ -1,72 +1,72 @@
1
- """Local plugin discovery for FinCLI.
2
-
3
- Plugins are intentionally manifest-first in v0.3.0: FinCLI reads metadata and
4
- exposes status, but does not execute plugin code yet. This keeps the plugin
5
- surface useful without creating a security footgun.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from dataclasses import dataclass
11
- import json
12
- from pathlib import Path
13
- from typing import Iterable
14
-
15
- from fincli.app.storage import config_paths
16
-
17
-
18
- @dataclass(frozen=True, slots=True)
19
- class PluginManifest:
20
- name: str
21
- version: str
22
- description: str
23
- commands: tuple[str, ...]
24
- capabilities: tuple[str, ...]
25
- path: Path
26
- status: str = "available"
27
-
28
-
29
- class PluginLoader:
30
- """Discover plugin manifests from local plugin directories."""
31
-
32
- def __init__(self, search_paths: Iterable[Path] | None = None) -> None:
33
- self.search_paths = tuple(search_paths) if search_paths is not None else (config_paths.APP_DIR / "plugins",)
34
-
35
- def discover(self) -> list[PluginManifest]:
36
- plugins: list[PluginManifest] = []
37
- for root in self.search_paths:
38
- if not root.exists():
39
- continue
40
- for manifest_path in sorted(root.glob("*/plugin.json")):
41
- plugins.append(self._read_manifest(manifest_path))
42
- return plugins
43
-
44
- def _read_manifest(self, manifest_path: Path) -> PluginManifest:
45
- try:
46
- payload = json.loads(manifest_path.read_text(encoding="utf-8"))
47
- name = str(payload["name"]).strip()
48
- version = str(payload.get("version") or "0.0.0").strip()
49
- description = str(payload.get("description") or "").strip()
50
- commands = tuple(str(item) for item in payload.get("commands", []) if str(item).strip())
51
- capabilities = tuple(str(item) for item in payload.get("capabilities", []) if str(item).strip())
52
- if not name:
53
- raise ValueError("name is empty")
54
- return PluginManifest(
55
- name=name,
56
- version=version,
57
- description=description,
58
- commands=commands,
59
- capabilities=capabilities,
60
- path=manifest_path,
61
- status="available",
62
- )
63
- except Exception as exc: # noqa: BLE001
64
- return PluginManifest(
65
- name=manifest_path.parent.name,
66
- version="unknown",
67
- description=f"Invalid plugin manifest: {exc}",
68
- commands=(),
69
- capabilities=(),
70
- path=manifest_path,
71
- status="invalid",
72
- )
1
+ """Local plugin discovery for FinCLI.
2
+
3
+ Plugins are intentionally manifest-first in v0.4.0: FinCLI reads metadata and
4
+ exposes status, but does not execute plugin code yet. This keeps the plugin
5
+ surface useful without creating a security footgun.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Iterable
14
+
15
+ from fincli.app.storage import config_paths
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class PluginManifest:
20
+ name: str
21
+ version: str
22
+ description: str
23
+ commands: tuple[str, ...]
24
+ capabilities: tuple[str, ...]
25
+ path: Path
26
+ status: str = "available"
27
+
28
+
29
+ class PluginLoader:
30
+ """Discover plugin manifests from local plugin directories."""
31
+
32
+ def __init__(self, search_paths: Iterable[Path] | None = None) -> None:
33
+ self.search_paths = tuple(search_paths) if search_paths is not None else (config_paths.APP_DIR / "plugins",)
34
+
35
+ def discover(self) -> list[PluginManifest]:
36
+ plugins: list[PluginManifest] = []
37
+ for root in self.search_paths:
38
+ if not root.exists():
39
+ continue
40
+ for manifest_path in sorted(root.glob("*/plugin.json")):
41
+ plugins.append(self._read_manifest(manifest_path))
42
+ return plugins
43
+
44
+ def _read_manifest(self, manifest_path: Path) -> PluginManifest:
45
+ try:
46
+ payload = json.loads(manifest_path.read_text(encoding="utf-8"))
47
+ name = str(payload["name"]).strip()
48
+ version = str(payload.get("version") or "0.0.0").strip()
49
+ description = str(payload.get("description") or "").strip()
50
+ commands = tuple(str(item) for item in payload.get("commands", []) if str(item).strip())
51
+ capabilities = tuple(str(item) for item in payload.get("capabilities", []) if str(item).strip())
52
+ if not name:
53
+ raise ValueError("name is empty")
54
+ return PluginManifest(
55
+ name=name,
56
+ version=version,
57
+ description=description,
58
+ commands=commands,
59
+ capabilities=capabilities,
60
+ path=manifest_path,
61
+ status="available",
62
+ )
63
+ except Exception as exc: # noqa: BLE001
64
+ return PluginManifest(
65
+ name=manifest_path.parent.name,
66
+ version="unknown",
67
+ description=f"Invalid plugin manifest: {exc}",
68
+ commands=(),
69
+ capabilities=(),
70
+ path=manifest_path,
71
+ status="invalid",
72
+ )
@@ -11,8 +11,10 @@ implements the endpoints FinCLI needs for stock-style symbols first:
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from datetime import datetime, timedelta
15
- from typing import Any
14
+ import asyncio
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from datetime import date, datetime, timedelta
17
+ from typing import Any, Awaitable
16
18
 
17
19
  import httpx
18
20
 
@@ -134,11 +136,34 @@ class FinnhubProvider:
134
136
  industry=data.get("finnhubIndustry"),
135
137
  )
136
138
 
139
+ async def insider_transactions(self, symbol: str, limit: int = 20) -> list[dict[str, object]]:
140
+ data = await self._get("/stock/insider-transactions", {"symbol": symbol.upper()})
141
+ rows = data.get("data") if isinstance(data, dict) else None
142
+ if not isinstance(rows, list):
143
+ raise ProviderError("Response Finnhub insider transactions tidak valid.")
144
+ return [_parse_insider_transaction(item, symbol) for item in rows[:limit] if isinstance(item, dict)]
145
+
146
+ async def ipo_calendar(self, start: date, end: date) -> list[dict[str, object]]:
147
+ data = await self._get("/calendar/ipo", {"from": start.isoformat(), "to": end.isoformat()})
148
+ rows = data.get("ipoCalendar") if isinstance(data, dict) else None
149
+ if not isinstance(rows, list):
150
+ raise ProviderError("Response Finnhub IPO calendar tidak valid.")
151
+ return [_parse_ipo_item(item) for item in rows if isinstance(item, dict)]
152
+
137
153
  async def status(self) -> ProviderStatus:
138
154
  status = "configured" if self.api_key else "unavailable"
139
155
  message = "Finnhub provider configured." if self.api_key else "Requires FINNHUB_API_KEY."
140
156
  return ProviderStatus(name=self.name, realtime=True, status=status, message=message)
141
157
 
158
+ def run(self, awaitable: Awaitable[Any]) -> Any:
159
+ try:
160
+ asyncio.get_running_loop()
161
+ except RuntimeError:
162
+ return asyncio.run(awaitable)
163
+ with ThreadPoolExecutor(max_workers=1) as executor:
164
+ future = executor.submit(asyncio.run, awaitable)
165
+ return future.result()
166
+
142
167
  async def _get(self, path: str, params: dict[str, object]) -> Any:
143
168
  if not self.api_key:
144
169
  raise ProviderError("API key Finnhub belum diatur.", "Gunakan /news_model key finnhub <api_key>.")
@@ -171,6 +196,30 @@ def _safe_float(value: Any) -> float | None:
171
196
  return None
172
197
 
173
198
 
199
+ def _parse_insider_transaction(item: dict[str, Any], symbol: str) -> dict[str, object]:
200
+ return {
201
+ "symbol": str(item.get("symbol") or symbol).upper(),
202
+ "name": str(item.get("name") or "-"),
203
+ "date": str(item.get("transactionDate") or item.get("filingDate") or "-"),
204
+ "transaction_code": str(item.get("transactionCode") or "-"),
205
+ "change": _safe_float(item.get("change")),
206
+ "shares": _safe_float(item.get("share")),
207
+ "transaction_price": _safe_float(item.get("transactionPrice")),
208
+ }
209
+
210
+
211
+ def _parse_ipo_item(item: dict[str, Any]) -> dict[str, object]:
212
+ return {
213
+ "date": str(item.get("date") or "-"),
214
+ "exchange": str(item.get("exchange") or "-"),
215
+ "symbol": str(item.get("symbol") or "-"),
216
+ "name": str(item.get("name") or "-"),
217
+ "price": str(item.get("price") or "-"),
218
+ "shares": _safe_float(item.get("numberOfShares")),
219
+ "status": str(item.get("status") or "-"),
220
+ }
221
+
222
+
174
223
  def _period_to_delta(period: str) -> timedelta:
175
224
  normalized = period.lower()
176
225
  if normalized.endswith("mo"):
@@ -129,6 +129,14 @@ class ResolvedSymbol:
129
129
  original: str
130
130
  symbol: str
131
131
  asset_class: str
132
+ provider_symbols: dict[str, str] | None = None
133
+ confidence: str = "rule"
134
+ source: str = "local_rules"
135
+ notes: str = ""
136
+
137
+ @property
138
+ def canonical(self) -> str:
139
+ return self.symbol
132
140
 
133
141
 
134
142
  @dataclass(frozen=True, slots=True)
@@ -150,8 +158,11 @@ SYMBOL_CATALOG: tuple[SymbolAlias, ...] = (
150
158
  SymbolAlias("SPY", "SPDR S&P 500 ETF Trust", "etf", "NYSE Arca", "USD", ("S&P ETF",)),
151
159
  SymbolAlias("QQQ", "Invesco QQQ Trust", "etf", "NASDAQ", "USD", ("NASDAQ ETF",)),
152
160
  SymbolAlias("SPX", "S&P 500 Index", "index", "US", "USD", ("SP500", "S&P500", "^GSPC")),
161
+ SymbolAlias("US500", "S&P 500 CFD alias", "index", "US", "USD", ("SPX", "SP500", "^GSPC")),
153
162
  SymbolAlias("NASDAQ", "Nasdaq Composite Index", "index", "US", "USD", ("IXIC", "^IXIC")),
163
+ SymbolAlias("US100", "Nasdaq 100 CFD alias", "index", "US", "USD", ("NDX", "NASDAQ100", "^NDX")),
154
164
  SymbolAlias("DOW", "Dow Jones Industrial Average", "index", "US", "USD", ("DJI", "^DJI")),
165
+ SymbolAlias("US30", "Dow Jones CFD alias", "index", "US", "USD", ("DOW", "DJI", "^DJI")),
155
166
  SymbolAlias("DAX", "DAX Performance Index", "index", "Germany", "EUR", ("^GDAXI",)),
156
167
  SymbolAlias("NIKKEI", "Nikkei 225 Index", "index", "Japan", "JPY", ("N225", "^N225")),
157
168
  SymbolAlias("EURUSD", "Euro / US Dollar", "forex", "FX", "USD", ("EUR/USD", "EURUSD=X")),
@@ -170,8 +181,61 @@ SYMBOL_CATALOG: tuple[SymbolAlias, ...] = (
170
181
  )
171
182
 
172
183
 
184
+ class SymbolResolver:
185
+ """Resolve user-facing symbols to provider-specific symbols with a small local cache."""
186
+
187
+ def __init__(self) -> None:
188
+ self._cache: dict[tuple[str, str, str], ResolvedSymbol] = {}
189
+
190
+ def resolve(self, symbol: str, provider: str = "yfinance", asset_class: str | None = None) -> ResolvedSymbol:
191
+ provider_name = provider.lower().strip()
192
+ asset_hint = (asset_class or "").lower().strip()
193
+ cache_key = (provider_name, _normalize(symbol), asset_hint)
194
+ cached = self._cache.get(cache_key)
195
+ if cached is not None:
196
+ return cached
197
+
198
+ resolved = resolve_provider_symbol(provider_name, symbol)
199
+ if asset_hint and resolved.asset_class != asset_hint:
200
+ resolved = ResolvedSymbol(
201
+ original=resolved.original,
202
+ symbol=resolved.symbol,
203
+ asset_class=asset_hint,
204
+ provider_symbols=resolved.provider_symbols,
205
+ confidence=resolved.confidence,
206
+ source=resolved.source,
207
+ notes=f"Asset class forced by user: {asset_hint}.",
208
+ )
209
+ self._cache[cache_key] = resolved
210
+ return resolved
211
+
212
+ def provider_symbol(self, provider: str, symbol: str, asset_class: str | None = None) -> str:
213
+ return self.resolve(symbol, provider=provider, asset_class=asset_class).symbol
214
+
215
+ def matrix(self, symbol: str, providers: tuple[str, ...] | None = None) -> dict[str, ResolvedSymbol]:
216
+ names = providers or ("yfinance", "twelvedata", "finnhub", "alphavantage", "custom")
217
+ return {name: self.resolve(symbol, provider=name) for name in names}
218
+
219
+ def search(self, query: str, limit: int = 12) -> list[SymbolSearchResult]:
220
+ return search_symbol_catalog(query, limit=limit)
221
+
222
+
173
223
  def resolve_yfinance_symbol(symbol: str) -> ResolvedSymbol:
174
224
  normalized = _normalize(symbol)
225
+ if normalized in {"US500", "SPX500"}:
226
+ return ResolvedSymbol(symbol, "^GSPC", "index")
227
+ if normalized in {"US100", "NASDAQ100"}:
228
+ return ResolvedSymbol(symbol, "^NDX", "index")
229
+ if normalized == "US30":
230
+ return ResolvedSymbol(symbol, "^DJI", "index")
231
+ if normalized in {"XAUUSD", "GOLD"}:
232
+ return ResolvedSymbol(symbol, "GC=F", "commodity", notes="Yahoo Finance uses gold futures as the practical free fallback for XAUUSD.")
233
+ if normalized in {"XAGUSD", "SILVER"}:
234
+ return ResolvedSymbol(symbol, "SI=F", "commodity", notes="Yahoo Finance uses silver futures as the practical free fallback for XAGUSD.")
235
+ if normalized in {"BTCUSD", "BTCUSDT", "BTC"}:
236
+ return ResolvedSymbol(symbol, "BTC-USD", "crypto")
237
+ if normalized in {"ETHUSD", "ETHUSDT", "ETH"}:
238
+ return ResolvedSymbol(symbol, "ETH-USD", "crypto")
175
239
  if normalized in YFINANCE_ALIASES:
176
240
  return ResolvedSymbol(symbol, YFINANCE_ALIASES[normalized], _alias_class(normalized))
177
241
  if normalized in IDX_ALIASES:
@@ -183,6 +247,16 @@ def resolve_yfinance_symbol(symbol: str) -> ResolvedSymbol:
183
247
 
184
248
  def resolve_twelvedata_symbol(symbol: str) -> ResolvedSymbol:
185
249
  normalized = _normalize(symbol)
250
+ if normalized in {"US500", "SPX500"}:
251
+ return ResolvedSymbol(symbol, "SPX", "index")
252
+ if normalized in {"US100", "NASDAQ100"}:
253
+ return ResolvedSymbol(symbol, "NDX", "index")
254
+ if normalized == "US30":
255
+ return ResolvedSymbol(symbol, "DJI", "index")
256
+ if normalized in {"BTCUSD", "BTCUSDT", "BTC"}:
257
+ return ResolvedSymbol(symbol, "BTC/USD", "crypto")
258
+ if normalized in {"ETHUSD", "ETHUSDT", "ETH"}:
259
+ return ResolvedSymbol(symbol, "ETH/USD", "crypto")
186
260
  if normalized in TWELVEDATA_ALIASES:
187
261
  return ResolvedSymbol(symbol, TWELVEDATA_ALIASES[normalized], _alias_class(normalized))
188
262
  if _is_metal_pair(normalized) or _is_forex_pair(normalized):
@@ -194,6 +268,16 @@ def resolve_twelvedata_symbol(symbol: str) -> ResolvedSymbol:
194
268
 
195
269
  def resolve_finnhub_symbol(symbol: str) -> ResolvedSymbol:
196
270
  normalized = _normalize(symbol)
271
+ if normalized in {"US500", "SPX", "SP500", "SPX500"}:
272
+ return ResolvedSymbol(symbol, "^GSPC", "index")
273
+ if normalized in {"US100", "NDX", "NASDAQ100"}:
274
+ return ResolvedSymbol(symbol, "^NDX", "index")
275
+ if normalized in {"US30", "DOW", "DJI"}:
276
+ return ResolvedSymbol(symbol, "^DJI", "index")
277
+ if normalized in {"BTCUSD", "BTCUSDT", "BTC"}:
278
+ return ResolvedSymbol(symbol, "BINANCE:BTCUSDT", "crypto")
279
+ if normalized in {"ETHUSD", "ETHUSDT", "ETH"}:
280
+ return ResolvedSymbol(symbol, "BINANCE:ETHUSDT", "crypto")
197
281
  if _is_metal_pair(normalized):
198
282
  return ResolvedSymbol(symbol, f"OANDA:{normalized[:3]}_{normalized[3:]}", "commodity")
199
283
  if _is_forex_pair(normalized):
@@ -215,6 +299,16 @@ def resolve_provider_symbol(provider: str, symbol: str) -> ResolvedSymbol:
215
299
  return resolve_finnhub_symbol(symbol)
216
300
  if provider_name == "alphavantage":
217
301
  normalized = _normalize(symbol)
302
+ if normalized in {"US500", "SPX500"}:
303
+ return ResolvedSymbol(symbol, "SPX", "index")
304
+ if normalized in {"US100", "NASDAQ100"}:
305
+ return ResolvedSymbol(symbol, "NDX", "index")
306
+ if normalized == "US30":
307
+ return ResolvedSymbol(symbol, "DJI", "index")
308
+ if normalized in {"BTC", "BTCUSD", "BTCUSDT"}:
309
+ return ResolvedSymbol(symbol, "BTCUSD", "crypto")
310
+ if normalized in {"ETH", "ETHUSD", "ETHUSDT"}:
311
+ return ResolvedSymbol(symbol, "ETHUSD", "crypto")
218
312
  if _is_metal_pair(normalized):
219
313
  return ResolvedSymbol(symbol, normalized, "commodity")
220
314
  if _is_forex_pair(normalized):
@@ -229,8 +323,7 @@ def resolve_provider_symbol(provider: str, symbol: str) -> ResolvedSymbol:
229
323
 
230
324
 
231
325
  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}
326
+ return SymbolResolver().matrix(symbol, providers)
234
327
 
235
328
 
236
329
  def search_symbol_catalog(query: str, limit: int = 12) -> list[SymbolSearchResult]: