@drico2008/fincli 0.1.3 → 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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -684
  3. package/fincli/__init__.py +3 -3
  4. package/fincli/app/agents/__init__.py +5 -0
  5. package/fincli/app/agents/registry.py +76 -0
  6. package/fincli/app/analysis/ai_prompts.py +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  8. package/fincli/app/analysis/assistant_context.py +187 -160
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/indicators.py +1 -1
  12. package/fincli/app/analysis/market_structure.py +1 -1
  13. package/fincli/app/analysis/multi_timeframe.py +180 -0
  14. package/fincli/app/analysis/trading_methods.py +144 -0
  15. package/fincli/app/cli/commands.py +105 -77
  16. package/fincli/app/cli/router.py +2143 -1121
  17. package/fincli/app/connectors/__init__.py +5 -0
  18. package/fincli/app/connectors/catalog.py +148 -0
  19. package/fincli/app/connectors/news_connectors.py +412 -0
  20. package/fincli/app/modules/alerts.py +80 -0
  21. package/fincli/app/modules/economic_calendar.py +374 -1
  22. package/fincli/app/modules/reports.py +151 -0
  23. package/fincli/app/modules/scanner.py +111 -93
  24. package/fincli/app/modules/session_history.py +113 -0
  25. package/fincli/app/modules/transactions.py +84 -84
  26. package/fincli/app/modules/user_profile.py +84 -0
  27. package/fincli/app/plugins/loader.py +72 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +8 -7
  29. package/fincli/app/providers/ai/gemini_provider.py +8 -7
  30. package/fincli/app/providers/ai/groq_provider.py +8 -7
  31. package/fincli/app/providers/ai/huggingface_provider.py +8 -7
  32. package/fincli/app/providers/ai/manager.py +60 -60
  33. package/fincli/app/providers/ai/openai_provider.py +8 -7
  34. package/fincli/app/providers/ai/openrouter_provider.py +8 -7
  35. package/fincli/app/providers/ai/together_provider.py +8 -7
  36. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  37. package/fincli/app/providers/market/base.py +98 -77
  38. package/fincli/app/providers/market/custom_provider.py +186 -169
  39. package/fincli/app/providers/market/manager.py +85 -2
  40. package/fincli/app/providers/market/news_provider.py +4 -4
  41. package/fincli/app/providers/market/symbols.py +143 -0
  42. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  43. package/fincli/app/providers/market/yfinance_provider.py +1 -1
  44. package/fincli/app/research/__init__.py +7 -0
  45. package/fincli/app/research/engine.py +75 -0
  46. package/fincli/app/research/formatter.py +22 -0
  47. package/fincli/app/research/models.py +18 -0
  48. package/fincli/app/research/prompt_builder.py +47 -0
  49. package/fincli/app/services/macro_data.py +50 -0
  50. package/fincli/app/services/market_data.py +203 -203
  51. package/fincli/app/services/news_aggregator.py +90 -0
  52. package/fincli/app/services/web_research.py +267 -0
  53. package/fincli/app/storage/cache.py +2 -2
  54. package/fincli/app/storage/config.py +122 -88
  55. package/fincli/app/storage/database.py +201 -85
  56. package/fincli/app/storage/secrets.py +12 -3
  57. package/fincli/app/tui/components.py +68 -50
  58. package/fincli/app/tui/layout.py +270 -258
  59. package/fincli/app/tui/market_provider_selector.py +6 -1
  60. package/fincli/app/tui/model_selector.py +11 -3
  61. package/fincli/app/tui/theme.py +134 -74
  62. package/fincli/app/utils/formatting.py +125 -12
  63. package/npm/bin/fincli.js +9 -2
  64. package/package.json +23 -23
  65. package/pyproject.toml +35 -35
