@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.
Files changed (69) hide show
  1. package/README.md +644 -0
  2. package/fincli/__init__.py +3 -0
  3. package/fincli/app/__init__.py +1 -0
  4. package/fincli/app/analysis/__init__.py +1 -0
  5. package/fincli/app/analysis/ai_prompts.py +33 -0
  6. package/fincli/app/analysis/analyzer.py +119 -0
  7. package/fincli/app/analysis/assistant_context.py +161 -0
  8. package/fincli/app/analysis/indicators.py +143 -0
  9. package/fincli/app/analysis/market_structure.py +106 -0
  10. package/fincli/app/analysis/technical_debate.py +251 -0
  11. package/fincli/app/analysis/technical_signal.py +203 -0
  12. package/fincli/app/cli/__init__.py +1 -0
  13. package/fincli/app/cli/autocomplete.py +17 -0
  14. package/fincli/app/cli/commands.py +82 -0
  15. package/fincli/app/cli/router.py +1257 -0
  16. package/fincli/app/main.py +16 -0
  17. package/fincli/app/modules/__init__.py +1 -0
  18. package/fincli/app/modules/economic_calendar.py +139 -0
  19. package/fincli/app/modules/exporter.py +51 -0
  20. package/fincli/app/modules/journal.py +65 -0
  21. package/fincli/app/modules/journal_analytics.py +70 -0
  22. package/fincli/app/modules/portfolio.py +34 -0
  23. package/fincli/app/modules/scanner.py +105 -0
  24. package/fincli/app/modules/transactions.py +84 -0
  25. package/fincli/app/modules/watchlist.py +25 -0
  26. package/fincli/app/providers/__init__.py +1 -0
  27. package/fincli/app/providers/ai/__init__.py +1 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +11 -0
  29. package/fincli/app/providers/ai/base.py +29 -0
  30. package/fincli/app/providers/ai/gemini_provider.py +11 -0
  31. package/fincli/app/providers/ai/groq_provider.py +11 -0
  32. package/fincli/app/providers/ai/http_provider.py +145 -0
  33. package/fincli/app/providers/ai/huggingface_provider.py +11 -0
  34. package/fincli/app/providers/ai/manager.py +60 -0
  35. package/fincli/app/providers/ai/openai_provider.py +11 -0
  36. package/fincli/app/providers/ai/openrouter_provider.py +11 -0
  37. package/fincli/app/providers/ai/together_provider.py +11 -0
  38. package/fincli/app/providers/market/__init__.py +1 -0
  39. package/fincli/app/providers/market/base.py +77 -0
  40. package/fincli/app/providers/market/custom_provider.py +169 -0
  41. package/fincli/app/providers/market/finnhub_provider.py +187 -0
  42. package/fincli/app/providers/market/manager.py +123 -0
  43. package/fincli/app/providers/market/news_provider.py +28 -0
  44. package/fincli/app/providers/market/symbols.py +182 -0
  45. package/fincli/app/providers/market/twelvedata_provider.py +167 -0
  46. package/fincli/app/providers/market/yfinance_provider.py +447 -0
  47. package/fincli/app/services/__init__.py +1 -0
  48. package/fincli/app/services/market_data.py +203 -0
  49. package/fincli/app/services/market_overview.py +111 -0
  50. package/fincli/app/storage/__init__.py +1 -0
  51. package/fincli/app/storage/cache.py +38 -0
  52. package/fincli/app/storage/config.py +114 -0
  53. package/fincli/app/storage/database.py +101 -0
  54. package/fincli/app/storage/market_cache.py +92 -0
  55. package/fincli/app/tui/__init__.py +1 -0
  56. package/fincli/app/tui/components.py +55 -0
  57. package/fincli/app/tui/layout.py +261 -0
  58. package/fincli/app/tui/market_provider_selector.py +267 -0
  59. package/fincli/app/tui/model_selector.py +412 -0
  60. package/fincli/app/tui/theme.py +157 -0
  61. package/fincli/app/utils/__init__.py +1 -0
  62. package/fincli/app/utils/errors.py +33 -0
  63. package/fincli/app/utils/formatting.py +17 -0
  64. package/fincli/app/utils/logger.py +19 -0
  65. package/npm/bin/fincli.js +35 -0
  66. package/npm/postinstall.js +72 -0
  67. package/package.json +23 -0
  68. package/pyproject.toml +31 -0
  69. 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