borsapy 0.4.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 +134 -0
- borsapy/_models/__init__.py +1 -0
- borsapy/_providers/__init__.py +5 -0
- borsapy/_providers/base.py +94 -0
- borsapy/_providers/bist_index.py +150 -0
- borsapy/_providers/btcturk.py +230 -0
- borsapy/_providers/canlidoviz.py +773 -0
- borsapy/_providers/dovizcom.py +869 -0
- borsapy/_providers/dovizcom_calendar.py +276 -0
- borsapy/_providers/dovizcom_tahvil.py +172 -0
- borsapy/_providers/hedeffiyat.py +376 -0
- borsapy/_providers/isin.py +247 -0
- borsapy/_providers/isyatirim.py +943 -0
- borsapy/_providers/isyatirim_screener.py +468 -0
- borsapy/_providers/kap.py +534 -0
- borsapy/_providers/paratic.py +278 -0
- borsapy/_providers/tcmb.py +317 -0
- borsapy/_providers/tefas.py +802 -0
- borsapy/_providers/viop.py +204 -0
- borsapy/bond.py +162 -0
- borsapy/cache.py +86 -0
- borsapy/calendar.py +272 -0
- borsapy/crypto.py +153 -0
- borsapy/exceptions.py +64 -0
- borsapy/fund.py +471 -0
- borsapy/fx.py +388 -0
- borsapy/index.py +285 -0
- borsapy/inflation.py +166 -0
- borsapy/market.py +53 -0
- borsapy/multi.py +227 -0
- borsapy/screener.py +365 -0
- borsapy/ticker.py +1196 -0
- borsapy/viop.py +162 -0
- borsapy-0.4.0.dist-info/METADATA +969 -0
- borsapy-0.4.0.dist-info/RECORD +37 -0
- borsapy-0.4.0.dist-info/WHEEL +4 -0
- borsapy-0.4.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
"""Doviz.com provider for forex and commodity data.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- HTML scraping for bank_rates and institution_rates (no token required)
|
|
5
|
+
|
|
6
|
+
Note: For currencies, metals, and energy use canlidoviz.py instead (token-free).
|
|
7
|
+
This provider is mainly used for bank_rates and institution_rates scraping.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from bs4 import BeautifulSoup
|
|
17
|
+
|
|
18
|
+
from borsapy._providers.base import BaseProvider
|
|
19
|
+
from borsapy.cache import TTL
|
|
20
|
+
from borsapy.exceptions import APIError, DataNotAvailableError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DovizcomProvider(BaseProvider):
|
|
24
|
+
"""
|
|
25
|
+
Provider for forex and commodity data from doviz.com.
|
|
26
|
+
|
|
27
|
+
Note: For currencies, metals, energy and commodities use canlidoviz.py (token-free).
|
|
28
|
+
This provider is mainly used for bank_rates and institution_rates HTML scraping.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
BASE_URL = "https://api.doviz.com/api/v12"
|
|
32
|
+
KUR_BASE_URL = "https://kur.doviz.com"
|
|
33
|
+
TOKEN_EXPIRY = 3600 # 1 hour
|
|
34
|
+
|
|
35
|
+
FALLBACK_TOKEN = "7e2a5e914861aac18902c544e17f1156e08e5245c113470f74bcd5402f1a926d"
|
|
36
|
+
|
|
37
|
+
# Bank slug mapping for kur.doviz.com
|
|
38
|
+
BANK_SLUGS = {
|
|
39
|
+
"kapalicarsi": "kapalicarsi",
|
|
40
|
+
"altinkaynak": "altinkaynak",
|
|
41
|
+
"harem": "harem",
|
|
42
|
+
"odaci": "odaci",
|
|
43
|
+
"venus": "venus",
|
|
44
|
+
"getirfinans": "getirfinans",
|
|
45
|
+
"akbank": "akbank",
|
|
46
|
+
"albaraka": "albaraka-turk",
|
|
47
|
+
"alternatifbank": "alternatif-bank",
|
|
48
|
+
"anadolubank": "anadolubank",
|
|
49
|
+
"cepteteb": "cepteteb",
|
|
50
|
+
"denizbank": "denizbank",
|
|
51
|
+
"destekbank": "destekbank",
|
|
52
|
+
"dunyakatilim": "dunya-katilim",
|
|
53
|
+
"emlakkatilim": "emlak-katilim",
|
|
54
|
+
"enpara": "enpara",
|
|
55
|
+
"fibabanka": "fibabanka",
|
|
56
|
+
"garanti": "garanti-bbva",
|
|
57
|
+
"hadi": "hadi",
|
|
58
|
+
"halkbank": "halkbank",
|
|
59
|
+
"hayatfinans": "hayat-finans",
|
|
60
|
+
"hsbc": "hsbc",
|
|
61
|
+
"ing": "ing-bank",
|
|
62
|
+
"isbank": "isbankasi",
|
|
63
|
+
"kuveytturk": "kuveyt-turk",
|
|
64
|
+
"tcmb": "merkez-bankasi",
|
|
65
|
+
"misyonbank": "misyon-bank",
|
|
66
|
+
"odeabank": "odeabank",
|
|
67
|
+
"qnb": "qnb-finansbank",
|
|
68
|
+
"sekerbank": "sekerbank",
|
|
69
|
+
"turkiyefinans": "turkiye-finans",
|
|
70
|
+
"vakifbank": "vakifbank",
|
|
71
|
+
"vakifkatilim": "vakif-katilim",
|
|
72
|
+
"yapikredi": "yapikredi",
|
|
73
|
+
"ziraat": "ziraat-bankasi",
|
|
74
|
+
"ziraatkatilim": "ziraat-katilim",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Currency slug mapping for kur.doviz.com
|
|
78
|
+
CURRENCY_SLUGS = {
|
|
79
|
+
"USD": "amerikan-dolari",
|
|
80
|
+
"EUR": "euro",
|
|
81
|
+
"GBP": "sterlin",
|
|
82
|
+
"CHF": "isvicre-frangi",
|
|
83
|
+
"CAD": "kanada-dolari",
|
|
84
|
+
"AUD": "avustralya-dolari",
|
|
85
|
+
"JPY": "japon-yeni",
|
|
86
|
+
"RUB": "rus-rublesi",
|
|
87
|
+
"AED": "birlesik-arap-emirlikleri-dirhemi",
|
|
88
|
+
"DKK": "danimarka-kronu",
|
|
89
|
+
"SEK": "isvec-kronu",
|
|
90
|
+
"NOK": "norvec-kronu",
|
|
91
|
+
"KWD": "kuveyt-dinari",
|
|
92
|
+
"ZAR": "guney-afrika-randi",
|
|
93
|
+
"SAR": "suudi-arabistan-riyali",
|
|
94
|
+
"PLN": "polonya-zlotisi",
|
|
95
|
+
"RON": "romen-leyi",
|
|
96
|
+
"CNY": "cin-yuani",
|
|
97
|
+
"HKD": "hong-kong-dolari",
|
|
98
|
+
"KRW": "guney-kore-wonu",
|
|
99
|
+
"QAR": "katar-riyali",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Precious metal slug mapping for altin.doviz.com institution rates
|
|
103
|
+
METAL_SLUGS = {
|
|
104
|
+
"gram-altin": "gram-altin",
|
|
105
|
+
"gram-gumus": "gumus",
|
|
106
|
+
"ons-altin": "ons",
|
|
107
|
+
"gram-platin": "gram-platin",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Asset to API slug mapping (for history/current endpoints)
|
|
111
|
+
# Some assets use different slugs in API vs user-facing names
|
|
112
|
+
HISTORY_API_SLUGS = {
|
|
113
|
+
"gram-gumus": "gumus",
|
|
114
|
+
"ons-altin": "ons",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Institution ID mapping for history API
|
|
118
|
+
# Pattern: {institution_id}-{metal_slug} → e.g., "1-gram-altin" for Akbank gram gold
|
|
119
|
+
# IDs discovered from altin.doviz.com/{institution}/{metal} pages
|
|
120
|
+
INSTITUTION_IDS = {
|
|
121
|
+
"akbank": 1,
|
|
122
|
+
"qnb-finansbank": 2,
|
|
123
|
+
"halkbank": 3,
|
|
124
|
+
"isbankasi": 4,
|
|
125
|
+
"vakifbank": 5,
|
|
126
|
+
"yapikredi": 6,
|
|
127
|
+
"ziraat-bankasi": 7,
|
|
128
|
+
"garanti-bbva": 8,
|
|
129
|
+
"sekerbank": 9,
|
|
130
|
+
"denizbank": 10,
|
|
131
|
+
"hsbc": 12,
|
|
132
|
+
"turkiye-finans": 13,
|
|
133
|
+
"ziraat-katilim": 14,
|
|
134
|
+
"vakif-katilim": 15,
|
|
135
|
+
"ing-bank": 16,
|
|
136
|
+
"kuveyt-turk": 17,
|
|
137
|
+
"albaraka-turk": 18,
|
|
138
|
+
"enpara": 19,
|
|
139
|
+
"kapalicarsi": 20,
|
|
140
|
+
"odaci": 22,
|
|
141
|
+
"harem": 23,
|
|
142
|
+
"altinkaynak": 24,
|
|
143
|
+
"hayat-finans": 29,
|
|
144
|
+
"emlak-katilim": 30,
|
|
145
|
+
"fibabanka": 31,
|
|
146
|
+
"odeabank": 36,
|
|
147
|
+
"getirfinans": 37,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Note: SUPPORTED_ASSETS is kept for backward compatibility but
|
|
151
|
+
# all these assets should use canlidoviz provider (token-free).
|
|
152
|
+
# This list is mainly used for validation in get_current/get_history
|
|
153
|
+
# which require token authentication that may be unreliable.
|
|
154
|
+
SUPPORTED_ASSETS = {
|
|
155
|
+
# Currencies
|
|
156
|
+
"USD",
|
|
157
|
+
"EUR",
|
|
158
|
+
"GBP",
|
|
159
|
+
"JPY",
|
|
160
|
+
"CHF",
|
|
161
|
+
"CAD",
|
|
162
|
+
"AUD",
|
|
163
|
+
# Precious Metals (TRY)
|
|
164
|
+
"gram-altin",
|
|
165
|
+
"gram-gumus",
|
|
166
|
+
"gram-platin",
|
|
167
|
+
# Precious Metals (USD)
|
|
168
|
+
"ons-altin",
|
|
169
|
+
"XAG-USD",
|
|
170
|
+
"XPT-USD",
|
|
171
|
+
"XPD-USD",
|
|
172
|
+
# Energy
|
|
173
|
+
"BRENT",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def __init__(self):
|
|
177
|
+
super().__init__()
|
|
178
|
+
self._token: str | None = None
|
|
179
|
+
self._token_expiry: float = 0
|
|
180
|
+
|
|
181
|
+
def _get_token(self) -> str:
|
|
182
|
+
"""Get valid Bearer token."""
|
|
183
|
+
if self._token and time.time() < self._token_expiry:
|
|
184
|
+
return self._token
|
|
185
|
+
|
|
186
|
+
# Try to extract token from website
|
|
187
|
+
try:
|
|
188
|
+
token = self._extract_token()
|
|
189
|
+
if token:
|
|
190
|
+
self._token = token
|
|
191
|
+
self._token_expiry = time.time() + self.TOKEN_EXPIRY
|
|
192
|
+
return token
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
# Use fallback
|
|
197
|
+
return self.FALLBACK_TOKEN
|
|
198
|
+
|
|
199
|
+
def _extract_token(self) -> str | None:
|
|
200
|
+
"""Extract token from doviz.com website."""
|
|
201
|
+
try:
|
|
202
|
+
response = self._client.get("https://www.doviz.com/")
|
|
203
|
+
html = response.text
|
|
204
|
+
|
|
205
|
+
# Look for 64-char hex token
|
|
206
|
+
patterns = [
|
|
207
|
+
r'token["\']?\s*:\s*["\']([a-f0-9]{64})["\']',
|
|
208
|
+
r"Bearer\s+([a-f0-9]{64})",
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
for pattern in patterns:
|
|
212
|
+
match = re.search(pattern, html, re.IGNORECASE)
|
|
213
|
+
if match:
|
|
214
|
+
return match.group(1)
|
|
215
|
+
|
|
216
|
+
return None
|
|
217
|
+
except Exception:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def _get_headers(self, asset: str) -> dict[str, str]:
|
|
221
|
+
"""Get request headers with token."""
|
|
222
|
+
if asset in ["gram-altin", "gumus", "ons"]:
|
|
223
|
+
origin = "https://altin.doviz.com"
|
|
224
|
+
elif asset.upper() in self.CURRENCY_SLUGS:
|
|
225
|
+
origin = "https://kur.doviz.com"
|
|
226
|
+
else:
|
|
227
|
+
origin = "https://www.doviz.com"
|
|
228
|
+
|
|
229
|
+
token = self._get_token()
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"Accept": "*/*",
|
|
233
|
+
"Authorization": f"Bearer {token}",
|
|
234
|
+
"Origin": origin,
|
|
235
|
+
"Referer": f"{origin}/",
|
|
236
|
+
"User-Agent": self.DEFAULT_HEADERS["User-Agent"],
|
|
237
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
def get_current(self, asset: str) -> dict[str, Any]:
|
|
241
|
+
"""
|
|
242
|
+
Get current price for an asset via doviz.com API.
|
|
243
|
+
|
|
244
|
+
Note: For currencies, metals, energy and commodities use canlidoviz provider instead.
|
|
245
|
+
This method requires token authentication which may be unreliable.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
asset: Asset code.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Dictionary with current price data.
|
|
252
|
+
"""
|
|
253
|
+
asset = asset.upper() if asset.upper() in self.SUPPORTED_ASSETS else asset
|
|
254
|
+
|
|
255
|
+
if asset not in self.SUPPORTED_ASSETS:
|
|
256
|
+
raise DataNotAvailableError(
|
|
257
|
+
f"Unsupported asset: {asset}. Supported: {sorted(self.SUPPORTED_ASSETS)}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
cache_key = f"dovizcom:current:{asset}"
|
|
261
|
+
cached = self._cache_get(cache_key)
|
|
262
|
+
if cached:
|
|
263
|
+
return cached
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
data = self._get_from_daily(asset)
|
|
267
|
+
|
|
268
|
+
if not data:
|
|
269
|
+
raise DataNotAvailableError(f"No data for {asset}")
|
|
270
|
+
|
|
271
|
+
result = {
|
|
272
|
+
"symbol": asset,
|
|
273
|
+
"last": float(data.get("close", 0)),
|
|
274
|
+
"open": float(data.get("open", 0)),
|
|
275
|
+
"high": float(data.get("highest", 0)),
|
|
276
|
+
"low": float(data.get("lowest", 0)),
|
|
277
|
+
"update_time": self._parse_timestamp(data.get("update_date")),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
self._cache_set(cache_key, result, TTL.FX_RATES)
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
raise APIError(f"Failed to fetch {asset}: {e}") from e
|
|
285
|
+
|
|
286
|
+
def get_history(
|
|
287
|
+
self,
|
|
288
|
+
asset: str,
|
|
289
|
+
period: str = "1mo",
|
|
290
|
+
start: datetime | None = None,
|
|
291
|
+
end: datetime | None = None,
|
|
292
|
+
) -> pd.DataFrame:
|
|
293
|
+
"""
|
|
294
|
+
Get historical data for an asset via doviz.com API.
|
|
295
|
+
|
|
296
|
+
Note: For currencies, metals, energy and commodities use canlidoviz provider instead.
|
|
297
|
+
This method requires token authentication which may be unreliable.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
asset: Asset code.
|
|
301
|
+
period: Period (1d, 5d, 1mo, 3mo, 6mo, 1y).
|
|
302
|
+
start: Start date.
|
|
303
|
+
end: End date.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
DataFrame with OHLC data.
|
|
307
|
+
"""
|
|
308
|
+
asset = asset.upper() if asset.upper() in self.SUPPORTED_ASSETS else asset
|
|
309
|
+
|
|
310
|
+
if asset not in self.SUPPORTED_ASSETS:
|
|
311
|
+
raise DataNotAvailableError(f"Unsupported asset: {asset}")
|
|
312
|
+
|
|
313
|
+
# Calculate date range
|
|
314
|
+
end_dt = end or datetime.now()
|
|
315
|
+
if start:
|
|
316
|
+
start_dt = start
|
|
317
|
+
else:
|
|
318
|
+
days = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365}.get(period, 30)
|
|
319
|
+
start_dt = end_dt - timedelta(days=days)
|
|
320
|
+
|
|
321
|
+
cache_key = f"dovizcom:history:{asset}:{start_dt.date()}:{end_dt.date()}"
|
|
322
|
+
cached = self._cache_get(cache_key)
|
|
323
|
+
if cached is not None:
|
|
324
|
+
return cached
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
api_slug = self.HISTORY_API_SLUGS.get(asset, asset)
|
|
328
|
+
url = f"{self.BASE_URL}/assets/{api_slug}/archive"
|
|
329
|
+
params = {
|
|
330
|
+
"start": int(start_dt.timestamp()),
|
|
331
|
+
"end": int(end_dt.timestamp()),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
response = self._client.get(url, headers=self._get_headers(asset), params=params)
|
|
335
|
+
response.raise_for_status()
|
|
336
|
+
data = response.json()
|
|
337
|
+
|
|
338
|
+
archive = data.get("data", {}).get("archive", [])
|
|
339
|
+
|
|
340
|
+
records = []
|
|
341
|
+
for item in archive:
|
|
342
|
+
records.append(
|
|
343
|
+
{
|
|
344
|
+
"Date": self._parse_timestamp(item.get("update_date")),
|
|
345
|
+
"Open": float(item.get("open", 0)),
|
|
346
|
+
"High": float(item.get("highest", 0)),
|
|
347
|
+
"Low": float(item.get("lowest", 0)),
|
|
348
|
+
"Close": float(item.get("close", 0)),
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
df = pd.DataFrame(records)
|
|
353
|
+
if not df.empty:
|
|
354
|
+
df.set_index("Date", inplace=True)
|
|
355
|
+
df.sort_index(inplace=True)
|
|
356
|
+
|
|
357
|
+
self._cache_set(cache_key, df, TTL.OHLCV_HISTORY)
|
|
358
|
+
return df
|
|
359
|
+
|
|
360
|
+
except Exception as e:
|
|
361
|
+
raise APIError(f"Failed to fetch history for {asset}: {e}") from e
|
|
362
|
+
|
|
363
|
+
def _get_from_daily(self, asset: str) -> dict | None:
|
|
364
|
+
"""Get latest data from daily endpoint."""
|
|
365
|
+
api_slug = self.HISTORY_API_SLUGS.get(asset, asset)
|
|
366
|
+
url = f"{self.BASE_URL}/assets/{api_slug}/daily"
|
|
367
|
+
response = self._client.get(url, headers=self._get_headers(asset), params={"limit": 1})
|
|
368
|
+
response.raise_for_status()
|
|
369
|
+
data = response.json()
|
|
370
|
+
|
|
371
|
+
archive = data.get("data", {}).get("archive", [])
|
|
372
|
+
return archive[0] if archive else None
|
|
373
|
+
|
|
374
|
+
def _get_from_archive(self, asset: str, days: int = 7) -> dict | None:
|
|
375
|
+
"""Get latest data from archive endpoint."""
|
|
376
|
+
end_time = int(time.time())
|
|
377
|
+
start_time = end_time - (days * 86400)
|
|
378
|
+
|
|
379
|
+
api_slug = self.HISTORY_API_SLUGS.get(asset, asset)
|
|
380
|
+
url = f"{self.BASE_URL}/assets/{api_slug}/archive"
|
|
381
|
+
params = {"start": start_time, "end": end_time}
|
|
382
|
+
|
|
383
|
+
response = self._client.get(url, headers=self._get_headers(asset), params=params)
|
|
384
|
+
response.raise_for_status()
|
|
385
|
+
data = response.json()
|
|
386
|
+
|
|
387
|
+
archive = data.get("data", {}).get("archive", [])
|
|
388
|
+
return archive[-1] if archive else None
|
|
389
|
+
|
|
390
|
+
def _parse_timestamp(self, ts: Any) -> datetime:
|
|
391
|
+
"""Parse timestamp to datetime."""
|
|
392
|
+
if isinstance(ts, (int, float)):
|
|
393
|
+
return datetime.fromtimestamp(ts)
|
|
394
|
+
if isinstance(ts, datetime):
|
|
395
|
+
return ts
|
|
396
|
+
return datetime.now()
|
|
397
|
+
|
|
398
|
+
def get_banks(self) -> list[str]:
|
|
399
|
+
"""
|
|
400
|
+
Get list of supported banks.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
List of bank codes.
|
|
404
|
+
"""
|
|
405
|
+
return sorted(self.BANK_SLUGS.keys())
|
|
406
|
+
|
|
407
|
+
def get_bank_rates(
|
|
408
|
+
self, asset: str, bank: str | None = None
|
|
409
|
+
) -> pd.DataFrame | dict[str, Any]:
|
|
410
|
+
"""
|
|
411
|
+
Get bank exchange rates for a currency.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
asset: Currency code (USD, EUR, GBP, etc.)
|
|
415
|
+
bank: Bank code (akbank, garanti, etc.) or None for all banks.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
If bank is None: DataFrame with all bank rates.
|
|
419
|
+
If bank is specified: Dictionary with single bank rate.
|
|
420
|
+
"""
|
|
421
|
+
asset = asset.upper()
|
|
422
|
+
currency_slug = self.CURRENCY_SLUGS.get(asset)
|
|
423
|
+
if not currency_slug:
|
|
424
|
+
raise DataNotAvailableError(
|
|
425
|
+
f"Unsupported currency for bank rates: {asset}. "
|
|
426
|
+
f"Supported: {sorted(self.CURRENCY_SLUGS.keys())}"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if bank:
|
|
430
|
+
# Single bank
|
|
431
|
+
bank = bank.lower()
|
|
432
|
+
bank_slug = self.BANK_SLUGS.get(bank)
|
|
433
|
+
if not bank_slug:
|
|
434
|
+
raise DataNotAvailableError(
|
|
435
|
+
f"Unknown bank: {bank}. Supported: {sorted(self.BANK_SLUGS.keys())}"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
cache_key = f"dovizcom:bank_rate:{asset}:{bank}"
|
|
439
|
+
cached = self._cache_get(cache_key)
|
|
440
|
+
if cached:
|
|
441
|
+
return cached
|
|
442
|
+
|
|
443
|
+
result = self._fetch_single_bank_rate(bank, bank_slug, currency_slug, asset)
|
|
444
|
+
self._cache_set(cache_key, result, TTL.FX_RATES)
|
|
445
|
+
return result
|
|
446
|
+
else:
|
|
447
|
+
# All banks
|
|
448
|
+
cache_key = f"dovizcom:bank_rates:{asset}"
|
|
449
|
+
cached = self._cache_get(cache_key)
|
|
450
|
+
if cached is not None:
|
|
451
|
+
return cached
|
|
452
|
+
|
|
453
|
+
result = self._fetch_all_bank_rates(currency_slug, asset)
|
|
454
|
+
self._cache_set(cache_key, result, TTL.FX_RATES)
|
|
455
|
+
return result
|
|
456
|
+
|
|
457
|
+
def _fetch_single_bank_rate(
|
|
458
|
+
self, bank: str, bank_slug: str, currency_slug: str, asset: str
|
|
459
|
+
) -> dict[str, Any]:
|
|
460
|
+
"""Fetch exchange rate for a single bank."""
|
|
461
|
+
url = f"{self.KUR_BASE_URL}/{bank_slug}/{currency_slug}"
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
response = self._client.get(url)
|
|
465
|
+
response.raise_for_status()
|
|
466
|
+
html = response.text
|
|
467
|
+
|
|
468
|
+
# Parse buy/sell rates from HTML
|
|
469
|
+
buy, sell = self._parse_bank_rate_html(html)
|
|
470
|
+
|
|
471
|
+
if buy is None or sell is None:
|
|
472
|
+
raise DataNotAvailableError(f"Could not parse rates for {bank}")
|
|
473
|
+
|
|
474
|
+
spread = ((sell - buy) / buy * 100) if buy > 0 else 0
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
"bank": bank,
|
|
478
|
+
"currency": asset,
|
|
479
|
+
"buy": buy,
|
|
480
|
+
"sell": sell,
|
|
481
|
+
"spread": round(spread, 2),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
except Exception as e:
|
|
485
|
+
raise APIError(f"Failed to fetch bank rate for {bank}: {e}") from e
|
|
486
|
+
|
|
487
|
+
def _fetch_all_bank_rates(self, currency_slug: str, asset: str) -> pd.DataFrame:
|
|
488
|
+
"""Fetch exchange rates for all banks."""
|
|
489
|
+
url = f"{self.KUR_BASE_URL}/serbest-piyasa/{currency_slug}"
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
response = self._client.get(url)
|
|
493
|
+
response.raise_for_status()
|
|
494
|
+
html = response.text
|
|
495
|
+
|
|
496
|
+
# Parse all bank rates from the table
|
|
497
|
+
records = self._parse_all_bank_rates_html(html, asset)
|
|
498
|
+
|
|
499
|
+
if not records:
|
|
500
|
+
raise DataNotAvailableError(f"Could not parse bank rates for {asset}")
|
|
501
|
+
|
|
502
|
+
df = pd.DataFrame(records)
|
|
503
|
+
df = df.sort_values("bank").reset_index(drop=True)
|
|
504
|
+
return df
|
|
505
|
+
|
|
506
|
+
except Exception as e:
|
|
507
|
+
raise APIError(f"Failed to fetch bank rates for {asset}: {e}") from e
|
|
508
|
+
|
|
509
|
+
def _parse_bank_rate_html(self, html: str) -> tuple[float | None, float | None]:
|
|
510
|
+
"""Parse buy/sell rates from single bank page HTML using BeautifulSoup."""
|
|
511
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
512
|
+
buy = None
|
|
513
|
+
sell = None
|
|
514
|
+
|
|
515
|
+
# Method 1: data-socket-attr="bid" and "ask"
|
|
516
|
+
bid_elem = soup.find(attrs={"data-socket-attr": "bid"})
|
|
517
|
+
ask_elem = soup.find(attrs={"data-socket-attr": "ask"})
|
|
518
|
+
|
|
519
|
+
if bid_elem and ask_elem:
|
|
520
|
+
buy = self._parse_turkish_number(bid_elem.get_text(strip=True))
|
|
521
|
+
sell = self._parse_turkish_number(ask_elem.get_text(strip=True))
|
|
522
|
+
return buy, sell
|
|
523
|
+
|
|
524
|
+
# Method 2: Regex fallback for "Alış X / Satış Y" pattern
|
|
525
|
+
pattern = r"Al[ıi][şs]\s*([\d.,]+)\s*/\s*Sat[ıi][şs]\s*([\d.,]+)"
|
|
526
|
+
match = re.search(pattern, html, re.IGNORECASE)
|
|
527
|
+
if match:
|
|
528
|
+
buy = self._parse_turkish_number(match.group(1))
|
|
529
|
+
sell = self._parse_turkish_number(match.group(2))
|
|
530
|
+
|
|
531
|
+
return buy, sell
|
|
532
|
+
|
|
533
|
+
def _parse_all_bank_rates_html(self, html: str, asset: str) -> list[dict[str, Any]]:
|
|
534
|
+
"""Parse all bank rates from the main currency page HTML using BeautifulSoup."""
|
|
535
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
536
|
+
records = []
|
|
537
|
+
|
|
538
|
+
# Find the bank rates table
|
|
539
|
+
tables = soup.find_all("table", {"data-sortable": True})
|
|
540
|
+
if not tables:
|
|
541
|
+
return records
|
|
542
|
+
|
|
543
|
+
for table in tables:
|
|
544
|
+
tbody = table.find("tbody")
|
|
545
|
+
if not tbody:
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
for row in tbody.find_all("tr"):
|
|
549
|
+
cells = row.find_all("td")
|
|
550
|
+
if len(cells) < 5:
|
|
551
|
+
continue
|
|
552
|
+
|
|
553
|
+
# First cell contains the bank link and name
|
|
554
|
+
link = cells[0].find("a")
|
|
555
|
+
if not link or "href" not in link.attrs:
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
href = link["href"]
|
|
559
|
+
# Extract bank slug from URL: https://kur.doviz.com/bank-slug/currency
|
|
560
|
+
slug_match = re.search(r"kur\.doviz\.com/([^/]+)/", href)
|
|
561
|
+
if not slug_match:
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
bank_slug = slug_match.group(1)
|
|
565
|
+
bank_name = link.get_text(strip=True)
|
|
566
|
+
|
|
567
|
+
# Parse numeric values from cells
|
|
568
|
+
buy = self._parse_turkish_number(cells[1].get_text(strip=True))
|
|
569
|
+
sell = self._parse_turkish_number(cells[2].get_text(strip=True))
|
|
570
|
+
spread_text = cells[4].get_text(strip=True).replace("%", "")
|
|
571
|
+
spread = self._parse_turkish_number(spread_text)
|
|
572
|
+
|
|
573
|
+
if buy and sell:
|
|
574
|
+
# Find bank code from slug
|
|
575
|
+
bank_code = None
|
|
576
|
+
for code, slug in self.BANK_SLUGS.items():
|
|
577
|
+
if slug == bank_slug:
|
|
578
|
+
bank_code = code
|
|
579
|
+
break
|
|
580
|
+
|
|
581
|
+
records.append(
|
|
582
|
+
{
|
|
583
|
+
"bank": bank_code or bank_slug,
|
|
584
|
+
"bank_name": bank_name,
|
|
585
|
+
"currency": asset,
|
|
586
|
+
"buy": buy,
|
|
587
|
+
"sell": sell,
|
|
588
|
+
"spread": spread if spread else round((sell - buy) / buy * 100, 2),
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
return records
|
|
593
|
+
|
|
594
|
+
def _parse_turkish_number(self, value: str) -> float | None:
|
|
595
|
+
"""Parse Turkish formatted number (comma as decimal separator)."""
|
|
596
|
+
if not value:
|
|
597
|
+
return None
|
|
598
|
+
try:
|
|
599
|
+
# Remove spaces and handle Turkish format
|
|
600
|
+
value = value.strip().replace(" ", "")
|
|
601
|
+
# If both . and , exist, assume . is thousands separator
|
|
602
|
+
if "." in value and "," in value:
|
|
603
|
+
value = value.replace(".", "").replace(",", ".")
|
|
604
|
+
elif "," in value:
|
|
605
|
+
value = value.replace(",", ".")
|
|
606
|
+
return float(value)
|
|
607
|
+
except (ValueError, TypeError):
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
# ========== Metal Institution Rates ==========
|
|
611
|
+
|
|
612
|
+
def get_metal_institutions(self) -> list[str]:
|
|
613
|
+
"""Get list of supported precious metal assets for institution rates."""
|
|
614
|
+
return sorted(self.METAL_SLUGS.keys())
|
|
615
|
+
|
|
616
|
+
def get_metal_institution_rates(
|
|
617
|
+
self, asset: str, institution: str | None = None
|
|
618
|
+
) -> pd.DataFrame | dict[str, Any]:
|
|
619
|
+
"""
|
|
620
|
+
Get precious metal rates from institutions (kuyumcular, bankalar).
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
asset: Asset code (gram-altin, gram-gumus, ons-altin, gram-platin)
|
|
624
|
+
institution: Optional institution slug. If None, returns all institutions.
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
DataFrame with all institutions or dict for single institution.
|
|
628
|
+
"""
|
|
629
|
+
if asset not in self.METAL_SLUGS:
|
|
630
|
+
raise DataNotAvailableError(
|
|
631
|
+
f"Asset '{asset}' not supported for institution rates. "
|
|
632
|
+
f"Supported: {', '.join(sorted(self.METAL_SLUGS.keys()))}"
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if institution:
|
|
636
|
+
# Single institution - filter from all rates
|
|
637
|
+
cache_key = f"dovizcom:metal_institution_rate:{asset}:{institution}"
|
|
638
|
+
cached = self._cache.get(cache_key)
|
|
639
|
+
if cached:
|
|
640
|
+
return cached
|
|
641
|
+
|
|
642
|
+
all_rates = self._fetch_all_metal_institution_rates(asset)
|
|
643
|
+
for rate in all_rates:
|
|
644
|
+
if rate["institution"] == institution:
|
|
645
|
+
self._cache.set(cache_key, rate, TTL.FX_RATES)
|
|
646
|
+
return rate
|
|
647
|
+
|
|
648
|
+
raise DataNotAvailableError(
|
|
649
|
+
f"Institution '{institution}' not found for asset '{asset}'"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# All institutions
|
|
653
|
+
cache_key = f"dovizcom:metal_institution_rates:{asset}"
|
|
654
|
+
cached = self._cache.get(cache_key)
|
|
655
|
+
if cached is not None:
|
|
656
|
+
return cached
|
|
657
|
+
|
|
658
|
+
rates = self._fetch_all_metal_institution_rates(asset)
|
|
659
|
+
df = pd.DataFrame(rates)
|
|
660
|
+
if not df.empty:
|
|
661
|
+
df = df.sort_values("institution").reset_index(drop=True)
|
|
662
|
+
|
|
663
|
+
self._cache.set(cache_key, df, TTL.FX_RATES)
|
|
664
|
+
return df
|
|
665
|
+
|
|
666
|
+
def _fetch_all_metal_institution_rates(self, asset: str) -> list[dict[str, Any]]:
|
|
667
|
+
"""Fetch institution rates from altin.doviz.com."""
|
|
668
|
+
slug = self.METAL_SLUGS.get(asset, asset)
|
|
669
|
+
url = f"https://altin.doviz.com/{slug}"
|
|
670
|
+
|
|
671
|
+
try:
|
|
672
|
+
response = self._client.get(
|
|
673
|
+
url,
|
|
674
|
+
headers={
|
|
675
|
+
"User-Agent": self.DEFAULT_HEADERS["User-Agent"],
|
|
676
|
+
"Accept": "text/html,application/xhtml+xml",
|
|
677
|
+
},
|
|
678
|
+
)
|
|
679
|
+
response.raise_for_status()
|
|
680
|
+
return self._parse_metal_institution_rates_html(response.text, asset)
|
|
681
|
+
except Exception as e:
|
|
682
|
+
raise APIError(f"Failed to fetch metal institution rates: {e}") from e
|
|
683
|
+
|
|
684
|
+
def _parse_metal_institution_rates_html(
|
|
685
|
+
self, html: str, asset: str
|
|
686
|
+
) -> list[dict[str, Any]]:
|
|
687
|
+
"""Parse institution rates table from altin.doviz.com HTML."""
|
|
688
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
689
|
+
records = []
|
|
690
|
+
|
|
691
|
+
# Find the bank rates table (data-sortable attribute)
|
|
692
|
+
tables = soup.find_all("table", {"data-sortable": True})
|
|
693
|
+
if not tables:
|
|
694
|
+
# Try alternative: look for table with specific class patterns
|
|
695
|
+
tables = soup.find_all("table", class_=re.compile(r"table|kurlar", re.I))
|
|
696
|
+
|
|
697
|
+
for table in tables:
|
|
698
|
+
tbody = table.find("tbody")
|
|
699
|
+
if not tbody:
|
|
700
|
+
continue
|
|
701
|
+
|
|
702
|
+
for row in tbody.find_all("tr"):
|
|
703
|
+
cells = row.find_all("td")
|
|
704
|
+
if len(cells) < 5:
|
|
705
|
+
continue
|
|
706
|
+
|
|
707
|
+
# First cell contains the institution link and name
|
|
708
|
+
link = cells[0].find("a")
|
|
709
|
+
if not link:
|
|
710
|
+
continue
|
|
711
|
+
|
|
712
|
+
href = link.get("href", "")
|
|
713
|
+
institution_name = link.get_text(strip=True)
|
|
714
|
+
|
|
715
|
+
# Extract institution slug from URL
|
|
716
|
+
# URL pattern: https://altin.doviz.com/institution-slug/asset-slug
|
|
717
|
+
# or: https://altin.doviz.com/institution-slug
|
|
718
|
+
slug_match = re.search(r"altin\.doviz\.com/([^/]+)", href)
|
|
719
|
+
if not slug_match:
|
|
720
|
+
continue
|
|
721
|
+
|
|
722
|
+
institution_slug = slug_match.group(1)
|
|
723
|
+
|
|
724
|
+
# Skip if it's an asset page, not an institution
|
|
725
|
+
if institution_slug in self.METAL_SLUGS.values():
|
|
726
|
+
continue
|
|
727
|
+
|
|
728
|
+
# Parse numeric values
|
|
729
|
+
buy = self._parse_turkish_number(cells[1].get_text(strip=True))
|
|
730
|
+
sell = self._parse_turkish_number(cells[2].get_text(strip=True))
|
|
731
|
+
|
|
732
|
+
# Spread is in cell 4 (index 4), remove % sign
|
|
733
|
+
spread_text = cells[4].get_text(strip=True).replace("%", "").strip()
|
|
734
|
+
spread = self._parse_turkish_number(spread_text)
|
|
735
|
+
|
|
736
|
+
if buy and sell:
|
|
737
|
+
records.append(
|
|
738
|
+
{
|
|
739
|
+
"institution": institution_slug,
|
|
740
|
+
"institution_name": institution_name,
|
|
741
|
+
"asset": asset,
|
|
742
|
+
"buy": buy,
|
|
743
|
+
"sell": sell,
|
|
744
|
+
"spread": spread if spread else round((sell - buy) / buy * 100, 2),
|
|
745
|
+
}
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
return records
|
|
749
|
+
|
|
750
|
+
def get_institution_history(
|
|
751
|
+
self,
|
|
752
|
+
asset: str,
|
|
753
|
+
institution: str,
|
|
754
|
+
period: str = "1mo",
|
|
755
|
+
start: datetime | None = None,
|
|
756
|
+
end: datetime | None = None,
|
|
757
|
+
) -> pd.DataFrame:
|
|
758
|
+
"""
|
|
759
|
+
Get historical data for a specific institution's rates via doviz.com API.
|
|
760
|
+
|
|
761
|
+
Note: For currencies and gram-altin, use canlidoviz provider instead.
|
|
762
|
+
This method is for other metals (gram-gumus, ons-altin, gram-platin).
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
asset: Asset code (gram-gumus, ons-altin, gram-platin).
|
|
766
|
+
institution: Institution slug (akbank, kapalicarsi, harem, etc.).
|
|
767
|
+
period: Period (1d, 5d, 1mo, 3mo, 6mo, 1y). Ignored if start is provided.
|
|
768
|
+
start: Start date.
|
|
769
|
+
end: End date.
|
|
770
|
+
|
|
771
|
+
Returns:
|
|
772
|
+
DataFrame with OHLC data (Open, High, Low, Close) indexed by Date.
|
|
773
|
+
Note: Banks typically return only Close values (Open/High/Low = 0).
|
|
774
|
+
|
|
775
|
+
Raises:
|
|
776
|
+
DataNotAvailableError: If asset or institution is not supported.
|
|
777
|
+
APIError: If API request fails.
|
|
778
|
+
"""
|
|
779
|
+
# Validate institution
|
|
780
|
+
if institution not in self.INSTITUTION_IDS:
|
|
781
|
+
supported = ", ".join(self.INSTITUTION_IDS.keys())
|
|
782
|
+
raise DataNotAvailableError(
|
|
783
|
+
f"Unsupported institution: {institution}. Supported: {supported}"
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
# Only support metals (currencies should use canlidoviz)
|
|
787
|
+
if asset not in self.METAL_SLUGS:
|
|
788
|
+
metal_supported = ", ".join(self.METAL_SLUGS.keys())
|
|
789
|
+
raise DataNotAvailableError(
|
|
790
|
+
f"Unsupported asset: {asset}. "
|
|
791
|
+
f"Supported metals: {metal_supported}. "
|
|
792
|
+
f"For currencies, use canlidoviz provider."
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
api_asset_slug = self.METAL_SLUGS[asset]
|
|
796
|
+
|
|
797
|
+
# Calculate date range
|
|
798
|
+
end_dt = end or datetime.now()
|
|
799
|
+
if start:
|
|
800
|
+
start_dt = start
|
|
801
|
+
else:
|
|
802
|
+
days = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365}.get(period, 30)
|
|
803
|
+
start_dt = end_dt - timedelta(days=days)
|
|
804
|
+
|
|
805
|
+
cache_key = f"dovizcom:institution_history:{asset}:{institution}:{start_dt.date()}:{end_dt.date()}"
|
|
806
|
+
cached = self._cache_get(cache_key)
|
|
807
|
+
if cached is not None:
|
|
808
|
+
return cached
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
# Build API slug: {institution_id}-{asset_slug}
|
|
812
|
+
institution_id = self.INSTITUTION_IDS[institution]
|
|
813
|
+
api_slug = f"{institution_id}-{api_asset_slug}"
|
|
814
|
+
|
|
815
|
+
url = f"{self.BASE_URL}/assets/{api_slug}/archive"
|
|
816
|
+
params = {
|
|
817
|
+
"start": int(start_dt.timestamp()),
|
|
818
|
+
"end": int(end_dt.timestamp()),
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
response = self._client.get(url, headers=self._get_headers(asset), params=params)
|
|
822
|
+
response.raise_for_status()
|
|
823
|
+
data = response.json()
|
|
824
|
+
|
|
825
|
+
archive = data.get("data", {}).get("archive", [])
|
|
826
|
+
|
|
827
|
+
records = []
|
|
828
|
+
for item in archive:
|
|
829
|
+
records.append(
|
|
830
|
+
{
|
|
831
|
+
"Date": self._parse_timestamp(item.get("update_date")),
|
|
832
|
+
"Open": float(item.get("open", 0)),
|
|
833
|
+
"High": float(item.get("highest", 0)),
|
|
834
|
+
"Low": float(item.get("lowest", 0)),
|
|
835
|
+
"Close": float(item.get("close", 0)),
|
|
836
|
+
}
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
df = pd.DataFrame(records)
|
|
840
|
+
if not df.empty:
|
|
841
|
+
df.set_index("Date", inplace=True)
|
|
842
|
+
df.sort_index(inplace=True)
|
|
843
|
+
|
|
844
|
+
self._cache_set(cache_key, df, TTL.OHLCV_HISTORY)
|
|
845
|
+
return df
|
|
846
|
+
|
|
847
|
+
except Exception as e:
|
|
848
|
+
raise APIError(f"Failed to fetch institution history for {asset} from {institution}: {e}") from e
|
|
849
|
+
|
|
850
|
+
def get_metal_institutions(self) -> list[str]:
|
|
851
|
+
"""
|
|
852
|
+
Get list of institutions that support metal history data.
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
List of institution slugs.
|
|
856
|
+
"""
|
|
857
|
+
return list(self.INSTITUTION_IDS.keys())
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
# Singleton
|
|
861
|
+
_provider: DovizcomProvider | None = None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def get_dovizcom_provider() -> DovizcomProvider:
|
|
865
|
+
"""Get singleton provider instance."""
|
|
866
|
+
global _provider
|
|
867
|
+
if _provider is None:
|
|
868
|
+
_provider = DovizcomProvider()
|
|
869
|
+
return _provider
|