borsapy 0.1.0__py3-none-any.whl

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.
borsapy/__init__.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ borsapy - Turkish Financial Markets Data Library
3
+
4
+ A yfinance-like API for BIST stocks, forex, crypto, funds, and economic data.
5
+
6
+ Examples:
7
+ >>> import borsapy as bp
8
+
9
+ # Get stock data
10
+ >>> stock = bp.Ticker("THYAO")
11
+ >>> stock.info # Real-time quote
12
+ >>> stock.history(period="1mo") # OHLCV data
13
+ >>> stock.balance_sheet # Financial statements
14
+
15
+ # Get forex/commodity data
16
+ >>> usd = bp.FX("USD")
17
+ >>> usd.current # Current rate
18
+ >>> usd.history(period="1mo") # Historical data
19
+ >>> gold = bp.FX("gram-altin")
20
+
21
+ # List all BIST companies
22
+ >>> bp.companies()
23
+ >>> bp.search_companies("banka")
24
+
25
+ # Get crypto data
26
+ >>> btc = bp.Crypto("BTCTRY")
27
+ >>> btc.current # Current price
28
+ >>> btc.history(period="1mo") # Historical OHLCV
29
+ >>> bp.crypto_pairs() # List available pairs
30
+
31
+ # Get fund data
32
+ >>> fund = bp.Fund("AAK")
33
+ >>> fund.info # Fund details
34
+ >>> fund.history(period="1mo") # Price history
35
+
36
+ # Get inflation data
37
+ >>> inf = bp.Inflation()
38
+ >>> inf.latest() # Latest TÜFE data
39
+ >>> inf.calculate(100000, "2020-01", "2024-01") # Inflation calculation
40
+ """
41
+
42
+ from borsapy.crypto import Crypto, crypto_pairs
43
+ from borsapy.exceptions import (
44
+ APIError,
45
+ AuthenticationError,
46
+ BorsapyError,
47
+ DataNotAvailableError,
48
+ InvalidIntervalError,
49
+ InvalidPeriodError,
50
+ RateLimitError,
51
+ TickerNotFoundError,
52
+ )
53
+ from borsapy.fund import Fund, search_funds
54
+ from borsapy.fx import FX
55
+ from borsapy.index import Index, index, indices
56
+ from borsapy.inflation import Inflation
57
+ from borsapy.market import companies, search_companies
58
+ from borsapy.multi import Tickers, download
59
+ from borsapy.ticker import Ticker
60
+ from borsapy.viop import VIOP
61
+
62
+ __version__ = "0.1.0"
63
+ __author__ = "Said Surucu"
64
+
65
+ __all__ = [
66
+ # Main classes
67
+ "Ticker",
68
+ "Tickers",
69
+ "FX",
70
+ "Crypto",
71
+ "Fund",
72
+ "Index",
73
+ "Inflation",
74
+ "VIOP",
75
+ # Market functions
76
+ "companies",
77
+ "search_companies",
78
+ "crypto_pairs",
79
+ "search_funds",
80
+ "download",
81
+ "index",
82
+ "indices",
83
+ # Exceptions
84
+ "BorsapyError",
85
+ "TickerNotFoundError",
86
+ "DataNotAvailableError",
87
+ "APIError",
88
+ "AuthenticationError",
89
+ "RateLimitError",
90
+ "InvalidPeriodError",
91
+ "InvalidIntervalError",
92
+ ]
@@ -0,0 +1 @@
1
+ """Data models for borsapy."""
@@ -0,0 +1,5 @@
1
+ """Data providers for borsapy."""
2
+
3
+ from borsapy._providers.base import BaseProvider
4
+
5
+ __all__ = ["BaseProvider"]
@@ -0,0 +1,94 @@
1
+ """Base provider class for all data providers."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from borsapy.cache import Cache, get_cache
8
+
9
+
10
+ class BaseProvider:
11
+ """Base class for all data providers."""
12
+
13
+ DEFAULT_HEADERS = {
14
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
15
+ "Accept": "application/json, text/plain, */*",
16
+ "Accept-Language": "tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7",
17
+ }
18
+
19
+ def __init__(
20
+ self,
21
+ timeout: float = 30.0,
22
+ cache: Cache | None = None,
23
+ ):
24
+ """
25
+ Initialize the provider.
26
+
27
+ Args:
28
+ timeout: HTTP request timeout in seconds.
29
+ cache: Cache instance to use. If None, uses global cache.
30
+ """
31
+ self._client = httpx.Client(timeout=timeout, headers=self.DEFAULT_HEADERS)
32
+ self._cache = cache or get_cache()
33
+
34
+ def close(self) -> None:
35
+ """Close the HTTP client."""
36
+ self._client.close()
37
+
38
+ def __enter__(self):
39
+ return self
40
+
41
+ def __exit__(self, exc_type, exc_val, exc_tb):
42
+ self.close()
43
+
44
+ def _get(
45
+ self,
46
+ url: str,
47
+ params: dict[str, Any] | None = None,
48
+ headers: dict[str, str] | None = None,
49
+ ) -> httpx.Response:
50
+ """
51
+ Make a GET request.
52
+
53
+ Args:
54
+ url: Request URL.
55
+ params: Query parameters.
56
+ headers: Request headers.
57
+
58
+ Returns:
59
+ HTTP response.
60
+ """
61
+ response = self._client.get(url, params=params, headers=headers)
62
+ response.raise_for_status()
63
+ return response
64
+
65
+ def _post(
66
+ self,
67
+ url: str,
68
+ data: dict[str, Any] | None = None,
69
+ json: dict[str, Any] | None = None,
70
+ headers: dict[str, str] | None = None,
71
+ ) -> httpx.Response:
72
+ """
73
+ Make a POST request.
74
+
75
+ Args:
76
+ url: Request URL.
77
+ data: Form data.
78
+ json: JSON data.
79
+ headers: Request headers.
80
+
81
+ Returns:
82
+ HTTP response.
83
+ """
84
+ response = self._client.post(url, data=data, json=json, headers=headers)
85
+ response.raise_for_status()
86
+ return response
87
+
88
+ def _cache_get(self, key: str) -> Any | None:
89
+ """Get a value from cache."""
90
+ return self._cache.get(key)
91
+
92
+ def _cache_set(self, key: str, value: Any, ttl: int) -> None:
93
+ """Set a value in cache."""
94
+ self._cache.set(key, value, ttl)
@@ -0,0 +1,230 @@
1
+ """BtcTurk provider for cryptocurrency data."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+
8
+ from borsapy._providers.base import BaseProvider
9
+ from borsapy.cache import TTL
10
+ from borsapy.exceptions import APIError, DataNotAvailableError
11
+
12
+
13
+ class BtcTurkProvider(BaseProvider):
14
+ """
15
+ Provider for cryptocurrency data from BtcTurk.
16
+
17
+ Provides:
18
+ - Real-time ticker data for crypto pairs
19
+ - Historical OHLC data
20
+ """
21
+
22
+ BASE_URL = "https://api.btcturk.com/api/v2"
23
+ GRAPH_API_URL = "https://graph-api.btcturk.com"
24
+
25
+ # Resolution mapping (minutes)
26
+ RESOLUTION_MAP = {
27
+ "1m": 1,
28
+ "5m": 5,
29
+ "15m": 15,
30
+ "30m": 30,
31
+ "1h": 60,
32
+ "4h": 240,
33
+ "1d": 1440,
34
+ "1wk": 10080,
35
+ }
36
+
37
+ def get_ticker(self, pair: str) -> dict[str, Any]:
38
+ """
39
+ Get current ticker data for a crypto pair.
40
+
41
+ Args:
42
+ pair: Trading pair (e.g., "BTCTRY", "ETHTRY", "BTCUSDT")
43
+
44
+ Returns:
45
+ Dictionary with ticker data.
46
+ """
47
+ pair = pair.upper()
48
+
49
+ cache_key = f"btcturk:ticker:{pair}"
50
+ cached = self._cache_get(cache_key)
51
+ if cached is not None:
52
+ return cached
53
+
54
+ try:
55
+ url = f"{self.BASE_URL}/ticker"
56
+ params = {"pairSymbol": pair}
57
+
58
+ response = self._client.get(url, params=params)
59
+ response.raise_for_status()
60
+ data = response.json()
61
+
62
+ if not data.get("success", False):
63
+ raise APIError(f"API error: {data.get('message', 'Unknown')}")
64
+
65
+ ticker_data = data.get("data", [])
66
+ if not ticker_data:
67
+ raise DataNotAvailableError(f"No data for pair: {pair}")
68
+
69
+ ticker = ticker_data[0] if isinstance(ticker_data, list) else ticker_data
70
+
71
+ result = {
72
+ "symbol": ticker.get("pair"),
73
+ "last": float(ticker.get("last", 0)),
74
+ "open": float(ticker.get("open", 0)),
75
+ "high": float(ticker.get("high", 0)),
76
+ "low": float(ticker.get("low", 0)),
77
+ "bid": float(ticker.get("bid", 0)),
78
+ "ask": float(ticker.get("ask", 0)),
79
+ "volume": float(ticker.get("volume", 0)),
80
+ "change": float(ticker.get("daily", 0)),
81
+ "change_percent": float(ticker.get("dailyPercent", 0)),
82
+ "timestamp": ticker.get("timestamp"),
83
+ }
84
+
85
+ self._cache_set(cache_key, result, TTL.REALTIME_PRICE)
86
+ return result
87
+
88
+ except Exception as e:
89
+ raise APIError(f"Failed to fetch ticker for {pair}: {e}") from e
90
+
91
+ def get_history(
92
+ self,
93
+ pair: str,
94
+ period: str = "1mo",
95
+ interval: str = "1d",
96
+ start: datetime | None = None,
97
+ end: datetime | None = None,
98
+ ) -> pd.DataFrame:
99
+ """
100
+ Get historical OHLC data for a crypto pair.
101
+
102
+ Args:
103
+ pair: Trading pair (e.g., "BTCTRY", "ETHTRY")
104
+ period: Data period (1d, 5d, 1mo, 3mo, 6mo, 1y)
105
+ interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d, 1wk)
106
+ start: Start date
107
+ end: End date
108
+
109
+ Returns:
110
+ DataFrame with OHLCV data.
111
+ """
112
+ pair = pair.upper()
113
+
114
+ # Calculate time range
115
+ end_dt = end or datetime.now()
116
+ if start:
117
+ start_dt = start
118
+ else:
119
+ days = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365}.get(period, 30)
120
+ start_dt = end_dt - timedelta(days=days)
121
+
122
+ from_ts = int(start_dt.timestamp())
123
+ to_ts = int(end_dt.timestamp())
124
+
125
+ cache_key = f"btcturk:history:{pair}:{interval}:{from_ts}:{to_ts}"
126
+ cached = self._cache_get(cache_key)
127
+ if cached is not None:
128
+ return cached
129
+
130
+ try:
131
+ # Get resolution in minutes
132
+ resolution = self.RESOLUTION_MAP.get(interval, 1440)
133
+
134
+ url = f"{self.GRAPH_API_URL}/v1/klines/history"
135
+ params = {
136
+ "symbol": pair,
137
+ "resolution": resolution,
138
+ "from": from_ts,
139
+ "to": to_ts,
140
+ }
141
+
142
+ response = self._client.get(url, params=params)
143
+ response.raise_for_status()
144
+ data = response.json()
145
+
146
+ # Graph API returns TradingView format
147
+ status = data.get("s", "error")
148
+ if status != "ok":
149
+ raise DataNotAvailableError(f"No data available for {pair}")
150
+
151
+ # Parse TradingView format
152
+ timestamps = data.get("t", [])
153
+ opens = data.get("o", [])
154
+ highs = data.get("h", [])
155
+ lows = data.get("l", [])
156
+ closes = data.get("c", [])
157
+ volumes = data.get("v", [])
158
+
159
+ records = []
160
+ for i in range(len(timestamps)):
161
+ records.append(
162
+ {
163
+ "Date": datetime.fromtimestamp(timestamps[i]),
164
+ "Open": float(opens[i]) if i < len(opens) else 0.0,
165
+ "High": float(highs[i]) if i < len(highs) else 0.0,
166
+ "Low": float(lows[i]) if i < len(lows) else 0.0,
167
+ "Close": float(closes[i]) if i < len(closes) else 0.0,
168
+ "Volume": float(volumes[i]) if i < len(volumes) else 0.0,
169
+ }
170
+ )
171
+
172
+ df = pd.DataFrame(records)
173
+ if not df.empty:
174
+ df.set_index("Date", inplace=True)
175
+ df.sort_index(inplace=True)
176
+
177
+ self._cache_set(cache_key, df, TTL.OHLCV_HISTORY)
178
+ return df
179
+
180
+ except Exception as e:
181
+ raise APIError(f"Failed to fetch history for {pair}: {e}") from e
182
+
183
+ def get_pairs(self, quote: str = "TRY") -> list[str]:
184
+ """
185
+ Get list of available trading pairs.
186
+
187
+ Args:
188
+ quote: Quote currency filter (TRY, USDT, BTC)
189
+
190
+ Returns:
191
+ List of trading pair symbols.
192
+ """
193
+ cache_key = f"btcturk:pairs:{quote}"
194
+ cached = self._cache_get(cache_key)
195
+ if cached is not None:
196
+ return cached
197
+
198
+ try:
199
+ url = f"{self.BASE_URL}/ticker"
200
+ response = self._client.get(url)
201
+ response.raise_for_status()
202
+ data = response.json()
203
+
204
+ if not data.get("success", False):
205
+ return []
206
+
207
+ pairs = []
208
+ quote_upper = quote.upper()
209
+ for ticker in data.get("data", []):
210
+ pair = ticker.get("pair", "")
211
+ if pair.endswith(quote_upper):
212
+ pairs.append(pair)
213
+
214
+ self._cache_set(cache_key, pairs, TTL.COMPANY_LIST)
215
+ return pairs
216
+
217
+ except Exception:
218
+ return []
219
+
220
+
221
+ # Singleton
222
+ _provider: BtcTurkProvider | None = None
223
+
224
+
225
+ def get_btcturk_provider() -> BtcTurkProvider:
226
+ """Get singleton provider instance."""
227
+ global _provider
228
+ if _provider is None:
229
+ _provider = BtcTurkProvider()
230
+ return _provider
@@ -0,0 +1,282 @@
1
+ """Doviz.com provider for forex and commodity data."""
2
+
3
+ import re
4
+ import time
5
+ from datetime import datetime, timedelta
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+ from borsapy._providers.base import BaseProvider
11
+ from borsapy.cache import TTL
12
+ from borsapy.exceptions import APIError, DataNotAvailableError
13
+
14
+
15
+ class DovizcomProvider(BaseProvider):
16
+ """
17
+ Provider for forex and commodity data from doviz.com.
18
+
19
+ Supported assets:
20
+ - Currencies: USD, EUR, GBP, JPY, CHF, CAD, AUD
21
+ - Precious Metals: gram-altin, gumus, ons, XAG-USD, XPT-USD, XPD-USD
22
+ - Energy: BRENT, WTI
23
+ - Fuel: diesel, gasoline, lpg
24
+ """
25
+
26
+ BASE_URL = "https://api.doviz.com/api/v12"
27
+ TOKEN_EXPIRY = 3600 # 1 hour
28
+
29
+ FALLBACK_TOKEN = "3e75d7fabf1c50c8b962626dd0e5ea22d8000815e1b0920d0a26afd77fcd6609"
30
+
31
+ SUPPORTED_ASSETS = {
32
+ # Currencies
33
+ "USD",
34
+ "EUR",
35
+ "GBP",
36
+ "JPY",
37
+ "CHF",
38
+ "CAD",
39
+ "AUD",
40
+ # Precious Metals (TRY)
41
+ "gram-altin",
42
+ "gumus",
43
+ # Precious Metals (USD)
44
+ "ons",
45
+ "XAG-USD",
46
+ "XPT-USD",
47
+ "XPD-USD",
48
+ # Energy
49
+ "BRENT",
50
+ "WTI",
51
+ # Fuel
52
+ "diesel",
53
+ "gasoline",
54
+ "lpg",
55
+ }
56
+
57
+ FUEL_ASSETS = {"gasoline", "diesel", "lpg"}
58
+
59
+ def __init__(self):
60
+ super().__init__()
61
+ self._token: str | None = None
62
+ self._token_expiry: float = 0
63
+
64
+ def _get_token(self) -> str:
65
+ """Get valid Bearer token."""
66
+ if self._token and time.time() < self._token_expiry:
67
+ return self._token
68
+
69
+ # Try to extract token from website
70
+ try:
71
+ token = self._extract_token()
72
+ if token:
73
+ self._token = token
74
+ self._token_expiry = time.time() + self.TOKEN_EXPIRY
75
+ return token
76
+ except Exception:
77
+ pass
78
+
79
+ # Use fallback
80
+ return self.FALLBACK_TOKEN
81
+
82
+ def _extract_token(self) -> str | None:
83
+ """Extract token from doviz.com website."""
84
+ try:
85
+ response = self._client.get("https://www.doviz.com/")
86
+ html = response.text
87
+
88
+ # Look for 64-char hex token
89
+ patterns = [
90
+ r'token["\']?\s*:\s*["\']([a-f0-9]{64})["\']',
91
+ r"Bearer\s+([a-f0-9]{64})",
92
+ ]
93
+
94
+ for pattern in patterns:
95
+ match = re.search(pattern, html, re.IGNORECASE)
96
+ if match:
97
+ return match.group(1)
98
+
99
+ return None
100
+ except Exception:
101
+ return None
102
+
103
+ def _get_headers(self, asset: str) -> dict[str, str]:
104
+ """Get request headers with token."""
105
+ if asset in ["gram-altin", "gumus", "ons"]:
106
+ origin = "https://altin.doviz.com"
107
+ else:
108
+ origin = "https://www.doviz.com"
109
+
110
+ token = self._get_token()
111
+
112
+ return {
113
+ "Accept": "*/*",
114
+ "Authorization": f"Bearer {token}",
115
+ "Origin": origin,
116
+ "Referer": f"{origin}/",
117
+ "User-Agent": self.DEFAULT_HEADERS["User-Agent"],
118
+ }
119
+
120
+ def get_current(self, asset: str) -> dict[str, Any]:
121
+ """
122
+ Get current price for an asset.
123
+
124
+ Args:
125
+ asset: Asset code (USD, EUR, gram-altin, BRENT, etc.)
126
+
127
+ Returns:
128
+ Dictionary with current price data.
129
+ """
130
+ asset = asset.upper() if asset.upper() in self.SUPPORTED_ASSETS else asset
131
+
132
+ if asset not in self.SUPPORTED_ASSETS:
133
+ raise DataNotAvailableError(
134
+ f"Unsupported asset: {asset}. Supported: {sorted(self.SUPPORTED_ASSETS)}"
135
+ )
136
+
137
+ cache_key = f"dovizcom:current:{asset}"
138
+ cached = self._cache_get(cache_key)
139
+ if cached:
140
+ return cached
141
+
142
+ try:
143
+ if asset in self.FUEL_ASSETS:
144
+ data = self._get_from_archive(asset, days=7)
145
+ else:
146
+ data = self._get_from_daily(asset)
147
+
148
+ if not data:
149
+ raise DataNotAvailableError(f"No data for {asset}")
150
+
151
+ result = {
152
+ "symbol": asset,
153
+ "last": float(data.get("close", 0)),
154
+ "open": float(data.get("open", 0)),
155
+ "high": float(data.get("highest", 0)),
156
+ "low": float(data.get("lowest", 0)),
157
+ "update_time": self._parse_timestamp(data.get("update_date")),
158
+ }
159
+
160
+ self._cache_set(cache_key, result, TTL.FX_RATES)
161
+ return result
162
+
163
+ except Exception as e:
164
+ raise APIError(f"Failed to fetch {asset}: {e}") from e
165
+
166
+ def get_history(
167
+ self,
168
+ asset: str,
169
+ period: str = "1mo",
170
+ start: datetime | None = None,
171
+ end: datetime | None = None,
172
+ ) -> pd.DataFrame:
173
+ """
174
+ Get historical data for an asset.
175
+
176
+ Args:
177
+ asset: Asset code.
178
+ period: Period (1d, 5d, 1mo, 3mo, 6mo, 1y).
179
+ start: Start date.
180
+ end: End date.
181
+
182
+ Returns:
183
+ DataFrame with OHLC data.
184
+ """
185
+ asset = asset.upper() if asset.upper() in self.SUPPORTED_ASSETS else asset
186
+
187
+ if asset not in self.SUPPORTED_ASSETS:
188
+ raise DataNotAvailableError(f"Unsupported asset: {asset}")
189
+
190
+ # Calculate date range
191
+ end_dt = end or datetime.now()
192
+ if start:
193
+ start_dt = start
194
+ else:
195
+ days = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365}.get(period, 30)
196
+ start_dt = end_dt - timedelta(days=days)
197
+
198
+ cache_key = f"dovizcom:history:{asset}:{start_dt.date()}:{end_dt.date()}"
199
+ cached = self._cache_get(cache_key)
200
+ if cached is not None:
201
+ return cached
202
+
203
+ try:
204
+ url = f"{self.BASE_URL}/assets/{asset}/archive"
205
+ params = {
206
+ "start": int(start_dt.timestamp()),
207
+ "end": int(end_dt.timestamp()),
208
+ }
209
+
210
+ response = self._client.get(url, headers=self._get_headers(asset), params=params)
211
+ response.raise_for_status()
212
+ data = response.json()
213
+
214
+ archive = data.get("data", {}).get("archive", [])
215
+
216
+ records = []
217
+ for item in archive:
218
+ records.append(
219
+ {
220
+ "Date": self._parse_timestamp(item.get("update_date")),
221
+ "Open": float(item.get("open", 0)),
222
+ "High": float(item.get("highest", 0)),
223
+ "Low": float(item.get("lowest", 0)),
224
+ "Close": float(item.get("close", 0)),
225
+ }
226
+ )
227
+
228
+ df = pd.DataFrame(records)
229
+ if not df.empty:
230
+ df.set_index("Date", inplace=True)
231
+ df.sort_index(inplace=True)
232
+
233
+ self._cache_set(cache_key, df, TTL.OHLCV_HISTORY)
234
+ return df
235
+
236
+ except Exception as e:
237
+ raise APIError(f"Failed to fetch history for {asset}: {e}") from e
238
+
239
+ def _get_from_daily(self, asset: str) -> dict | None:
240
+ """Get latest data from daily endpoint."""
241
+ url = f"{self.BASE_URL}/assets/{asset}/daily"
242
+ response = self._client.get(url, headers=self._get_headers(asset), params={"limit": 1})
243
+ response.raise_for_status()
244
+ data = response.json()
245
+
246
+ archive = data.get("data", {}).get("archive", [])
247
+ return archive[0] if archive else None
248
+
249
+ def _get_from_archive(self, asset: str, days: int = 7) -> dict | None:
250
+ """Get latest data from archive endpoint."""
251
+ end_time = int(time.time())
252
+ start_time = end_time - (days * 86400)
253
+
254
+ url = f"{self.BASE_URL}/assets/{asset}/archive"
255
+ params = {"start": start_time, "end": end_time}
256
+
257
+ response = self._client.get(url, headers=self._get_headers(asset), params=params)
258
+ response.raise_for_status()
259
+ data = response.json()
260
+
261
+ archive = data.get("data", {}).get("archive", [])
262
+ return archive[-1] if archive else None
263
+
264
+ def _parse_timestamp(self, ts: Any) -> datetime:
265
+ """Parse timestamp to datetime."""
266
+ if isinstance(ts, (int, float)):
267
+ return datetime.fromtimestamp(ts)
268
+ if isinstance(ts, datetime):
269
+ return ts
270
+ return datetime.now()
271
+
272
+
273
+ # Singleton
274
+ _provider: DovizcomProvider | None = None
275
+
276
+
277
+ def get_dovizcom_provider() -> DovizcomProvider:
278
+ """Get singleton provider instance."""
279
+ global _provider
280
+ if _provider is None:
281
+ _provider = DovizcomProvider()
282
+ return _provider