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,802 @@
1
+ """TEFAS provider for mutual fund data."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+ import urllib3
8
+
9
+ from borsapy._providers.base import BaseProvider
10
+ from borsapy.cache import TTL
11
+ from borsapy.exceptions import APIError, DataNotAvailableError
12
+
13
+ # Disable SSL warnings for TEFAS
14
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
15
+
16
+ # Asset type code mapping (Turkish abbreviations to full names)
17
+ # Used by BindHistoryAllocation API (returns abbreviations like "HS", "TR")
18
+ ASSET_TYPE_MAPPING = {
19
+ "BB": "Banka Bonosu",
20
+ "BYF": "Borsa Yatırım Fonu",
21
+ "D": "Döviz",
22
+ "DB": "Devlet Bonusu",
23
+ "DT": "Devlet Tahvili",
24
+ "DÖT": "Döviz Ödenekli Tahvil",
25
+ "EUT": "Eurobond Tahvil",
26
+ "FB": "Finansman Bonosu",
27
+ "FKB": "Fon Katılma Belgesi",
28
+ "GAS": "Gümüş",
29
+ "GSYKB": "Girişim Sermayesi Yatırım Katılma Belgesi",
30
+ "GSYY": "Girişim Sermayesi Yatırım",
31
+ "GYKB": "Gayrimenkul Yatırım Katılma Belgesi",
32
+ "GYY": "Gayrimenkul Yatırım",
33
+ "HB": "Hazine Bonosu",
34
+ "HS": "Hisse Senedi",
35
+ "KBA": "Kira Sertifikası Alım",
36
+ "KH": "Katılım Hesabı",
37
+ "KHAU": "Katılım Hesabı ABD Doları",
38
+ "KHD": "Katılım Hesabı Döviz",
39
+ "KHTL": "Katılım Hesabı Türk Lirası",
40
+ "KKS": "Kira Sertifikası",
41
+ "KKSD": "Kira Sertifikası Döviz",
42
+ "KKSTL": "Kira Sertifikası Türk Lirası",
43
+ "KKSYD": "Kira Sertifikası Yabancı Döviz",
44
+ "KM": "Kıymetli Maden",
45
+ "KMBYF": "Kıymetli Maden Borsa Yatırım Fonu",
46
+ "KMKBA": "Kıymetli Maden Katılma Belgesi Alım",
47
+ "KMKKS": "Kıymetli Maden Kira Sertifikası",
48
+ "KİBD": "Kira Sertifikası İpotekli Borçlanma",
49
+ "OSKS": "Özel Sektör Kira Sertifikası",
50
+ "OST": "Özel Sektör Tahvili",
51
+ "R": "Repo",
52
+ "T": "Tahvil",
53
+ "TPP": "Ters Repo Para Piyasası",
54
+ "TR": "Ters Repo",
55
+ "VDM": "Vadeli Mevduat",
56
+ "VM": "Vadesiz Mevduat",
57
+ "VMAU": "Vadesiz Mevduat ABD Doları",
58
+ "VMD": "Vadesiz Mevduat Döviz",
59
+ "VMTL": "Vadesiz Mevduat Türk Lirası",
60
+ "VİNT": "Varlık İpotek Tahvil",
61
+ "YBA": "Yabancı Borçlanma Araçları",
62
+ "YBKB": "Yabancı Borsa Katılma Belgesi",
63
+ "YBOSB": "Yabancı Borsa Özel Sektör Bonusu",
64
+ "YBYF": "Yabancı Borsa Yatırım Fonu",
65
+ "YHS": "Yabancı Hisse Senedi",
66
+ "YMK": "Yabancı Menkul Kıymet",
67
+ "YYF": "Yabancı Yatırım Fonu",
68
+ "ÖKSYD": "Özel Sektör Kira Sertifikası Yabancı Döviz",
69
+ "ÖSDB": "Özel Sektör Devlet Bonusu",
70
+ }
71
+
72
+ # Standardized asset names (for GetAllFundAnalyzeData API which returns full Turkish names)
73
+ # Maps various API response names to standardized English names
74
+ ASSET_NAME_STANDARDIZATION = {
75
+ # Direct mappings (API returns these exact names)
76
+ "Hisse Senedi": "Stocks",
77
+ "Ters-Repo": "Reverse Repo",
78
+ "Finansman Bonosu": "Commercial Paper",
79
+ "Özel Sektör Tahvili": "Corporate Bonds",
80
+ "Mevduat (TL)": "TL Deposits",
81
+ "Yatırım Fonları Katılma Payları": "Fund Shares",
82
+ "Girişim Sermayesi Yatırım Fonları Katılma Payları": "VC Fund Shares",
83
+ "Vadeli İşlemler Nakit Teminatları": "Futures Margin",
84
+ "Diğer": "Other",
85
+ # Additional common names
86
+ "Devlet Tahvili": "Government Bonds",
87
+ "Hazine Bonosu": "Treasury Bills",
88
+ "Kıymetli Maden": "Precious Metals",
89
+ "Döviz": "Foreign Currency",
90
+ "Repo": "Repo",
91
+ }
92
+
93
+
94
+ class TEFASProvider(BaseProvider):
95
+ """
96
+ Provider for mutual fund data from TEFAS.
97
+
98
+ Provides:
99
+ - Fund details and current prices
100
+ - Historical performance data
101
+ - Fund search
102
+ """
103
+
104
+ BASE_URL = "https://www.tefas.gov.tr/api/DB"
105
+
106
+ def __init__(self):
107
+ super().__init__()
108
+ # Disable SSL verification for TEFAS
109
+ self._client.verify = False
110
+
111
+ def get_fund_detail(self, fund_code: str) -> dict[str, Any]:
112
+ """
113
+ Get detailed information about a fund.
114
+
115
+ Args:
116
+ fund_code: TEFAS fund code (e.g., "AAK", "TTE")
117
+
118
+ Returns:
119
+ Dictionary with fund details.
120
+ """
121
+ fund_code = fund_code.upper()
122
+
123
+ cache_key = f"tefas:detail:{fund_code}"
124
+ cached = self._cache_get(cache_key)
125
+ if cached is not None:
126
+ return cached
127
+
128
+ try:
129
+ url = f"{self.BASE_URL}/GetAllFundAnalyzeData"
130
+ data = {"dil": "TR", "fonkod": fund_code}
131
+
132
+ headers = {
133
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
134
+ "User-Agent": self.DEFAULT_HEADERS["User-Agent"],
135
+ "Accept": "application/json, text/plain, */*",
136
+ }
137
+
138
+ response = self._client.post(url, data=data, headers=headers)
139
+ response.raise_for_status()
140
+ result = response.json()
141
+
142
+ if not result or not result.get("fundInfo"):
143
+ raise DataNotAvailableError(f"No data for fund: {fund_code}")
144
+
145
+ fund_info = result["fundInfo"][0]
146
+ fund_return = result.get("fundReturn", [{}])[0] if result.get("fundReturn") else {}
147
+ fund_profile = result.get("fundProfile", [{}])[0] if result.get("fundProfile") else {}
148
+ fund_allocation = result.get("fundAllocation", [])
149
+
150
+ # Parse allocation data
151
+ allocation = None
152
+ if fund_allocation:
153
+ allocation = []
154
+ for item in fund_allocation:
155
+ weight = float(item.get("PORTFOYORANI", 0) or 0)
156
+ if weight > 0:
157
+ asset_type_tr = item.get("KIYMETTIP", "")
158
+ allocation.append({
159
+ "asset_type": asset_type_tr,
160
+ "asset_name": ASSET_NAME_STANDARDIZATION.get(asset_type_tr, asset_type_tr),
161
+ "weight": weight,
162
+ })
163
+ # Sort by weight descending
164
+ allocation.sort(key=lambda x: x["weight"], reverse=True)
165
+
166
+ detail = {
167
+ "fund_code": fund_code,
168
+ "name": fund_info.get("FONUNVAN", ""),
169
+ "date": fund_info.get("TARIH", ""),
170
+ "price": float(fund_info.get("SONFIYAT", 0) or 0),
171
+ "fund_size": float(fund_info.get("PORTBUYUKLUK", 0) or 0),
172
+ "investor_count": int(fund_info.get("YATIRIMCISAYI", 0) or 0),
173
+ "founder": fund_info.get("KURUCU", ""),
174
+ "manager": fund_info.get("YONETICI", ""),
175
+ "fund_type": fund_info.get("FONTUR", ""),
176
+ "category": fund_info.get("FONKATEGORI", ""),
177
+ "risk_value": int(fund_info.get("RISKDEGERI", 0) or 0),
178
+ # Performance metrics
179
+ "return_1m": fund_return.get("GETIRI1A"),
180
+ "return_3m": fund_return.get("GETIRI3A"),
181
+ "return_6m": fund_return.get("GETIRI6A"),
182
+ "return_ytd": fund_return.get("GETIRIYB"),
183
+ "return_1y": fund_return.get("GETIRI1Y"),
184
+ "return_3y": fund_return.get("GETIRI3Y"),
185
+ "return_5y": fund_return.get("GETIRI5Y"),
186
+ # Daily/weekly change
187
+ "daily_return": fund_info.get("GUNLUKGETIRI"),
188
+ "weekly_return": fund_info.get("HAFTALIKGETIRI"),
189
+ # Category ranking
190
+ "category_rank": fund_info.get("KATEGORIDERECE"),
191
+ "category_fund_count": fund_info.get("KATEGORIFONSAY"),
192
+ "market_share": fund_info.get("PAZARPAYI"),
193
+ # Fund profile (from fundProfile)
194
+ "isin": fund_profile.get("ISINKOD"),
195
+ "last_trading_time": fund_profile.get("SONISSAAT"),
196
+ "min_purchase": fund_profile.get("MINALIS"),
197
+ "min_redemption": fund_profile.get("MINSATIS"),
198
+ "entry_fee": fund_profile.get("GIRISKOMISYONU"),
199
+ "exit_fee": fund_profile.get("CIKISKOMISYONU"),
200
+ "kap_link": fund_profile.get("KAPLINK"),
201
+ # Portfolio allocation (from fundAllocation)
202
+ "allocation": allocation,
203
+ }
204
+
205
+ self._cache_set(cache_key, detail, TTL.FX_RATES)
206
+ return detail
207
+
208
+ except Exception as e:
209
+ raise APIError(f"Failed to fetch fund detail for {fund_code}: {e}") from e
210
+
211
+ # WAF limit for TEFAS API - requests longer than ~90 days get blocked
212
+ MAX_CHUNK_DAYS = 90
213
+
214
+ def get_history(
215
+ self,
216
+ fund_code: str,
217
+ period: str = "1mo",
218
+ start: datetime | None = None,
219
+ end: datetime | None = None,
220
+ ) -> pd.DataFrame:
221
+ """
222
+ Get historical price data for a fund.
223
+
224
+ Args:
225
+ fund_code: TEFAS fund code
226
+ period: Data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 3y, 5y, max)
227
+ start: Start date
228
+ end: End date
229
+
230
+ Returns:
231
+ DataFrame with price history.
232
+
233
+ Note:
234
+ For periods longer than 90 days, data is fetched in chunks
235
+ to avoid TEFAS WAF blocking.
236
+ """
237
+ fund_code = fund_code.upper()
238
+
239
+ # Calculate date range
240
+ end_dt = end or datetime.now()
241
+ if start:
242
+ start_dt = start
243
+ else:
244
+ days = {
245
+ "1d": 1,
246
+ "5d": 5,
247
+ "1mo": 30,
248
+ "3mo": 90,
249
+ "6mo": 180,
250
+ "1y": 365,
251
+ "3y": 365 * 3,
252
+ "5y": 365 * 5,
253
+ "max": 365 * 5, # Limited to 5y due to WAF constraints
254
+ }.get(period, 30)
255
+ start_dt = end_dt - timedelta(days=days)
256
+
257
+ cache_key = f"tefas:history:{fund_code}:{start_dt.date()}:{end_dt.date()}"
258
+ cached = self._cache_get(cache_key)
259
+ if cached is not None:
260
+ return cached
261
+
262
+ # Check if we need chunked requests
263
+ total_days = (end_dt - start_dt).days
264
+ if total_days > self.MAX_CHUNK_DAYS:
265
+ df = self._get_history_chunked(fund_code, start_dt, end_dt)
266
+ else:
267
+ df = self._fetch_history_chunk(fund_code, start_dt, end_dt)
268
+
269
+ self._cache_set(cache_key, df, TTL.OHLCV_HISTORY)
270
+ return df
271
+
272
+ def _get_history_chunked(
273
+ self,
274
+ fund_code: str,
275
+ start_dt: datetime,
276
+ end_dt: datetime,
277
+ ) -> pd.DataFrame:
278
+ """
279
+ Fetch history in chunks to avoid WAF blocking.
280
+
281
+ TEFAS WAF blocks requests longer than ~90-100 days.
282
+ This method fetches data in chunks and combines them.
283
+ """
284
+ import time
285
+
286
+ all_records = []
287
+ chunk_start = start_dt
288
+ chunk_count = 0
289
+
290
+ while chunk_start < end_dt:
291
+ chunk_end = min(chunk_start + timedelta(days=self.MAX_CHUNK_DAYS), end_dt)
292
+
293
+ try:
294
+ # Add delay between requests to avoid rate limiting
295
+ if chunk_count > 0:
296
+ time.sleep(0.3)
297
+
298
+ chunk_df = self._fetch_history_chunk(fund_code, chunk_start, chunk_end)
299
+ if not chunk_df.empty:
300
+ all_records.append(chunk_df)
301
+ chunk_count += 1
302
+ except DataNotAvailableError:
303
+ # No data for this chunk, continue to next
304
+ pass
305
+ except APIError:
306
+ # WAF blocked - stop fetching older data
307
+ # Return what we have so far
308
+ break
309
+
310
+ # Move to next chunk
311
+ chunk_start = chunk_end + timedelta(days=1)
312
+
313
+ if not all_records:
314
+ raise DataNotAvailableError(f"No history for fund: {fund_code}")
315
+
316
+ # Combine all chunks
317
+ df = pd.concat(all_records)
318
+ df = df[~df.index.duplicated(keep="last")] # Remove duplicate dates
319
+ df.sort_index(inplace=True)
320
+ return df
321
+
322
+ def _fetch_history_chunk(
323
+ self,
324
+ fund_code: str,
325
+ start_dt: datetime,
326
+ end_dt: datetime,
327
+ ) -> pd.DataFrame:
328
+ """Fetch a single chunk of history data (max ~90 days)."""
329
+ try:
330
+ url = f"{self.BASE_URL}/BindHistoryInfo"
331
+
332
+ data = {
333
+ "fontip": "YAT",
334
+ "sfontur": "",
335
+ "fonkod": fund_code,
336
+ "fongrup": "",
337
+ "bastarih": start_dt.strftime("%d.%m.%Y"),
338
+ "bittarih": end_dt.strftime("%d.%m.%Y"),
339
+ "fonturkod": "",
340
+ "fonunvantip": "",
341
+ "kurucukod": "",
342
+ }
343
+
344
+ headers = {
345
+ "Accept": "application/json, text/javascript, */*; q=0.01",
346
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
347
+ "Origin": "https://www.tefas.gov.tr",
348
+ "Referer": "https://www.tefas.gov.tr/TarihselVeriler.aspx",
349
+ "User-Agent": self.DEFAULT_HEADERS["User-Agent"],
350
+ "X-Requested-With": "XMLHttpRequest",
351
+ }
352
+
353
+ response = self._client.post(url, data=data, headers=headers)
354
+ response.raise_for_status()
355
+
356
+ # Check if response is JSON (not HTML error page)
357
+ content_type = response.headers.get("content-type", "")
358
+ if "text/html" in content_type:
359
+ raise APIError("TEFAS WAF blocked the request")
360
+
361
+ result = response.json()
362
+
363
+ if not result.get("data"):
364
+ raise DataNotAvailableError(f"No history for fund: {fund_code}")
365
+
366
+ records = []
367
+ for item in result["data"]:
368
+ timestamp = int(item.get("TARIH", 0))
369
+ if timestamp > 0:
370
+ dt = datetime.fromtimestamp(timestamp / 1000)
371
+ records.append(
372
+ {
373
+ "Date": dt,
374
+ "Price": float(item.get("FIYAT", 0)),
375
+ "FundSize": float(item.get("PORTFOYBUYUKLUK", 0)),
376
+ "Investors": int(item.get("KISISAYISI", 0)),
377
+ }
378
+ )
379
+
380
+ df = pd.DataFrame(records)
381
+ if not df.empty:
382
+ df.set_index("Date", inplace=True)
383
+ df.sort_index(inplace=True)
384
+
385
+ return df
386
+
387
+ except Exception as e:
388
+ if "WAF" in str(e):
389
+ raise
390
+ raise APIError(f"Failed to fetch history for {fund_code}: {e}") from e
391
+
392
+ def get_allocation(
393
+ self,
394
+ fund_code: str,
395
+ start: datetime | None = None,
396
+ end: datetime | None = None,
397
+ ) -> pd.DataFrame:
398
+ """
399
+ Get portfolio allocation (asset breakdown) for a fund.
400
+
401
+ Args:
402
+ fund_code: TEFAS fund code
403
+ start: Start date (default: 7 days ago)
404
+ end: End date (default: today)
405
+
406
+ Returns:
407
+ DataFrame with columns: Date, asset_type, asset_name, weight
408
+ """
409
+ fund_code = fund_code.upper()
410
+
411
+ # Default date range (1 week)
412
+ end_dt = end or datetime.now()
413
+ start_dt = start or (end_dt - timedelta(days=7))
414
+
415
+ cache_key = f"tefas:allocation:{fund_code}:{start_dt.date()}:{end_dt.date()}"
416
+ cached = self._cache_get(cache_key)
417
+ if cached is not None:
418
+ return cached
419
+
420
+ try:
421
+ url = f"{self.BASE_URL}/BindHistoryAllocation"
422
+
423
+ data = {
424
+ "fontip": "YAT",
425
+ "sfontur": "",
426
+ "fonkod": fund_code,
427
+ "fongrup": "",
428
+ "bastarih": start_dt.strftime("%d.%m.%Y"),
429
+ "bittarih": end_dt.strftime("%d.%m.%Y"),
430
+ "fonturkod": "",
431
+ "fonunvantip": "",
432
+ "kurucukod": "",
433
+ }
434
+
435
+ headers = {
436
+ "Accept": "application/json, text/javascript, */*; q=0.01",
437
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
438
+ "Origin": "https://www.tefas.gov.tr",
439
+ "Referer": "https://www.tefas.gov.tr/TarihselVeriler.aspx",
440
+ "User-Agent": self.DEFAULT_HEADERS["User-Agent"],
441
+ "X-Requested-With": "XMLHttpRequest",
442
+ }
443
+
444
+ response = self._client.post(url, data=data, headers=headers)
445
+ response.raise_for_status()
446
+ result = response.json()
447
+
448
+ if not result.get("data"):
449
+ raise DataNotAvailableError(f"No allocation data for fund: {fund_code}")
450
+
451
+ records = []
452
+ for item in result["data"]:
453
+ timestamp = int(item.get("TARIH", 0))
454
+ if timestamp > 0:
455
+ dt = datetime.fromtimestamp(timestamp / 1000)
456
+
457
+ # Extract allocation percentages for each asset type
458
+ for key, value in item.items():
459
+ if key not in ["TARIH", "FONKODU", "FONUNVAN", "BilFiyat"] and value is not None:
460
+ asset_name = ASSET_TYPE_MAPPING.get(key, key)
461
+ weight = float(value)
462
+ if weight > 0: # Only include non-zero allocations
463
+ records.append({
464
+ "Date": dt,
465
+ "asset_type": key,
466
+ "asset_name": asset_name,
467
+ "weight": weight,
468
+ })
469
+
470
+ if not records:
471
+ raise DataNotAvailableError(f"No allocation data for fund: {fund_code}")
472
+
473
+ df = pd.DataFrame(records)
474
+ df.sort_values(["Date", "weight"], ascending=[False, False], inplace=True)
475
+
476
+ self._cache_set(cache_key, df, TTL.FX_RATES)
477
+ return df
478
+
479
+ except Exception as e:
480
+ raise APIError(f"Failed to fetch allocation for {fund_code}: {e}") from e
481
+
482
+ def screen_funds(
483
+ self,
484
+ fund_type: str = "YAT",
485
+ founder: str | None = None,
486
+ min_return_1m: float | None = None,
487
+ min_return_3m: float | None = None,
488
+ min_return_6m: float | None = None,
489
+ min_return_ytd: float | None = None,
490
+ min_return_1y: float | None = None,
491
+ min_return_3y: float | None = None,
492
+ limit: int = 50,
493
+ ) -> list[dict[str, Any]]:
494
+ """
495
+ Screen funds based on fund type and return criteria.
496
+
497
+ Args:
498
+ fund_type: Fund type filter:
499
+ - "YAT": Investment Funds (Yatırım Fonları) - default
500
+ - "EMK": Pension Funds (Emeklilik Fonları)
501
+ founder: Filter by fund management company code (e.g., "AKP", "GPY", "ISP")
502
+ min_return_1m: Minimum 1-month return (%)
503
+ min_return_3m: Minimum 3-month return (%)
504
+ min_return_6m: Minimum 6-month return (%)
505
+ min_return_ytd: Minimum year-to-date return (%)
506
+ min_return_1y: Minimum 1-year return (%)
507
+ min_return_3y: Minimum 3-year return (%)
508
+ limit: Maximum number of results (default: 50)
509
+
510
+ Returns:
511
+ List of funds matching the criteria, sorted by 1-year return.
512
+
513
+ Examples:
514
+ >>> provider.screen_funds(fund_type="EMK") # All pension funds
515
+ >>> provider.screen_funds(min_return_1y=50) # Funds with >50% 1Y return
516
+ >>> provider.screen_funds(fund_type="EMK", min_return_ytd=20)
517
+ """
518
+ try:
519
+ url = f"{self.BASE_URL}/BindComparisonFundReturns"
520
+
521
+ # Use calismatipi=2 for period-based returns (1A, 3A, 6A, YB, 1Y, 3Y, 5Y)
522
+ data = {
523
+ "calismatipi": "2", # Period-based returns
524
+ "fontip": fund_type,
525
+ "sfontur": "Tümü",
526
+ "kurucukod": founder or "",
527
+ "fongrup": "",
528
+ "bastarih": "Başlangıç", # Start (placeholder for period-based)
529
+ "bittarih": "Bitiş", # End (placeholder for period-based)
530
+ "fonturkod": "",
531
+ "fonunvantip": "",
532
+ "strperiod": "1,1,1,1,1,1,1", # All periods: 1A, 3A, 6A, YB, 1Y, 3Y, 5Y
533
+ "islemdurum": "1", # Active funds only
534
+ }
535
+
536
+ headers = {
537
+ "Accept": "application/json, text/javascript, */*; q=0.01",
538
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
539
+ "Origin": "https://www.tefas.gov.tr",
540
+ "Referer": "https://www.tefas.gov.tr/FonKarsilastirma.aspx",
541
+ "User-Agent": self.DEFAULT_HEADERS["User-Agent"],
542
+ "X-Requested-With": "XMLHttpRequest",
543
+ }
544
+
545
+ response = self._client.post(url, data=data, headers=headers)
546
+ response.raise_for_status()
547
+ result = response.json()
548
+
549
+ all_funds = result.get("data", []) if isinstance(result, dict) else result
550
+
551
+ # Apply return-based filters
552
+ filtered = []
553
+ for fund in all_funds:
554
+ # Extract return values
555
+ r1m = fund.get("GETIRI1A")
556
+ r3m = fund.get("GETIRI3A")
557
+ r6m = fund.get("GETIRI6A")
558
+ rytd = fund.get("GETIRIYB")
559
+ r1y = fund.get("GETIRI1Y")
560
+ r3y = fund.get("GETIRI3Y")
561
+ r5y = fund.get("GETIRI5Y")
562
+
563
+ # Apply filters
564
+ if min_return_1m is not None and (r1m is None or r1m < min_return_1m):
565
+ continue
566
+ if min_return_3m is not None and (r3m is None or r3m < min_return_3m):
567
+ continue
568
+ if min_return_6m is not None and (r6m is None or r6m < min_return_6m):
569
+ continue
570
+ if min_return_ytd is not None and (rytd is None or rytd < min_return_ytd):
571
+ continue
572
+ if min_return_1y is not None and (r1y is None or r1y < min_return_1y):
573
+ continue
574
+ if min_return_3y is not None and (r3y is None or r3y < min_return_3y):
575
+ continue
576
+
577
+ filtered.append({
578
+ "fund_code": fund.get("FONKODU", ""),
579
+ "name": fund.get("FONUNVAN", ""),
580
+ "fund_type": fund.get("FONTURACIKLAMA", ""),
581
+ "return_1m": r1m,
582
+ "return_3m": r3m,
583
+ "return_6m": r6m,
584
+ "return_ytd": rytd,
585
+ "return_1y": r1y,
586
+ "return_3y": r3y,
587
+ "return_5y": r5y,
588
+ })
589
+
590
+ # Sort by 1-year return (descending), then YTD if 1Y not available
591
+ def sort_key(x):
592
+ r1y = x.get("return_1y")
593
+ rytd = x.get("return_ytd")
594
+ if r1y is not None:
595
+ return (1, r1y)
596
+ if rytd is not None:
597
+ return (0, rytd)
598
+ return (-1, 0)
599
+
600
+ filtered.sort(key=sort_key, reverse=True)
601
+
602
+ return filtered[:limit]
603
+
604
+ except Exception as e:
605
+ raise APIError(f"Failed to screen funds: {e}") from e
606
+
607
+ def compare_funds(self, fund_codes: list[str]) -> dict[str, Any]:
608
+ """
609
+ Compare multiple funds side by side.
610
+
611
+ Args:
612
+ fund_codes: List of TEFAS fund codes to compare (max 10)
613
+
614
+ Returns:
615
+ Dictionary with:
616
+ - funds: List of fund details with performance metrics
617
+ - rankings: Ranking by different criteria
618
+ - summary: Aggregate statistics
619
+
620
+ Examples:
621
+ >>> provider.compare_funds(["AAK", "TTE", "YAF"])
622
+ """
623
+ if not fund_codes:
624
+ return {"funds": [], "rankings": {}, "summary": {}}
625
+
626
+ # Limit to 10 funds
627
+ fund_codes = [code.upper() for code in fund_codes[:10]]
628
+
629
+ funds_data = []
630
+ errors = []
631
+
632
+ for code in fund_codes:
633
+ try:
634
+ detail = self.get_fund_detail(code)
635
+ funds_data.append({
636
+ "fund_code": detail.get("fund_code"),
637
+ "name": detail.get("name"),
638
+ "fund_type": detail.get("fund_type"),
639
+ "category": detail.get("category"),
640
+ "founder": detail.get("founder"),
641
+ "price": detail.get("price"),
642
+ "fund_size": detail.get("fund_size"),
643
+ "investor_count": detail.get("investor_count"),
644
+ "risk_value": detail.get("risk_value"),
645
+ # Returns
646
+ "daily_return": detail.get("daily_return"),
647
+ "weekly_return": detail.get("weekly_return"),
648
+ "return_1m": detail.get("return_1m"),
649
+ "return_3m": detail.get("return_3m"),
650
+ "return_6m": detail.get("return_6m"),
651
+ "return_ytd": detail.get("return_ytd"),
652
+ "return_1y": detail.get("return_1y"),
653
+ "return_3y": detail.get("return_3y"),
654
+ "return_5y": detail.get("return_5y"),
655
+ # Allocation summary
656
+ "allocation": detail.get("allocation"),
657
+ })
658
+ except Exception as e:
659
+ errors.append({"fund_code": code, "error": str(e)})
660
+
661
+ if not funds_data:
662
+ return {"funds": [], "rankings": {}, "summary": {}, "errors": errors}
663
+
664
+ # Calculate rankings
665
+ rankings = {}
666
+
667
+ # Rank by 1-year return
668
+ sorted_1y = sorted(
669
+ [f for f in funds_data if f.get("return_1y") is not None],
670
+ key=lambda x: x["return_1y"],
671
+ reverse=True,
672
+ )
673
+ rankings["by_return_1y"] = [f["fund_code"] for f in sorted_1y]
674
+
675
+ # Rank by YTD return
676
+ sorted_ytd = sorted(
677
+ [f for f in funds_data if f.get("return_ytd") is not None],
678
+ key=lambda x: x["return_ytd"],
679
+ reverse=True,
680
+ )
681
+ rankings["by_return_ytd"] = [f["fund_code"] for f in sorted_ytd]
682
+
683
+ # Rank by fund size
684
+ sorted_size = sorted(
685
+ [f for f in funds_data if f.get("fund_size") is not None],
686
+ key=lambda x: x["fund_size"],
687
+ reverse=True,
688
+ )
689
+ rankings["by_size"] = [f["fund_code"] for f in sorted_size]
690
+
691
+ # Rank by risk (ascending - lower is better)
692
+ sorted_risk = sorted(
693
+ [f for f in funds_data if f.get("risk_value") is not None],
694
+ key=lambda x: x["risk_value"],
695
+ )
696
+ rankings["by_risk_asc"] = [f["fund_code"] for f in sorted_risk]
697
+
698
+ # Summary statistics
699
+ returns_1y = [f["return_1y"] for f in funds_data if f.get("return_1y") is not None]
700
+ returns_ytd = [f["return_ytd"] for f in funds_data if f.get("return_ytd") is not None]
701
+ sizes = [f["fund_size"] for f in funds_data if f.get("fund_size") is not None]
702
+
703
+ summary = {
704
+ "fund_count": len(funds_data),
705
+ "total_size": sum(sizes) if sizes else 0,
706
+ "avg_return_1y": sum(returns_1y) / len(returns_1y) if returns_1y else None,
707
+ "avg_return_ytd": sum(returns_ytd) / len(returns_ytd) if returns_ytd else None,
708
+ "best_return_1y": max(returns_1y) if returns_1y else None,
709
+ "worst_return_1y": min(returns_1y) if returns_1y else None,
710
+ }
711
+
712
+ result = {
713
+ "funds": funds_data,
714
+ "rankings": rankings,
715
+ "summary": summary,
716
+ }
717
+
718
+ if errors:
719
+ result["errors"] = errors
720
+
721
+ return result
722
+
723
+ def search(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
724
+ """
725
+ Search for funds by name or code.
726
+
727
+ Args:
728
+ query: Search query
729
+ limit: Maximum results
730
+
731
+ Returns:
732
+ List of matching funds.
733
+ """
734
+ try:
735
+ url = f"{self.BASE_URL}/BindComparisonFundReturns"
736
+
737
+ data = {
738
+ "calismatipi": "2",
739
+ "fontip": "YAT",
740
+ "sfontur": "Tümü",
741
+ "kurucukod": "",
742
+ "fongrup": "",
743
+ "bastarih": "Başlangıç",
744
+ "bittarih": "Bitiş",
745
+ "fonturkod": "",
746
+ "fonunvantip": "",
747
+ "strperiod": "1,1,1,1,1,1,1",
748
+ "islemdurum": "1",
749
+ }
750
+
751
+ headers = {
752
+ "Accept": "application/json, text/javascript, */*; q=0.01",
753
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
754
+ "Origin": "https://www.tefas.gov.tr",
755
+ "Referer": "https://www.tefas.gov.tr/FonKarsilastirma.aspx",
756
+ "User-Agent": self.DEFAULT_HEADERS["User-Agent"],
757
+ "X-Requested-With": "XMLHttpRequest",
758
+ }
759
+
760
+ response = self._client.post(url, data=data, headers=headers)
761
+ response.raise_for_status()
762
+ result = response.json()
763
+
764
+ all_funds = result.get("data", []) if isinstance(result, dict) else result
765
+
766
+ # Normalize query for matching
767
+ query_lower = query.lower()
768
+
769
+ matching = []
770
+ for fund in all_funds:
771
+ code = fund.get("FONKODU", "").lower()
772
+ name = fund.get("FONUNVAN", "").lower()
773
+
774
+ if query_lower in code or query_lower in name:
775
+ matching.append(
776
+ {
777
+ "fund_code": fund.get("FONKODU", ""),
778
+ "name": fund.get("FONUNVAN", ""),
779
+ "fund_type": fund.get("FONTURACIKLAMA", ""),
780
+ "return_1y": fund.get("GETIRI1Y"),
781
+ }
782
+ )
783
+
784
+ if len(matching) >= limit:
785
+ break
786
+
787
+ return matching
788
+
789
+ except Exception as e:
790
+ raise APIError(f"Failed to search funds: {e}") from e
791
+
792
+
793
+ # Singleton
794
+ _provider: TEFASProvider | None = None
795
+
796
+
797
+ def get_tefas_provider() -> TEFASProvider:
798
+ """Get singleton provider instance."""
799
+ global _provider
800
+ if _provider is None:
801
+ _provider = TEFASProvider()
802
+ return _provider