@drico2008/fincli 0.1.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 +644 -0
- package/fincli/__init__.py +3 -0
- package/fincli/app/__init__.py +1 -0
- package/fincli/app/analysis/__init__.py +1 -0
- package/fincli/app/analysis/ai_prompts.py +33 -0
- package/fincli/app/analysis/analyzer.py +119 -0
- package/fincli/app/analysis/assistant_context.py +161 -0
- package/fincli/app/analysis/indicators.py +143 -0
- package/fincli/app/analysis/market_structure.py +106 -0
- package/fincli/app/analysis/technical_debate.py +251 -0
- package/fincli/app/analysis/technical_signal.py +203 -0
- package/fincli/app/cli/__init__.py +1 -0
- package/fincli/app/cli/autocomplete.py +17 -0
- package/fincli/app/cli/commands.py +82 -0
- package/fincli/app/cli/router.py +1257 -0
- package/fincli/app/main.py +16 -0
- package/fincli/app/modules/__init__.py +1 -0
- package/fincli/app/modules/economic_calendar.py +139 -0
- package/fincli/app/modules/exporter.py +51 -0
- package/fincli/app/modules/journal.py +65 -0
- package/fincli/app/modules/journal_analytics.py +70 -0
- package/fincli/app/modules/portfolio.py +34 -0
- package/fincli/app/modules/scanner.py +105 -0
- package/fincli/app/modules/transactions.py +84 -0
- package/fincli/app/modules/watchlist.py +25 -0
- package/fincli/app/providers/__init__.py +1 -0
- package/fincli/app/providers/ai/__init__.py +1 -0
- package/fincli/app/providers/ai/anthropic_provider.py +11 -0
- package/fincli/app/providers/ai/base.py +29 -0
- package/fincli/app/providers/ai/gemini_provider.py +11 -0
- package/fincli/app/providers/ai/groq_provider.py +11 -0
- package/fincli/app/providers/ai/http_provider.py +145 -0
- package/fincli/app/providers/ai/huggingface_provider.py +11 -0
- package/fincli/app/providers/ai/manager.py +60 -0
- package/fincli/app/providers/ai/openai_provider.py +11 -0
- package/fincli/app/providers/ai/openrouter_provider.py +11 -0
- package/fincli/app/providers/ai/together_provider.py +11 -0
- package/fincli/app/providers/market/__init__.py +1 -0
- package/fincli/app/providers/market/base.py +77 -0
- package/fincli/app/providers/market/custom_provider.py +169 -0
- package/fincli/app/providers/market/finnhub_provider.py +187 -0
- package/fincli/app/providers/market/manager.py +123 -0
- package/fincli/app/providers/market/news_provider.py +28 -0
- package/fincli/app/providers/market/symbols.py +182 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -0
- package/fincli/app/providers/market/yfinance_provider.py +447 -0
- package/fincli/app/services/__init__.py +1 -0
- package/fincli/app/services/market_data.py +203 -0
- package/fincli/app/services/market_overview.py +111 -0
- package/fincli/app/storage/__init__.py +1 -0
- package/fincli/app/storage/cache.py +38 -0
- package/fincli/app/storage/config.py +114 -0
- package/fincli/app/storage/database.py +101 -0
- package/fincli/app/storage/market_cache.py +92 -0
- package/fincli/app/tui/__init__.py +1 -0
- package/fincli/app/tui/components.py +55 -0
- package/fincli/app/tui/layout.py +261 -0
- package/fincli/app/tui/market_provider_selector.py +267 -0
- package/fincli/app/tui/model_selector.py +412 -0
- package/fincli/app/tui/theme.py +157 -0
- package/fincli/app/utils/__init__.py +1 -0
- package/fincli/app/utils/errors.py +33 -0
- package/fincli/app/utils/formatting.py +17 -0
- package/fincli/app/utils/logger.py +19 -0
- package/npm/bin/fincli.js +35 -0
- package/npm/postinstall.js +72 -0
- package/package.json +23 -0
- package/pyproject.toml +31 -0
- package/requirements.txt +9 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""HTTP-based AI providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
|
|
8
|
+
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAICompatibleProvider(BaseAIProvider):
|
|
12
|
+
"""Provider for OpenAI-compatible chat completion APIs."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
name: str,
|
|
17
|
+
base_url: str,
|
|
18
|
+
api_key: str | None,
|
|
19
|
+
client: httpx.AsyncClient | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.name = name
|
|
22
|
+
self.base_url = base_url.rstrip("/")
|
|
23
|
+
self.api_key = api_key or ""
|
|
24
|
+
self._client = client
|
|
25
|
+
|
|
26
|
+
async def complete(self, request: AIRequest) -> AIResponse:
|
|
27
|
+
if not self.api_key:
|
|
28
|
+
raise ProviderError(
|
|
29
|
+
f"API key untuk provider {self.name} belum diatur.",
|
|
30
|
+
"Isi API key di .env lalu jalankan ulang FinCLI.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
payload = {
|
|
34
|
+
"model": request.model,
|
|
35
|
+
"messages": [{"role": "user", "content": request.prompt}],
|
|
36
|
+
"temperature": request.temperature,
|
|
37
|
+
}
|
|
38
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
39
|
+
|
|
40
|
+
close_client = self._client is None
|
|
41
|
+
client = self._client or httpx.AsyncClient(timeout=request.timeout_seconds)
|
|
42
|
+
try:
|
|
43
|
+
response = await client.post(f"{self.base_url}/chat/completions", json=payload, headers=headers)
|
|
44
|
+
if response.status_code == 429:
|
|
45
|
+
raise RateLimitError(f"Provider {self.name} terkena rate limit.")
|
|
46
|
+
response.raise_for_status()
|
|
47
|
+
data = response.json()
|
|
48
|
+
content = _extract_openai_content(data)
|
|
49
|
+
return AIResponse(provider=self.name, model=request.model, content=content)
|
|
50
|
+
except httpx.TimeoutException as exc:
|
|
51
|
+
raise ProviderError(f"AI provider {self.name} timeout.") from exc
|
|
52
|
+
except httpx.HTTPStatusError as exc:
|
|
53
|
+
raise ProviderError(f"AI provider {self.name} gagal: HTTP {exc.response.status_code}.") from exc
|
|
54
|
+
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
|
55
|
+
raise ProviderError(f"Response AI provider {self.name} tidak valid.") from exc
|
|
56
|
+
finally:
|
|
57
|
+
if close_client:
|
|
58
|
+
await client.aclose()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GeminiProviderHTTP(BaseAIProvider):
|
|
62
|
+
"""Minimal Gemini generateContent provider."""
|
|
63
|
+
|
|
64
|
+
name = "gemini"
|
|
65
|
+
|
|
66
|
+
def __init__(self, api_key: str | None, base_url: str = "https://generativelanguage.googleapis.com/v1beta") -> None:
|
|
67
|
+
self.api_key = api_key or ""
|
|
68
|
+
self.base_url = base_url.rstrip("/")
|
|
69
|
+
|
|
70
|
+
async def complete(self, request: AIRequest) -> AIResponse:
|
|
71
|
+
if not self.api_key:
|
|
72
|
+
raise ProviderError("API key untuk provider gemini belum diatur.")
|
|
73
|
+
url = f"{self.base_url}/models/{request.model}:generateContent?key={self.api_key}"
|
|
74
|
+
payload = {"contents": [{"parts": [{"text": request.prompt}]}]}
|
|
75
|
+
try:
|
|
76
|
+
async with httpx.AsyncClient(timeout=request.timeout_seconds) as client:
|
|
77
|
+
response = await client.post(url, json=payload)
|
|
78
|
+
if response.status_code == 429:
|
|
79
|
+
raise RateLimitError("Provider gemini terkena rate limit.")
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
data = response.json()
|
|
82
|
+
content = data["candidates"][0]["content"]["parts"][0]["text"]
|
|
83
|
+
return AIResponse(provider=self.name, model=request.model, content=str(content))
|
|
84
|
+
except httpx.TimeoutException as exc:
|
|
85
|
+
raise ProviderError("AI provider gemini timeout.") from exc
|
|
86
|
+
except httpx.HTTPStatusError as exc:
|
|
87
|
+
raise ProviderError(f"AI provider gemini gagal: HTTP {exc.response.status_code}.") from exc
|
|
88
|
+
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
|
89
|
+
raise ProviderError("Response AI provider gemini tidak valid.") from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AnthropicProviderHTTP(BaseAIProvider):
|
|
93
|
+
"""Minimal Anthropic Messages API provider."""
|
|
94
|
+
|
|
95
|
+
name = "anthropic"
|
|
96
|
+
|
|
97
|
+
def __init__(self, api_key: str | None, base_url: str = "https://api.anthropic.com/v1") -> None:
|
|
98
|
+
self.api_key = api_key or ""
|
|
99
|
+
self.base_url = base_url.rstrip("/")
|
|
100
|
+
|
|
101
|
+
async def complete(self, request: AIRequest) -> AIResponse:
|
|
102
|
+
if not self.api_key:
|
|
103
|
+
raise ProviderError("API key untuk provider anthropic belum diatur.")
|
|
104
|
+
payload = {
|
|
105
|
+
"model": request.model,
|
|
106
|
+
"max_tokens": 1200,
|
|
107
|
+
"temperature": request.temperature,
|
|
108
|
+
"messages": [{"role": "user", "content": request.prompt}],
|
|
109
|
+
}
|
|
110
|
+
headers = {
|
|
111
|
+
"x-api-key": self.api_key,
|
|
112
|
+
"anthropic-version": "2023-06-01",
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
}
|
|
115
|
+
try:
|
|
116
|
+
async with httpx.AsyncClient(timeout=request.timeout_seconds) as client:
|
|
117
|
+
response = await client.post(f"{self.base_url}/messages", json=payload, headers=headers)
|
|
118
|
+
if response.status_code == 429:
|
|
119
|
+
raise RateLimitError("Provider anthropic terkena rate limit.")
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
data = response.json()
|
|
122
|
+
content = data["content"][0]["text"]
|
|
123
|
+
return AIResponse(provider=self.name, model=request.model, content=str(content))
|
|
124
|
+
except httpx.TimeoutException as exc:
|
|
125
|
+
raise ProviderError("AI provider anthropic timeout.") from exc
|
|
126
|
+
except httpx.HTTPStatusError as exc:
|
|
127
|
+
raise ProviderError(f"AI provider anthropic gagal: HTTP {exc.response.status_code}.") from exc
|
|
128
|
+
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
|
129
|
+
raise ProviderError("Response AI provider anthropic tidak valid.") from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _extract_openai_content(data: dict[str, object]) -> str:
|
|
133
|
+
choices = data["choices"]
|
|
134
|
+
if not isinstance(choices, list) or not choices:
|
|
135
|
+
raise ValueError("choices kosong")
|
|
136
|
+
first = choices[0]
|
|
137
|
+
if not isinstance(first, dict):
|
|
138
|
+
raise ValueError("choice tidak valid")
|
|
139
|
+
message = first.get("message")
|
|
140
|
+
if isinstance(message, dict) and message.get("content"):
|
|
141
|
+
return str(message["content"])
|
|
142
|
+
text = first.get("text")
|
|
143
|
+
if text:
|
|
144
|
+
return str(text)
|
|
145
|
+
raise ValueError("content kosong")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""HuggingFace provider placeholder for Phase 2."""
|
|
2
|
+
|
|
3
|
+
from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
|
|
4
|
+
from fincli.app.utils.errors import ProviderError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HuggingFaceProvider(BaseAIProvider):
|
|
8
|
+
name = "huggingface"
|
|
9
|
+
|
|
10
|
+
async def complete(self, request: AIRequest) -> AIResponse:
|
|
11
|
+
raise ProviderError("HuggingFace client belum diimplementasi di Phase 1.")
|
|
@@ -0,0 +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}")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""OpenAI provider placeholder for Phase 2."""
|
|
2
|
+
|
|
3
|
+
from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
|
|
4
|
+
from fincli.app.utils.errors import ProviderError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OpenAIProvider(BaseAIProvider):
|
|
8
|
+
name = "openai"
|
|
9
|
+
|
|
10
|
+
async def complete(self, request: AIRequest) -> AIResponse:
|
|
11
|
+
raise ProviderError("OpenAI client belum diimplementasi di Phase 1.")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""OpenRouter provider placeholder for Phase 2."""
|
|
2
|
+
|
|
3
|
+
from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
|
|
4
|
+
from fincli.app.utils.errors import ProviderError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OpenRouterProvider(BaseAIProvider):
|
|
8
|
+
name = "openrouter"
|
|
9
|
+
|
|
10
|
+
async def complete(self, request: AIRequest) -> AIResponse:
|
|
11
|
+
raise ProviderError("OpenRouter client belum diimplementasi di Phase 1.")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Together AI provider placeholder for Phase 2."""
|
|
2
|
+
|
|
3
|
+
from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
|
|
4
|
+
from fincli.app.utils.errors import ProviderError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TogetherProvider(BaseAIProvider):
|
|
8
|
+
name = "together"
|
|
9
|
+
|
|
10
|
+
async def complete(self, request: AIRequest) -> AIResponse:
|
|
11
|
+
raise ProviderError("Together AI client belum diimplementasi di Phase 1.")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Market data provider modules."""
|
|
@@ -0,0 +1,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
|
+
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."""
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Custom HTTP market API provider.
|
|
2
|
+
|
|
3
|
+
Expected endpoint contract:
|
|
4
|
+
- GET /quote/{symbol}
|
|
5
|
+
- GET /history/{symbol}?period=6mo&interval=1d
|
|
6
|
+
- GET /news/{symbol}?limit=5
|
|
7
|
+
- GET /fundamentals/{symbol}
|
|
8
|
+
|
|
9
|
+
The provider accepts common JSON key variants so users can adapt simple APIs
|
|
10
|
+
without changing FinCLI core.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from fincli.app.providers.market.base import (
|
|
21
|
+
BaseMarketProvider,
|
|
22
|
+
Candle,
|
|
23
|
+
FundamentalSnapshot,
|
|
24
|
+
NewsItem,
|
|
25
|
+
ProviderStatus,
|
|
26
|
+
Quote,
|
|
27
|
+
)
|
|
28
|
+
from fincli.app.utils.errors import ProviderError, RateLimitError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CustomMarketProvider(BaseMarketProvider):
|
|
32
|
+
name = "custom"
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_key: str | None,
|
|
37
|
+
base_url: str,
|
|
38
|
+
client: httpx.AsyncClient | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.api_key = api_key or ""
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self._client = client
|
|
43
|
+
|
|
44
|
+
async def quote(self, symbol: str) -> Quote:
|
|
45
|
+
data = await self._get(f"/quote/{symbol.upper()}")
|
|
46
|
+
return Quote(
|
|
47
|
+
symbol=str(data.get("symbol") or symbol).upper(),
|
|
48
|
+
price=_safe_float(data.get("price") or data.get("last") or data.get("last_price")),
|
|
49
|
+
currency=str(data.get("currency") or "USD"),
|
|
50
|
+
provider=self.name,
|
|
51
|
+
timestamp=_parse_datetime(data.get("timestamp")) or datetime.now(),
|
|
52
|
+
status=str(data.get("status") or "custom"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
|
|
56
|
+
data = await self._get(f"/history/{symbol.upper()}", params={"period": period, "interval": interval})
|
|
57
|
+
raw_items = data.get("candles") if isinstance(data, dict) else data
|
|
58
|
+
if not isinstance(raw_items, list):
|
|
59
|
+
raise ProviderError("Response history custom provider tidak valid.")
|
|
60
|
+
candles: list[Candle] = []
|
|
61
|
+
for item in raw_items:
|
|
62
|
+
if not isinstance(item, dict):
|
|
63
|
+
continue
|
|
64
|
+
candles.append(
|
|
65
|
+
Candle(
|
|
66
|
+
timestamp=_parse_datetime(item.get("timestamp") or item.get("date")) or datetime.now(),
|
|
67
|
+
open=float(item.get("open") or item.get("o")),
|
|
68
|
+
high=float(item.get("high") or item.get("h")),
|
|
69
|
+
low=float(item.get("low") or item.get("l")),
|
|
70
|
+
close=float(item.get("close") or item.get("c")),
|
|
71
|
+
volume=float(item.get("volume") or item.get("v") or 0),
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
if not candles:
|
|
75
|
+
raise ProviderError(f"Data OHLCV kosong untuk {symbol}.")
|
|
76
|
+
return candles
|
|
77
|
+
|
|
78
|
+
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
79
|
+
data = await self._get(f"/news/{symbol.upper()}", params={"limit": limit})
|
|
80
|
+
raw_items = data.get("news") if isinstance(data, dict) else data
|
|
81
|
+
if not isinstance(raw_items, list):
|
|
82
|
+
raise ProviderError("Response news custom provider tidak valid.")
|
|
83
|
+
items: list[NewsItem] = []
|
|
84
|
+
for item in raw_items[:limit]:
|
|
85
|
+
if not isinstance(item, dict):
|
|
86
|
+
continue
|
|
87
|
+
items.append(
|
|
88
|
+
NewsItem(
|
|
89
|
+
title=str(item.get("title") or "Untitled"),
|
|
90
|
+
source=str(item.get("source") or self.name),
|
|
91
|
+
url=item.get("url"),
|
|
92
|
+
published_at=_parse_datetime(item.get("published_at") or item.get("timestamp")),
|
|
93
|
+
summary=str(item.get("summary") or ""),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return items
|
|
97
|
+
|
|
98
|
+
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
99
|
+
data = await self._get(f"/fundamentals/{symbol.upper()}")
|
|
100
|
+
return FundamentalSnapshot(
|
|
101
|
+
symbol=str(data.get("symbol") or symbol).upper(),
|
|
102
|
+
provider=self.name,
|
|
103
|
+
currency=str(data.get("currency") or "USD"),
|
|
104
|
+
market_cap=_safe_float(data.get("market_cap") or data.get("marketCap")),
|
|
105
|
+
pe_ratio=_safe_float(data.get("pe_ratio") or data.get("trailingPE")),
|
|
106
|
+
eps=_safe_float(data.get("eps") or data.get("trailingEps")),
|
|
107
|
+
revenue=_safe_float(data.get("revenue") or data.get("totalRevenue")),
|
|
108
|
+
beta=_safe_float(data.get("beta")),
|
|
109
|
+
sector=data.get("sector"),
|
|
110
|
+
industry=data.get("industry"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def status(self) -> ProviderStatus:
|
|
114
|
+
status = "configured" if self.api_key else "unavailable"
|
|
115
|
+
message = "Custom provider configured." if self.api_key else "Requires MARKET_DATA_API_KEY."
|
|
116
|
+
return ProviderStatus(name=self.name, realtime=True, status=status, message=message)
|
|
117
|
+
|
|
118
|
+
async def _get(self, path: str, params: dict[str, object] | None = None) -> Any:
|
|
119
|
+
if not self.api_key:
|
|
120
|
+
raise ProviderError(
|
|
121
|
+
"API key custom market provider belum diatur.",
|
|
122
|
+
"Isi MARKET_DATA_API_KEY di .env.",
|
|
123
|
+
)
|
|
124
|
+
if not self.base_url:
|
|
125
|
+
raise ProviderError(
|
|
126
|
+
"Base URL custom market provider belum diatur.",
|
|
127
|
+
"Isi MARKET_DATA_BASE_URL di .env.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
close_client = self._client is None
|
|
131
|
+
client = self._client or httpx.AsyncClient(timeout=30)
|
|
132
|
+
headers = {"X-API-Key": self.api_key, "Authorization": f"Bearer {self.api_key}"}
|
|
133
|
+
try:
|
|
134
|
+
response = await client.get(f"{self.base_url}{path}", params=params, headers=headers)
|
|
135
|
+
if response.status_code == 429:
|
|
136
|
+
raise RateLimitError("Custom market provider terkena rate limit.")
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
return response.json()
|
|
139
|
+
except httpx.TimeoutException as exc:
|
|
140
|
+
raise ProviderError("Custom market provider timeout.") from exc
|
|
141
|
+
except httpx.HTTPStatusError as exc:
|
|
142
|
+
raise ProviderError(f"Custom market provider gagal: HTTP {exc.response.status_code}.") from exc
|
|
143
|
+
except ValueError as exc:
|
|
144
|
+
raise ProviderError("Response custom market provider bukan JSON valid.") from exc
|
|
145
|
+
finally:
|
|
146
|
+
if close_client:
|
|
147
|
+
await client.aclose()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _safe_float(value: Any) -> float | None:
|
|
151
|
+
try:
|
|
152
|
+
if value is None:
|
|
153
|
+
return None
|
|
154
|
+
return float(value)
|
|
155
|
+
except (TypeError, ValueError):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
160
|
+
if value is None:
|
|
161
|
+
return None
|
|
162
|
+
if isinstance(value, (int, float)):
|
|
163
|
+
return datetime.fromtimestamp(value)
|
|
164
|
+
if isinstance(value, str):
|
|
165
|
+
try:
|
|
166
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
167
|
+
except ValueError:
|
|
168
|
+
return None
|
|
169
|
+
return None
|