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.
@@ -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