@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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Alpha Vantage market provider adapter."""
|
|
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_provider_symbol
|
|
12
|
+
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AlphaVantageProvider:
|
|
16
|
+
name = "alphavantage"
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
api_key: str | None,
|
|
21
|
+
base_url: str = "https://www.alphavantage.co/query",
|
|
22
|
+
client: httpx.AsyncClient | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.api_key = api_key or ""
|
|
25
|
+
self.base_url = base_url
|
|
26
|
+
self._client = client
|
|
27
|
+
|
|
28
|
+
async def quote(self, symbol: str) -> Quote:
|
|
29
|
+
resolved = resolve_provider_symbol(self.name, symbol)
|
|
30
|
+
if resolved.asset_class == "forex":
|
|
31
|
+
data = await self._get(
|
|
32
|
+
{
|
|
33
|
+
"function": "CURRENCY_EXCHANGE_RATE",
|
|
34
|
+
"from_currency": resolved.symbol[:3],
|
|
35
|
+
"to_currency": resolved.symbol[3:],
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
rate = data.get("Realtime Currency Exchange Rate", {})
|
|
39
|
+
price = _safe_float(rate.get("5. Exchange Rate"))
|
|
40
|
+
if price is None:
|
|
41
|
+
raise ProviderError(f"Alpha Vantage tidak mengembalikan FX quote valid untuk {symbol}.")
|
|
42
|
+
return Quote(
|
|
43
|
+
symbol=resolved.symbol,
|
|
44
|
+
price=price,
|
|
45
|
+
currency=resolved.symbol[3:],
|
|
46
|
+
provider=self.name,
|
|
47
|
+
timestamp=_parse_datetime(rate.get("6. Last Refreshed")) or datetime.now(),
|
|
48
|
+
status="plan-dependent",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
data = await self._get({"function": "GLOBAL_QUOTE", "symbol": resolved.symbol})
|
|
52
|
+
quote = data.get("Global Quote", {})
|
|
53
|
+
price = _safe_float(quote.get("05. price"))
|
|
54
|
+
if price is None:
|
|
55
|
+
raise ProviderError(f"Alpha Vantage tidak mengembalikan quote valid untuk {symbol}.")
|
|
56
|
+
return Quote(
|
|
57
|
+
symbol=str(quote.get("01. symbol") or resolved.symbol).upper(),
|
|
58
|
+
price=price,
|
|
59
|
+
currency="USD",
|
|
60
|
+
provider=self.name,
|
|
61
|
+
timestamp=_parse_datetime(quote.get("07. latest trading day")) or datetime.now(),
|
|
62
|
+
status="plan-dependent",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
|
|
66
|
+
resolved = resolve_provider_symbol(self.name, symbol)
|
|
67
|
+
if resolved.asset_class == "forex":
|
|
68
|
+
data = await self._get(
|
|
69
|
+
{
|
|
70
|
+
"function": "FX_DAILY",
|
|
71
|
+
"from_symbol": resolved.symbol[:3],
|
|
72
|
+
"to_symbol": resolved.symbol[3:],
|
|
73
|
+
"outputsize": "compact",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
series = data.get("Time Series FX (Daily)", {})
|
|
77
|
+
else:
|
|
78
|
+
data = await self._get(
|
|
79
|
+
{
|
|
80
|
+
"function": "TIME_SERIES_DAILY_ADJUSTED",
|
|
81
|
+
"symbol": resolved.symbol,
|
|
82
|
+
"outputsize": "compact",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
series = data.get("Time Series (Daily)", {})
|
|
86
|
+
|
|
87
|
+
if not isinstance(series, dict) or not series:
|
|
88
|
+
raise ProviderError(f"Alpha Vantage OHLCV kosong untuk {symbol}.")
|
|
89
|
+
candles = [_parse_daily_candle(day, payload) for day, payload in series.items() if isinstance(payload, dict)]
|
|
90
|
+
candles.sort(key=lambda candle: candle.timestamp)
|
|
91
|
+
return candles
|
|
92
|
+
|
|
93
|
+
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
94
|
+
resolved = resolve_provider_symbol(self.name, symbol)
|
|
95
|
+
data = await self._get({"function": "NEWS_SENTIMENT", "tickers": resolved.symbol, "limit": limit})
|
|
96
|
+
feed = data.get("feed", [])
|
|
97
|
+
if not isinstance(feed, list):
|
|
98
|
+
return []
|
|
99
|
+
return [
|
|
100
|
+
NewsItem(
|
|
101
|
+
title=str(item.get("title") or "Untitled"),
|
|
102
|
+
source=str(item.get("source") or self.name),
|
|
103
|
+
url=item.get("url"),
|
|
104
|
+
published_at=_parse_datetime(item.get("time_published")),
|
|
105
|
+
summary=str(item.get("summary") or ""),
|
|
106
|
+
)
|
|
107
|
+
for item in feed[:limit]
|
|
108
|
+
if isinstance(item, dict)
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
112
|
+
resolved = resolve_provider_symbol(self.name, symbol)
|
|
113
|
+
data = await self._get({"function": "OVERVIEW", "symbol": resolved.symbol})
|
|
114
|
+
return FundamentalSnapshot(
|
|
115
|
+
symbol=str(data.get("Symbol") or resolved.symbol).upper(),
|
|
116
|
+
provider=self.name,
|
|
117
|
+
currency=str(data.get("Currency") or "USD"),
|
|
118
|
+
market_cap=_safe_float(data.get("MarketCapitalization")),
|
|
119
|
+
pe_ratio=_safe_float(data.get("PERatio")),
|
|
120
|
+
eps=_safe_float(data.get("EPS")),
|
|
121
|
+
revenue=_safe_float(data.get("RevenueTTM")),
|
|
122
|
+
beta=_safe_float(data.get("Beta")),
|
|
123
|
+
sector=data.get("Sector"),
|
|
124
|
+
industry=data.get("Industry"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def status(self) -> ProviderStatus:
|
|
128
|
+
status = "configured" if self.api_key else "unavailable"
|
|
129
|
+
message = "Alpha Vantage provider configured." if self.api_key else "Requires ALPHA_VANTAGE_API_KEY."
|
|
130
|
+
return ProviderStatus(name=self.name, realtime=False, status=status, message=message)
|
|
131
|
+
|
|
132
|
+
async def _get(self, params: dict[str, object]) -> dict[str, Any]:
|
|
133
|
+
if not self.api_key:
|
|
134
|
+
raise ProviderError("API key Alpha Vantage belum diatur.", "Gunakan /news_model key alphavantage <api_key>.")
|
|
135
|
+
close_client = self._client is None
|
|
136
|
+
client = self._client or httpx.AsyncClient(timeout=30)
|
|
137
|
+
try:
|
|
138
|
+
response = await client.get(self.base_url, params={**params, "apikey": self.api_key})
|
|
139
|
+
if response.status_code == 429:
|
|
140
|
+
raise RateLimitError("Alpha Vantage terkena rate limit.")
|
|
141
|
+
response.raise_for_status()
|
|
142
|
+
data = response.json()
|
|
143
|
+
if not isinstance(data, dict):
|
|
144
|
+
raise ProviderError("Response Alpha Vantage bukan JSON object.")
|
|
145
|
+
message = data.get("Note") or data.get("Information")
|
|
146
|
+
if message:
|
|
147
|
+
raise RateLimitError(f"Alpha Vantage membatasi request: {message}")
|
|
148
|
+
if "Error Message" in data:
|
|
149
|
+
raise ProviderError(f"Alpha Vantage gagal: {data['Error Message']}")
|
|
150
|
+
return data
|
|
151
|
+
except httpx.TimeoutException as exc:
|
|
152
|
+
raise ProviderError("Alpha Vantage timeout.") from exc
|
|
153
|
+
except httpx.HTTPStatusError as exc:
|
|
154
|
+
raise ProviderError(f"Alpha Vantage gagal: HTTP {exc.response.status_code}.") from exc
|
|
155
|
+
except ValueError as exc:
|
|
156
|
+
raise ProviderError("Response Alpha Vantage bukan JSON valid.") from exc
|
|
157
|
+
finally:
|
|
158
|
+
if close_client:
|
|
159
|
+
await client.aclose()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_daily_candle(day: str, payload: dict[str, Any]) -> Candle:
|
|
163
|
+
return Candle(
|
|
164
|
+
timestamp=_parse_datetime(day) or datetime.now(),
|
|
165
|
+
open=float(payload.get("1. open")),
|
|
166
|
+
high=float(payload.get("2. high")),
|
|
167
|
+
low=float(payload.get("3. low")),
|
|
168
|
+
close=float(payload.get("4. close")),
|
|
169
|
+
volume=float(payload.get("6. volume") or payload.get("5. volume") or 0),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _safe_float(value: Any) -> float | None:
|
|
174
|
+
try:
|
|
175
|
+
if value in {None, "None", "-", ""}:
|
|
176
|
+
return None
|
|
177
|
+
return float(value)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
183
|
+
if not value:
|
|
184
|
+
return None
|
|
185
|
+
text = str(value)
|
|
186
|
+
for fmt in ("%Y%m%dT%H%M%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
187
|
+
try:
|
|
188
|
+
return datetime.strptime(text, fmt)
|
|
189
|
+
except ValueError:
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
193
|
+
except ValueError:
|
|
194
|
+
return None
|
|
@@ -1,77 +1,98 @@
|
|
|
1
|
-
"""Base market provider contract for future provider implementations."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from datetime import datetime
|
|
7
|
-
from typing import Protocol
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass(frozen=True, slots=True)
|
|
11
|
-
class Quote:
|
|
12
|
-
symbol: str
|
|
13
|
-
price: float | None
|
|
14
|
-
currency: str
|
|
15
|
-
provider: str
|
|
16
|
-
timestamp: datetime
|
|
17
|
-
status: str
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@dataclass(frozen=True, slots=True)
|
|
21
|
-
class Candle:
|
|
22
|
-
timestamp: datetime
|
|
23
|
-
open: float
|
|
24
|
-
high: float
|
|
25
|
-
low: float
|
|
26
|
-
close: float
|
|
27
|
-
volume: float
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@dataclass(frozen=True, slots=True)
|
|
31
|
-
class NewsItem:
|
|
32
|
-
title: str
|
|
33
|
-
source: str
|
|
34
|
-
url: str | None
|
|
35
|
-
published_at: datetime | None
|
|
36
|
-
summary: str = ""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@dataclass(frozen=True, slots=True)
|
|
40
|
-
class FundamentalSnapshot:
|
|
41
|
-
symbol: str
|
|
42
|
-
provider: str
|
|
43
|
-
currency: str
|
|
44
|
-
market_cap: float | None = None
|
|
45
|
-
pe_ratio: float | None = None
|
|
46
|
-
eps: float | None = None
|
|
47
|
-
revenue: float | None = None
|
|
48
|
-
beta: float | None = None
|
|
49
|
-
sector: str | None = None
|
|
50
|
-
industry: str | None = None
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@dataclass(frozen=True, slots=True)
|
|
54
|
-
class ProviderStatus:
|
|
55
|
-
name: str
|
|
56
|
-
realtime: bool
|
|
57
|
-
status: str
|
|
58
|
-
message: str
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1
|
+
"""Base market provider contract for future provider implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Quote:
|
|
12
|
+
symbol: str
|
|
13
|
+
price: float | None
|
|
14
|
+
currency: str
|
|
15
|
+
provider: str
|
|
16
|
+
timestamp: datetime
|
|
17
|
+
status: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Candle:
|
|
22
|
+
timestamp: datetime
|
|
23
|
+
open: float
|
|
24
|
+
high: float
|
|
25
|
+
low: float
|
|
26
|
+
close: float
|
|
27
|
+
volume: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class NewsItem:
|
|
32
|
+
title: str
|
|
33
|
+
source: str
|
|
34
|
+
url: str | None
|
|
35
|
+
published_at: datetime | None
|
|
36
|
+
summary: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class FundamentalSnapshot:
|
|
41
|
+
symbol: str
|
|
42
|
+
provider: str
|
|
43
|
+
currency: str
|
|
44
|
+
market_cap: float | None = None
|
|
45
|
+
pe_ratio: float | None = None
|
|
46
|
+
eps: float | None = None
|
|
47
|
+
revenue: float | None = None
|
|
48
|
+
beta: float | None = None
|
|
49
|
+
sector: str | None = None
|
|
50
|
+
industry: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ProviderStatus:
|
|
55
|
+
name: str
|
|
56
|
+
realtime: bool
|
|
57
|
+
status: str
|
|
58
|
+
message: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class ProviderEntitlement:
|
|
63
|
+
provider: str
|
|
64
|
+
status: str
|
|
65
|
+
realtime_label: str
|
|
66
|
+
asset_classes: tuple[str, ...]
|
|
67
|
+
capabilities: tuple[str, ...]
|
|
68
|
+
limitations: tuple[str, ...] = ()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class SymbolSearchResult:
|
|
73
|
+
symbol: str
|
|
74
|
+
name: str
|
|
75
|
+
asset_class: str
|
|
76
|
+
exchange: str = ""
|
|
77
|
+
currency: str = ""
|
|
78
|
+
provider_symbols: dict[str, str] | None = None
|
|
79
|
+
notes: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BaseMarketProvider(Protocol):
|
|
83
|
+
name: str
|
|
84
|
+
|
|
85
|
+
async def quote(self, symbol: str) -> Quote:
|
|
86
|
+
"""Fetch a single quote."""
|
|
87
|
+
|
|
88
|
+
async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
|
|
89
|
+
"""Fetch historical candles."""
|
|
90
|
+
|
|
91
|
+
async def status(self) -> ProviderStatus:
|
|
92
|
+
"""Return provider health and realtime/delayed status."""
|
|
93
|
+
|
|
94
|
+
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
95
|
+
"""Fetch latest news items."""
|
|
96
|
+
|
|
97
|
+
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
98
|
+
"""Fetch a compact fundamental snapshot."""
|