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,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
|