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,773 @@
1
+ """Canlidoviz.com provider for forex data - token-free alternative to doviz.com."""
2
+
3
+ import re
4
+ from datetime import datetime, timedelta
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+ from bs4 import BeautifulSoup
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 CanlidovizProvider(BaseProvider):
16
+ """
17
+ Provider for forex data from canlidoviz.com.
18
+
19
+ Key advantage: No authentication token required!
20
+
21
+ Supports:
22
+ - Currency history (USD, EUR, GBP, etc.)
23
+ - Bank-specific currency rates and history
24
+ - Precious metal rates (gram-altin, etc.)
25
+ """
26
+
27
+ API_BASE = "https://a.canlidoviz.com"
28
+ WEB_BASE = "https://canlidoviz.com"
29
+
30
+ # Main currency IDs (TRY prices) - 65 currencies
31
+ # Discovered via Chrome DevTools network inspection on 2026-01-13
32
+ CURRENCY_IDS = {
33
+ # Major currencies
34
+ "USD": 1, # ABD Doları
35
+ "EUR": 50, # Euro
36
+ "GBP": 100, # İngiliz Sterlini
37
+ "CHF": 51, # İsviçre Frangı
38
+ "CAD": 56, # Kanada Doları
39
+ "AUD": 102, # Avustralya Doları
40
+ "JPY": 57, # 100 Japon Yeni
41
+ "NZD": 67, # Yeni Zelanda Doları
42
+ "SGD": 17, # Singapur Doları
43
+ "HKD": 80, # Hong Kong Doları
44
+ "TWD": 9, # Yeni Tayvan Doları
45
+ # European currencies
46
+ "DKK": 54, # Danimarka Kronu
47
+ "SEK": 60, # İsveç Kronu
48
+ "NOK": 99, # Norveç Kronu
49
+ "PLN": 110, # Polonya Zlotisi
50
+ "CZK": 69, # Çek Korunası
51
+ "HUF": 108, # Macar Forinti
52
+ "RON": 77, # Romanya Leyi
53
+ "BGN": 71, # Bulgar Levası
54
+ "HRK": 116, # Hırvat Kunası
55
+ "RSD": 7, # Sırbistan Dinarı
56
+ "BAM": 82, # Bosna Hersek Markı
57
+ "MKD": 21, # Makedon Dinarı
58
+ "ALL": 112, # Arnavutluk Leki
59
+ "MDL": 10, # Moldovya Leusu
60
+ "UAH": 8, # Ukrayna Grivnası
61
+ "BYR": 109, # Belarus Rublesi
62
+ "ISK": 83, # İzlanda Kronası
63
+ # Middle East & Africa
64
+ "AED": 53, # BAE Dirhemi
65
+ "SAR": 61, # Suudi Arabistan Riyali
66
+ "QAR": 5, # Katar Riyali
67
+ "KWD": 104, # Kuveyt Dinarı
68
+ "BHD": 64, # Bahreyn Dinarı
69
+ "OMR": 2, # Umman Riyali
70
+ "JOD": 92, # Ürdün Dinarı
71
+ "IQD": 106, # Irak Dinarı
72
+ "IRR": 68, # İran Riyali
73
+ "LBP": 117, # Lübnan Lirası
74
+ "SYP": 6, # Suriye Lirası
75
+ "EGP": 111, # Mısır Lirası
76
+ "LYD": 101, # Libya Dinarı
77
+ "TND": 885, # Tunus Dinarı
78
+ "DZD": 88, # Cezayir Dinarı
79
+ "MAD": 89, # Fas Dirhemi
80
+ "ZAR": 59, # Güney Afrika Randı
81
+ "ILS": 63, # İsrail Şekeli
82
+ # Asia & Pacific
83
+ "CNY": 107, # Çin Yuanı
84
+ "INR": 103, # Hindistan Rupisi
85
+ "PKR": 29, # Pakistan Rupisi
86
+ "LKR": 87, # Sri Lanka Rupisi
87
+ "IDR": 105, # Endonezya Rupiahı
88
+ "MYR": 3, # Malezya Ringgiti
89
+ "THB": 39, # Tayland Bahtı
90
+ "PHP": 4, # Filipinler Pesosu
91
+ "KRW": 113, # Güney Kore Wonu
92
+ "KZT": 85, # Kazak Tengesi
93
+ "AZN": 75, # Azerbaycan Manatı
94
+ "GEL": 162, # Gürcistan Larisi
95
+ # Americas
96
+ "MXN": 65, # Meksika Pesosu
97
+ "BRL": 74, # Brezilya Reali
98
+ "ARS": 73, # Arjantin Pesosu
99
+ "CLP": 76, # Şili Pesosu
100
+ "COP": 114, # Kolombiya Pesosu
101
+ "PEN": 13, # Peru İnti
102
+ "UYU": 25, # Uruguay Pesosu
103
+ "CRC": 79, # Kostarika Kolonu
104
+ # Other
105
+ "RUB": 97, # Rus Rublesi
106
+ "DVZSP1": 783, # Sepet Kur (Döviz Sepeti)
107
+ }
108
+
109
+ # Precious metal IDs (TRY prices)
110
+ # Note: These IDs were verified against canlidoviz.com pages on 2026-01-13
111
+ METAL_IDS = {
112
+ "gram-altin": 32, # ~6,300 TRY (altin-fiyatlari/gram-altin)
113
+ "ceyrek-altin": 11, # ~10,500 TRY
114
+ "yarim-altin": 47, # ~21,000 TRY
115
+ "tam-altin": 14, # ~42,000 TRY
116
+ "cumhuriyet-altin": 27, # ~43,000 TRY
117
+ "ata-altin": 43, # ~43,000 TRY
118
+ "gram-gumus": 20, # ~115 TRY (altin-fiyatlari/gumus)
119
+ "ons-altin": 81, # ~104,000 TRY (ons in TRY)
120
+ "gram-platin": 1012, # ~3,260 TRY (emtia-fiyatlari/platin-gram)
121
+ }
122
+
123
+ # Energy IDs (USD prices)
124
+ # Verified via Chrome DevTools network inspection on 2026-01-13
125
+ ENERGY_IDS = {
126
+ "BRENT": 266, # Brent Petrol ~$64 (emtia-fiyatlari/brent-petrol)
127
+ }
128
+
129
+ # Commodity IDs - Precious metals in USD (emtia)
130
+ # Verified via Chrome DevTools network inspection on 2026-01-13
131
+ COMMODITY_IDS = {
132
+ "XAG-USD": 267, # Silver Ounce (emtia-fiyatlari/gumus-ons)
133
+ "XPT-USD": 268, # Platinum Spot (emtia-fiyatlari/platin-spot-dolar)
134
+ "XPD-USD": 269, # Palladium Spot (emtia-fiyatlari/paladyum-spot-dolar)
135
+ }
136
+
137
+ # Bank-specific USD IDs
138
+ BANK_USD_IDS = {
139
+ "akbank": 822,
140
+ "garanti-bbva": 805,
141
+ "is-bankasi": 1020,
142
+ "ziraat-bankasi": 264,
143
+ "halkbank": 1017,
144
+ "yapi-kredi": 819,
145
+ "vakifbank": 1018,
146
+ "denizbank": 1019,
147
+ "ing-bank": 1023,
148
+ "hsbc": 1025,
149
+ "teb": 1024,
150
+ "qnb-finansbank": 788,
151
+ "merkez-bankasi": 1016,
152
+ "kapali-carsi": 1114,
153
+ "kuveyt-turk": 1021,
154
+ "albaraka-turk": 1022,
155
+ "sekerbank": 1113,
156
+ "enpara": 824,
157
+ }
158
+
159
+ # Bank-specific EUR IDs
160
+ BANK_EUR_IDS = {
161
+ "akbank": 1341,
162
+ "garanti-bbva": 807,
163
+ "is-bankasi": 1030,
164
+ "ziraat-bankasi": 894,
165
+ "merkez-bankasi": 1026,
166
+ "halkbank": 1027,
167
+ "yapi-kredi": 820,
168
+ "vakifbank": 1028,
169
+ "denizbank": 1029,
170
+ "ing-bank": 1033,
171
+ "hsbc": 1035,
172
+ "teb": 1034,
173
+ "qnb-finansbank": 789,
174
+ "kapali-carsi": 1115,
175
+ "kuveyt-turk": 1031,
176
+ "albaraka-turk": 1032,
177
+ }
178
+
179
+ # Bank-specific GBP IDs (18 banka - halkbank hariç veri yok)
180
+ BANK_GBP_IDS = {
181
+ "akbank": 1342,
182
+ "albaraka-turk": 1329,
183
+ "denizbank": 1376,
184
+ "destekbank": 1338,
185
+ "fibabanka": 1410,
186
+ "garanti-bbva": 809,
187
+ "hsbc": 1417,
188
+ "ing-bank": 1427,
189
+ "is-bankasi": 1485,
190
+ "kapali-carsi": 1116,
191
+ "kuveyt-turk": 841,
192
+ "merkez-bankasi": 1036,
193
+ "qnb-finansbank": 791,
194
+ "sekerbank": 1289,
195
+ "teb": 1288,
196
+ "vakifbank": 1460,
197
+ "yapi-kredi": 1475,
198
+ "ziraat-bankasi": 896,
199
+ }
200
+
201
+ # Bank-specific CHF IDs
202
+ BANK_CHF_IDS = {
203
+ "akbank": 1351,
204
+ "albaraka-turk": 1330,
205
+ "denizbank": 1377,
206
+ "is-bankasi": 1489,
207
+ "kapali-carsi": 1199,
208
+ "merkez-bankasi": 1440,
209
+ "vakifbank": 1461,
210
+ "yapi-kredi": 1479,
211
+ "ziraat-bankasi": 902,
212
+ }
213
+
214
+ # Bank-specific CAD IDs
215
+ BANK_CAD_IDS = {
216
+ "akbank": 1345,
217
+ "is-bankasi": 1490,
218
+ "kapali-carsi": 1204,
219
+ "merkez-bankasi": 1442,
220
+ "ziraat-bankasi": 899,
221
+ }
222
+
223
+ # Bank-specific AUD IDs
224
+ BANK_AUD_IDS = {
225
+ "akbank": 1343,
226
+ "is-bankasi": 1486,
227
+ "kapali-carsi": 1203,
228
+ "merkez-bankasi": 1437,
229
+ "ziraat-bankasi": 897,
230
+ }
231
+
232
+ # Bank-specific JPY IDs (100 Japon Yeni)
233
+ BANK_JPY_IDS = {
234
+ "garanti-bbva": 814,
235
+ "kapali-carsi": 1198,
236
+ "merkez-bankasi": 1455,
237
+ "sekerbank": 1498,
238
+ "vakifbank": 1469,
239
+ "ziraat-bankasi": 1286,
240
+ }
241
+
242
+ # Bank-specific RUB IDs (Rus Rublesi)
243
+ BANK_RUB_IDS = {
244
+ "akbank": 1352,
245
+ "albaraka-turk": 1367,
246
+ "denizbank": 1384,
247
+ "ing-bank": 1436,
248
+ "kapali-carsi": 1206,
249
+ "kuveyt-turk": 831,
250
+ "merkez-bankasi": 1448,
251
+ "qnb-finansbank": 801,
252
+ "vakifbank": 1462,
253
+ "ziraat-bankasi": 901,
254
+ }
255
+
256
+ # Bank-specific SAR IDs (Suudi Arabistan Riyali)
257
+ BANK_SAR_IDS = {
258
+ "akbank": 1350,
259
+ "denizbank": 1401,
260
+ "hsbc": 1418,
261
+ "ing-bank": 1434,
262
+ "is-bankasi": 1493,
263
+ "kapali-carsi": 1205,
264
+ "kuveyt-turk": 842,
265
+ "merkez-bankasi": 1445,
266
+ "qnb-finansbank": 802,
267
+ "vakifbank": 1463,
268
+ "yapi-kredi": 1483,
269
+ "ziraat-bankasi": 903,
270
+ }
271
+
272
+ # Bank-specific AED IDs (BAE Dirhemi)
273
+ BANK_AED_IDS = {
274
+ "akbank": 1358,
275
+ "denizbank": 1385,
276
+ "kapali-carsi": 1208,
277
+ "merkez-bankasi": 1454,
278
+ }
279
+
280
+ # Bank-specific CNY IDs (Çin Yuanı)
281
+ BANK_CNY_IDS = {
282
+ "akbank": 1353,
283
+ "kapali-carsi": 1210,
284
+ "merkez-bankasi": 1449,
285
+ }
286
+
287
+ # Bank slug to dovizcom-compatible slug mapping
288
+ BANK_SLUG_MAP = {
289
+ "akbank": "akbank",
290
+ "albaraka-turk": "albaraka",
291
+ "denizbank": "denizbank",
292
+ "destekbank": "destekbank",
293
+ "enpara": "enpara",
294
+ "fibabanka": "fibabanka",
295
+ "garanti-bbva": "garanti",
296
+ "halkbank": "halkbank",
297
+ "hsbc": "hsbc",
298
+ "ing-bank": "ing",
299
+ "is-bankasi": "isbank",
300
+ "kapali-carsi": "kapalicarsi",
301
+ "kuveyt-turk": "kuveytturk",
302
+ "merkez-bankasi": "tcmb",
303
+ "qnb-finansbank": "qnb",
304
+ "sekerbank": "sekerbank",
305
+ "teb": "teb",
306
+ "vakifbank": "vakifbank",
307
+ "yapi-kredi": "yapikredi",
308
+ "ziraat-bankasi": "ziraat",
309
+ }
310
+
311
+ # Reverse mapping (dovizcom slug -> canlidoviz slug)
312
+ DOVIZCOM_TO_CANLIDOVIZ = {v: k for k, v in BANK_SLUG_MAP.items()}
313
+
314
+ # Currency code to URL slug mapping (for HTML scraping)
315
+ CURRENCY_SLUGS = {
316
+ "USD": "dolar",
317
+ "EUR": "euro",
318
+ "GBP": "ingiliz-sterlini",
319
+ "CHF": "isvicre-frangi",
320
+ "CAD": "kanada-dolari",
321
+ "AUD": "avustralya-dolari",
322
+ "JPY": "100-japon-yeni",
323
+ }
324
+
325
+ # Bank-specific metal IDs (gram-altin)
326
+ # Verified via Chrome DevTools network inspection (January 2026)
327
+ BANK_GRAM_ALTIN_IDS: dict[str, int] = {
328
+ "kapali-carsi": 1115,
329
+ "akbank": 823,
330
+ "ziraat-bankasi": 1039,
331
+ "is-bankasi": 1040,
332
+ "vakifbank": 1037,
333
+ "halkbank": 1036,
334
+ "garanti-bankasi": 806,
335
+ "yapi-kredi": 821,
336
+ "denizbank": 1038,
337
+ "albaraka": 1112,
338
+ "destekbank": 1339,
339
+ "enpara": 1041,
340
+ "fibabanka": 1300,
341
+ "hsbc": 1045,
342
+ "ing-bank": 1043,
343
+ "kuveyt-turk": 826,
344
+ "qnb-finansbank": 789,
345
+ "sekerbank": 1042,
346
+ "teb": 1044,
347
+ }
348
+
349
+ # Bank-specific metal IDs (gumus/silver)
350
+ # Verified via Chrome DevTools network inspection (January 2026)
351
+ BANK_GUMUS_IDS: dict[str, int] = {
352
+ "kapali-carsi": 1181,
353
+ "akbank": 1359,
354
+ "albaraka": 1372,
355
+ "denizbank": 1378,
356
+ "destekbank": 1340,
357
+ "fibabanka": 1413,
358
+ "garanti-bankasi": 1415,
359
+ "halkbank": 1416,
360
+ "hsbc": 1426,
361
+ "kuveyt-turk": 827,
362
+ "qnb-finansbank": 1456,
363
+ "vakifbank": 1474,
364
+ "ziraat-bankasi": 1283,
365
+ }
366
+
367
+ # Bank-specific metal IDs (platin/platinum)
368
+ # Verified via Chrome DevTools network inspection (January 2026)
369
+ # Note: Only Kuveyt Türk provides platin institution rates on canlidoviz
370
+ BANK_PLATIN_IDS: dict[str, int] = {
371
+ "kuveyt-turk": 1013,
372
+ }
373
+
374
+ def __init__(self):
375
+ super().__init__()
376
+
377
+ def _get_headers(self) -> dict[str, str]:
378
+ """Get request headers - no token needed!"""
379
+ return {
380
+ "Accept": "*/*",
381
+ "Origin": self.WEB_BASE,
382
+ "Referer": f"{self.WEB_BASE}/",
383
+ "User-Agent": self.DEFAULT_HEADERS["User-Agent"],
384
+ }
385
+
386
+ def _get_item_id(
387
+ self, asset: str, institution: str | None = None
388
+ ) -> int | None:
389
+ """
390
+ Get canlidoviz item ID for an asset.
391
+
392
+ Args:
393
+ asset: Currency code (USD, EUR) or metal (gram-altin).
394
+ institution: Optional bank/institution slug.
395
+
396
+ Returns:
397
+ Item ID or None if not found.
398
+ """
399
+ asset_upper = asset.upper()
400
+
401
+ if institution:
402
+ # Convert dovizcom slug to canlidoviz slug if needed
403
+ inst_slug = self.DOVIZCOM_TO_CANLIDOVIZ.get(institution, institution)
404
+
405
+ # Bank-specific ID
406
+ if asset_upper == "USD":
407
+ return self.BANK_USD_IDS.get(inst_slug)
408
+ elif asset_upper == "EUR":
409
+ return self.BANK_EUR_IDS.get(inst_slug)
410
+ elif asset_upper == "GBP":
411
+ return self.BANK_GBP_IDS.get(inst_slug)
412
+ elif asset_upper == "CHF":
413
+ return self.BANK_CHF_IDS.get(inst_slug)
414
+ elif asset_upper == "CAD":
415
+ return self.BANK_CAD_IDS.get(inst_slug)
416
+ elif asset_upper == "AUD":
417
+ return self.BANK_AUD_IDS.get(inst_slug)
418
+ elif asset_upper == "JPY":
419
+ return self.BANK_JPY_IDS.get(inst_slug)
420
+ elif asset_upper == "RUB":
421
+ return self.BANK_RUB_IDS.get(inst_slug)
422
+ elif asset_upper == "SAR":
423
+ return self.BANK_SAR_IDS.get(inst_slug)
424
+ elif asset_upper == "AED":
425
+ return self.BANK_AED_IDS.get(inst_slug)
426
+ elif asset_upper == "CNY":
427
+ return self.BANK_CNY_IDS.get(inst_slug)
428
+ elif asset == "gram-altin":
429
+ return self.BANK_GRAM_ALTIN_IDS.get(inst_slug)
430
+ elif asset == "gumus":
431
+ return self.BANK_GUMUS_IDS.get(inst_slug)
432
+ elif asset == "gram-platin":
433
+ return self.BANK_PLATIN_IDS.get(inst_slug)
434
+ return None
435
+
436
+ # Main asset ID
437
+ if asset_upper in self.CURRENCY_IDS:
438
+ return self.CURRENCY_IDS[asset_upper]
439
+ if asset in self.METAL_IDS:
440
+ return self.METAL_IDS[asset]
441
+ if asset_upper in self.ENERGY_IDS:
442
+ return self.ENERGY_IDS[asset_upper]
443
+ if asset_upper in self.COMMODITY_IDS:
444
+ return self.COMMODITY_IDS[asset_upper]
445
+
446
+ return None
447
+
448
+ def get_history(
449
+ self,
450
+ asset: str,
451
+ period: str = "1mo",
452
+ start: datetime | None = None,
453
+ end: datetime | None = None,
454
+ institution: str | None = None,
455
+ ) -> pd.DataFrame:
456
+ """
457
+ Get historical OHLC data for a currency or metal.
458
+
459
+ Args:
460
+ asset: Currency code (USD, EUR) or metal (gram-altin).
461
+ period: Period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y).
462
+ start: Start date. Overrides period if provided.
463
+ end: End date. Defaults to now.
464
+ institution: Optional bank slug for bank-specific history.
465
+
466
+ Returns:
467
+ DataFrame with OHLC data indexed by Date.
468
+
469
+ Raises:
470
+ DataNotAvailableError: If asset is not supported.
471
+ APIError: If API request fails.
472
+ """
473
+ item_id = self._get_item_id(asset, institution)
474
+ if item_id is None:
475
+ if institution:
476
+ raise DataNotAvailableError(
477
+ f"No canlidoviz data for {asset} from {institution}"
478
+ )
479
+ raise DataNotAvailableError(f"Unsupported asset: {asset}")
480
+
481
+ # Calculate date range
482
+ end_dt = end or datetime.now()
483
+ if start:
484
+ start_dt = start
485
+ else:
486
+ days = {
487
+ "1d": 1,
488
+ "5d": 5,
489
+ "1mo": 30,
490
+ "3mo": 90,
491
+ "6mo": 180,
492
+ "1y": 365,
493
+ "2y": 730,
494
+ "5y": 1825,
495
+ "max": 3650,
496
+ }.get(period, 30)
497
+ start_dt = end_dt - timedelta(days=days)
498
+
499
+ cache_key = f"canlidoviz:history:{asset}:{institution}:{start_dt.date()}:{end_dt.date()}"
500
+ cached = self._cache_get(cache_key)
501
+ if cached is not None:
502
+ return cached
503
+
504
+ try:
505
+ url = f"{self.API_BASE}/items/history"
506
+ params = {
507
+ "period": "DAILY",
508
+ "itemDataId": item_id,
509
+ "startDate": start_dt.strftime("%Y-%m-%dT%H:%M:%S"),
510
+ "endDate": end_dt.strftime("%Y-%m-%dT%H:%M:%S"),
511
+ }
512
+
513
+ response = self._client.get(url, headers=self._get_headers(), params=params)
514
+ response.raise_for_status()
515
+ data = response.json()
516
+
517
+ # Parse response: {"timestamp": "open|high|low|close", ...}
518
+ records = []
519
+ for ts_str, ohlc_str in data.items():
520
+ try:
521
+ ts = int(ts_str)
522
+ dt = datetime.fromtimestamp(ts)
523
+ values = ohlc_str.split("|")
524
+ if len(values) >= 4:
525
+ records.append({
526
+ "Date": dt,
527
+ "Open": float(values[0]),
528
+ "High": float(values[1]),
529
+ "Low": float(values[2]),
530
+ "Close": float(values[3]),
531
+ })
532
+ except (ValueError, IndexError):
533
+ continue
534
+
535
+ df = pd.DataFrame(records)
536
+ if not df.empty:
537
+ df.set_index("Date", inplace=True)
538
+ df.sort_index(inplace=True)
539
+
540
+ self._cache_set(cache_key, df, TTL.OHLCV_HISTORY)
541
+ return df
542
+
543
+ except Exception as e:
544
+ raise APIError(f"Failed to fetch canlidoviz history for {asset}: {e}") from e
545
+
546
+ def get_current(self, asset: str, institution: str | None = None) -> dict[str, Any]:
547
+ """
548
+ Get current price for a currency or metal.
549
+
550
+ Uses the most recent data point from history API.
551
+
552
+ Args:
553
+ asset: Currency code (USD, EUR) or metal (gram-altin).
554
+ institution: Optional bank slug for bank-specific rate.
555
+
556
+ Returns:
557
+ Dictionary with current price data.
558
+ """
559
+ item_id = self._get_item_id(asset, institution)
560
+ if item_id is None:
561
+ raise DataNotAvailableError(f"Unsupported asset: {asset}")
562
+
563
+ cache_key = f"canlidoviz:current:{asset}:{institution}"
564
+ cached = self._cache_get(cache_key)
565
+ if cached:
566
+ return cached
567
+
568
+ try:
569
+ # Get recent history to extract latest price
570
+ df = self.get_history(asset, period="5d", institution=institution)
571
+
572
+ if df.empty:
573
+ raise DataNotAvailableError(f"No data for {asset}")
574
+
575
+ # Get the most recent row
576
+ latest = df.iloc[-1]
577
+ latest_date = df.index[-1]
578
+
579
+ result = {
580
+ "symbol": asset,
581
+ "last": float(latest["Close"]),
582
+ "open": float(latest["Open"]),
583
+ "high": float(latest["High"]),
584
+ "low": float(latest["Low"]),
585
+ "update_time": latest_date,
586
+ }
587
+
588
+ if institution:
589
+ result["institution"] = institution
590
+
591
+ self._cache_set(cache_key, result, TTL.FX_RATES)
592
+ return result
593
+
594
+ except Exception as e:
595
+ raise APIError(f"Failed to fetch current price for {asset}: {e}") from e
596
+
597
+ def get_supported_currencies(self) -> list[str]:
598
+ """Get list of supported currencies."""
599
+ return sorted(self.CURRENCY_IDS.keys())
600
+
601
+ def get_supported_metals(self) -> list[str]:
602
+ """Get list of supported metals."""
603
+ return sorted(self.METAL_IDS.keys())
604
+
605
+ def get_supported_banks(self, currency: str = "USD") -> list[str]:
606
+ """
607
+ Get list of supported banks for a currency.
608
+
609
+ Args:
610
+ currency: Currency code (default USD).
611
+
612
+ Returns:
613
+ List of bank slugs.
614
+ """
615
+ currency = currency.upper()
616
+ if currency == "USD":
617
+ return sorted(self.BANK_USD_IDS.keys())
618
+ elif currency == "EUR":
619
+ return sorted(self.BANK_EUR_IDS.keys())
620
+ elif currency == "GBP":
621
+ return sorted(self.BANK_GBP_IDS.keys())
622
+ elif currency == "CHF":
623
+ return sorted(self.BANK_CHF_IDS.keys())
624
+ elif currency == "CAD":
625
+ return sorted(self.BANK_CAD_IDS.keys())
626
+ elif currency == "AUD":
627
+ return sorted(self.BANK_AUD_IDS.keys())
628
+ elif currency == "JPY":
629
+ return sorted(self.BANK_JPY_IDS.keys())
630
+ elif currency == "RUB":
631
+ return sorted(self.BANK_RUB_IDS.keys())
632
+ elif currency == "SAR":
633
+ return sorted(self.BANK_SAR_IDS.keys())
634
+ elif currency == "AED":
635
+ return sorted(self.BANK_AED_IDS.keys())
636
+ elif currency == "CNY":
637
+ return sorted(self.BANK_CNY_IDS.keys())
638
+ return []
639
+
640
+ def get_bank_rates(
641
+ self, currency: str, bank: str | None = None
642
+ ) -> pd.DataFrame | dict[str, Any]:
643
+ """
644
+ Get buy/sell rates from banks via HTML scraping.
645
+
646
+ Args:
647
+ currency: Currency code (USD, EUR, GBP, etc.)
648
+ bank: Optional bank slug. If provided, returns single dict.
649
+
650
+ Returns:
651
+ DataFrame with all banks or dict for single bank.
652
+ Columns: bank, bank_name, currency, buy, sell, spread
653
+
654
+ Raises:
655
+ DataNotAvailableError: If currency is not supported.
656
+ """
657
+ currency = currency.upper()
658
+ slug = self.CURRENCY_SLUGS.get(currency)
659
+ if not slug:
660
+ raise DataNotAvailableError(
661
+ f"Bank rates not available for {currency}. "
662
+ f"Supported: {list(self.CURRENCY_SLUGS.keys())}"
663
+ )
664
+
665
+ cache_key = f"canlidoviz:bank_rates:{currency}"
666
+ cached = self._cache_get(cache_key)
667
+
668
+ if cached is None:
669
+ url = f"{self.WEB_BASE}/doviz-kurlari/{slug}"
670
+ try:
671
+ response = self._client.get(url, headers=self._get_headers())
672
+ response.raise_for_status()
673
+ html = response.text
674
+ cached = self._parse_bank_rates_html(html, currency)
675
+ self._cache_set(cache_key, cached, TTL.FX_RATES)
676
+ except Exception as e:
677
+ raise APIError(f"Failed to fetch bank rates: {e}") from e
678
+
679
+ if bank:
680
+ # Convert dovizcom slug to canlidoviz slug
681
+ bank_slug = self.DOVIZCOM_TO_CANLIDOVIZ.get(bank, bank)
682
+ for row in cached:
683
+ if row["bank"] == bank_slug:
684
+ return row
685
+ raise DataNotAvailableError(f"Bank {bank} not found for {currency}")
686
+
687
+ return pd.DataFrame(cached)
688
+
689
+ def _parse_bank_rates_html(
690
+ self, html: str, currency: str
691
+ ) -> list[dict[str, Any]]:
692
+ """Parse bank rates from HTML page.
693
+
694
+ HTML structure: Each bank is in a table row with TDs:
695
+ - TD 0: Bank name link
696
+ - TD 1: Buy price (Alış)
697
+ - TD 2: Sell price + change values concatenated (Satış0.54%-1.21)
698
+ - TD 3: Close (Kapanış)
699
+ - TD 4: High (Yüksek)
700
+ - TD 5: Low (Düşük)
701
+ """
702
+ soup = BeautifulSoup(html, "html.parser")
703
+ results = []
704
+
705
+ # Find all bank links in the "DİĞER PİYASALAR" table
706
+ # Pattern: /doviz-kurlari/{bank-slug}/{currency-slug}
707
+ currency_slug = self.CURRENCY_SLUGS.get(currency.upper(), "")
708
+ pattern = re.compile(rf"/doviz-kurlari/([^/]+)/{currency_slug}$")
709
+
710
+ for link in soup.find_all("a", href=pattern):
711
+ match = pattern.search(link.get("href", ""))
712
+ if not match:
713
+ continue
714
+
715
+ bank_slug = match.group(1)
716
+ # Skip the main currency page link
717
+ if bank_slug == currency_slug:
718
+ continue
719
+
720
+ # Get bank display name from link text
721
+ bank_text = link.get_text(strip=True)
722
+ # Remove timestamp if present (e.g., "AKBANK15:57:42" or "AKBANK 15:57:42")
723
+ bank_name = re.sub(r"\s*\d{2}:\d{2}:\d{2}$", "", bank_text)
724
+
725
+ # Find the parent TD element
726
+ td_parent = link.find_parent("td")
727
+ if not td_parent:
728
+ continue
729
+
730
+ # Get sibling TDs containing the values
731
+ sibling_tds = td_parent.find_next_siblings("td")
732
+ if len(sibling_tds) < 2:
733
+ continue
734
+
735
+ try:
736
+ # TD 0: Buy price (clean number like "42.4400")
737
+ buy_text = sibling_tds[0].get_text(strip=True)
738
+ buy = float(buy_text.replace(",", "."))
739
+
740
+ # TD 1: Sell price + change concatenated (e.g., "43.79000.54%-1.21")
741
+ # Extract the first decimal number (sell price)
742
+ sell_text = sibling_tds[1].get_text(strip=True)
743
+ sell_match = re.match(r"^(\d+[.,]\d+)", sell_text)
744
+ if not sell_match:
745
+ continue
746
+ sell = float(sell_match.group(1).replace(",", "."))
747
+
748
+ spread = round((sell - buy) / buy * 100, 2) if buy > 0 else 0
749
+
750
+ results.append({
751
+ "bank": bank_slug,
752
+ "bank_name": bank_name,
753
+ "currency": currency,
754
+ "buy": buy,
755
+ "sell": sell,
756
+ "spread": spread,
757
+ })
758
+ except (ValueError, IndexError, AttributeError):
759
+ continue
760
+
761
+ return results
762
+
763
+
764
+ # Singleton
765
+ _provider: CanlidovizProvider | None = None
766
+
767
+
768
+ def get_canlidoviz_provider() -> CanlidovizProvider:
769
+ """Get singleton provider instance."""
770
+ global _provider
771
+ if _provider is None:
772
+ _provider = CanlidovizProvider()
773
+ return _provider