@@ -1,11 +1,12 @@
1
- """Gemini provider placeholder for Phase 2."""
1
+ """Gemini provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class GeminiProvider(BaseAIProvider):
8
- name = "gemini"
7
+ from fincli.app.providers.ai.http_provider import GeminiProviderHTTP
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Gemini client belum diimplementasi di Phase 1.")
9
+
10
+ class GeminiProvider(GeminiProviderHTTP):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__(api_key or os.getenv("GEMINI_API_KEY"))
@@ -1,11 +1,12 @@
1
- """Groq provider placeholder for Phase 2."""
1
+ """Groq provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class GroqProvider(BaseAIProvider):
8
- name = "groq"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Groq client belum diimplementasi di Phase 1.")
9
+
10
+ class GroqProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("groq", "https://api.groq.com/openai/v1", api_key or os.getenv("GROQ_API_KEY"))
@@ -1,11 +1,12 @@
1
- """HuggingFace provider placeholder for Phase 2."""
1
+ """HuggingFace provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class HuggingFaceProvider(BaseAIProvider):
8
- name = "huggingface"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("HuggingFace client belum diimplementasi di Phase 1.")
9
+
10
+ class HuggingFaceProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("huggingface", "https://router.huggingface.co/v1", api_key or os.getenv("HUGGINGFACE_API_KEY"))
@@ -1,60 +1,60 @@
1
- """AI provider catalog and selection state."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- import os
7
-
8
- from fincli.app.providers.ai.base import BaseAIProvider
9
- from fincli.app.providers.ai.http_provider import AnthropicProviderHTTP, GeminiProviderHTTP, OpenAICompatibleProvider
10
-
11
-
12
- @dataclass(frozen=True, slots=True)
13
- class AIProviderInfo:
14
- name: str
15
- env_key: str
16
- default_model: str
17
- status: str = "configured"
18
-
19
-
20
- AI_PROVIDERS: dict[str, AIProviderInfo] = {
21
- "openrouter": AIProviderInfo("openrouter", "OPENROUTER_API_KEY", "openai/gpt-4o-mini"),
22
- "gemini": AIProviderInfo("gemini", "GEMINI_API_KEY", "gemini-1.5-flash"),
23
- "anthropic": AIProviderInfo("anthropic", "ANTHROPIC_API_KEY", "claude-3-5-sonnet-latest"),
24
- "openai": AIProviderInfo("openai", "OPENAI_API_KEY", "gpt-4o-mini"),
25
- "together": AIProviderInfo("together", "TOGETHER_API_KEY", "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"),
26
- "huggingface": AIProviderInfo("huggingface", "HUGGINGFACE_API_KEY", "meta-llama/Llama-3.1-8B-Instruct"),
27
- "groq": AIProviderInfo("groq", "GROQ_API_KEY", "llama-3.1-70b-versatile"),
28
- }
29
-
30
-
31
- class AIProviderManager:
32
- """AI provider catalog and factory."""
33
-
34
- def list_providers(self) -> list[AIProviderInfo]:
35
- return list(AI_PROVIDERS.values())
36
-
37
- def get(self, name: str) -> AIProviderInfo | None:
38
- return AI_PROVIDERS.get(name.lower())
39
-
40
- def create(self, name: str) -> BaseAIProvider:
41
- provider = self.get(name)
42
- if provider is None:
43
- raise ValueError(f"AI provider tidak dikenal: {name}")
44
-
45
- api_key = os.getenv(provider.env_key)
46
- if provider.name == "openrouter":
47
- return OpenAICompatibleProvider(provider.name, "https://openrouter.ai/api/v1", api_key)
48
- if provider.name == "openai":
49
- return OpenAICompatibleProvider(provider.name, "https://api.openai.com/v1", api_key)
50
- if provider.name == "together":
51
- return OpenAICompatibleProvider(provider.name, "https://api.together.xyz/v1", api_key)
52
- if provider.name == "groq":
53
- return OpenAICompatibleProvider(provider.name, "https://api.groq.com/openai/v1", api_key)
54
- if provider.name == "huggingface":
55
- return OpenAICompatibleProvider(provider.name, "https://router.huggingface.co/v1", api_key)
56
- if provider.name == "gemini":
57
- return GeminiProviderHTTP(api_key)
58
- if provider.name == "anthropic":
59
- return AnthropicProviderHTTP(api_key)
60
- raise ValueError(f"AI provider tidak didukung: {name}")
1
+ """AI provider catalog and selection state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+
8
+ from fincli.app.providers.ai.base import BaseAIProvider
9
+ from fincli.app.providers.ai.http_provider import AnthropicProviderHTTP, GeminiProviderHTTP, OpenAICompatibleProvider
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class AIProviderInfo:
14
+ name: str
15
+ env_key: str
16
+ default_model: str
17
+ status: str = "configured"
18
+
19
+
20
+ AI_PROVIDERS: dict[str, AIProviderInfo] = {
21
+ "openrouter": AIProviderInfo("openrouter", "OPENROUTER_API_KEY", "openai/gpt-4o-mini"),
22
+ "gemini": AIProviderInfo("gemini", "GEMINI_API_KEY", "gemini-1.5-flash"),
23
+ "anthropic": AIProviderInfo("anthropic", "ANTHROPIC_API_KEY", "claude-3-5-sonnet-latest"),
24
+ "openai": AIProviderInfo("openai", "OPENAI_API_KEY", "gpt-4o-mini"),
25
+ "together": AIProviderInfo("together", "TOGETHER_API_KEY", "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"),
26
+ "huggingface": AIProviderInfo("huggingface", "HUGGINGFACE_API_KEY", "meta-llama/Llama-3.1-8B-Instruct"),
27
+ "groq": AIProviderInfo("groq", "GROQ_API_KEY", "llama-3.1-70b-versatile"),
28
+ }
29
+
30
+
31
+ class AIProviderManager:
32
+ """AI provider catalog and factory."""
33
+
34
+ def list_providers(self) -> list[AIProviderInfo]:
35
+ return list(AI_PROVIDERS.values())
36
+
37
+ def get(self, name: str) -> AIProviderInfo | None:
38
+ return AI_PROVIDERS.get(name.lower())
39
+
40
+ def create(self, name: str) -> BaseAIProvider:
41
+ provider = self.get(name)
42
+ if provider is None:
43
+ raise ValueError(f"AI provider tidak dikenal: {name}")
44
+
45
+ api_key = os.getenv(provider.env_key)
46
+ if provider.name == "openrouter":
47
+ return OpenAICompatibleProvider(provider.name, "https://openrouter.ai/api/v1", api_key)
48
+ if provider.name == "openai":
49
+ return OpenAICompatibleProvider(provider.name, "https://api.openai.com/v1", api_key)
50
+ if provider.name == "together":
51
+ return OpenAICompatibleProvider(provider.name, "https://api.together.xyz/v1", api_key)
52
+ if provider.name == "groq":
53
+ return OpenAICompatibleProvider(provider.name, "https://api.groq.com/openai/v1", api_key)
54
+ if provider.name == "huggingface":
55
+ return OpenAICompatibleProvider(provider.name, "https://router.huggingface.co/v1", api_key)
56
+ if provider.name == "gemini":
57
+ return GeminiProviderHTTP(api_key)
58
+ if provider.name == "anthropic":
59
+ return AnthropicProviderHTTP(api_key)
60
+ raise ValueError(f"AI provider tidak didukung: {name}")
@@ -1,11 +1,12 @@
1
- """OpenAI provider placeholder for Phase 2."""
1
+ """OpenAI provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class OpenAIProvider(BaseAIProvider):
8
- name = "openai"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("OpenAI client belum diimplementasi di Phase 1.")
9
+
10
+ class OpenAIProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("openai", "https://api.openai.com/v1", api_key or os.getenv("OPENAI_API_KEY"))
@@ -1,11 +1,12 @@
1
- """OpenRouter provider placeholder for Phase 2."""
1
+ """OpenRouter provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class OpenRouterProvider(BaseAIProvider):
8
- name = "openrouter"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("OpenRouter client belum diimplementasi di Phase 1.")
9
+
10
+ class OpenRouterProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("openrouter", "https://openrouter.ai/api/v1", api_key or os.getenv("OPENROUTER_API_KEY"))
@@ -1,11 +1,12 @@
1
- """Together AI provider placeholder for Phase 2."""
1
+ """Together AI provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class TogetherProvider(BaseAIProvider):
8
- name = "together"
7
+ from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Together AI client belum diimplementasi di Phase 1.")
9
+
10
+ class TogetherProvider(OpenAICompatibleProvider):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__("together", "https://api.together.xyz/v1", api_key or os.getenv("TOGETHER_API_KEY"))
@@ -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
- class BaseMarketProvider(Protocol):
62
- name: str
63
-
64
- async def quote(self, symbol: str) -> Quote:
65
- """Fetch a single quote."""
66
-
67
- async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
68
- """Fetch historical candles."""
69
-
70
- async def status(self) -> ProviderStatus:
71
- """Return provider health and realtime/delayed status."""
72
-
73
- async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
74
- """Fetch latest news items."""
75
-
76
- async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
77
- """Fetch a compact fundamental snapshot."""
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."""