@drico2008/fincli 0.3.1 → 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.
- package/README.md +217 -217
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +29 -27
- package/fincli/app/analysis/analyzer.py +34 -34
- package/fincli/app/analysis/assistant_context.py +3 -3
- package/fincli/app/cli/commands.py +33 -27
- package/fincli/app/cli/router.py +1633 -1105
- package/fincli/app/diagnostics/__init__.py +2 -0
- package/fincli/app/diagnostics/capabilities.py +44 -0
- package/fincli/app/diagnostics/runtime.py +106 -0
- package/fincli/app/main.py +6 -1
- package/fincli/app/modules/economic_calendar.py +512 -512
- package/fincli/app/modules/portfolio_risk.py +305 -305
- package/fincli/app/modules/trading.py +142 -0
- package/fincli/app/plugins/loader.py +72 -72
- package/fincli/app/providers/market/finnhub_provider.py +51 -2
- package/fincli/app/providers/market/symbols.py +95 -2
- package/fincli/app/providers/reliability.py +82 -65
- package/fincli/app/research/__init__.py +8 -8
- package/fincli/app/research/engine.py +119 -112
- package/fincli/app/research/exporter.py +91 -91
- package/fincli/app/research/formatter.py +25 -24
- package/fincli/app/research/models.py +22 -21
- package/fincli/app/research/prompt_builder.py +53 -51
- package/fincli/app/services/data_quality.py +27 -0
- package/fincli/app/services/data_trust.py +117 -0
- package/fincli/app/services/macro_data.py +158 -50
- package/fincli/app/services/market_data.py +183 -79
- package/fincli/app/services/market_overview.py +131 -142
- package/fincli/app/services/news_aggregator.py +95 -95
- package/fincli/app/storage/config.py +6 -3
- package/fincli/app/storage/database.py +130 -117
- package/fincli/app/storage/provider_metrics.py +61 -61
- package/fincli/app/storage/secrets.py +128 -128
- package/npm/bin/fincli.js +65 -65
- package/package.json +7 -7
- 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.
|
|
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
|
-
|
|
15
|
-
from
|
|
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
|
-
|
|
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]:
|