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/fund.py ADDED
@@ -0,0 +1,471 @@
1
+ """Fund class for mutual fund data - yfinance-like API."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from borsapy._providers.tefas import get_tefas_provider
10
+
11
+
12
+ class Fund:
13
+ """
14
+ A yfinance-like interface for mutual fund data from TEFAS.
15
+
16
+ Examples:
17
+ >>> import borsapy as bp
18
+ >>> fund = bp.Fund("AAK")
19
+ >>> fund.info
20
+ {'fund_code': 'AAK', 'name': 'Ak Portföy...', 'price': 1.234, ...}
21
+ >>> fund.history(period="1mo")
22
+ Price FundSize Investors
23
+ Date
24
+ 2024-12-01 1.200 150000000.0 5000
25
+ ...
26
+
27
+ >>> fund = bp.Fund("TTE")
28
+ >>> fund.info['return_1y']
29
+ 45.67
30
+ """
31
+
32
+ def __init__(self, fund_code: str):
33
+ """
34
+ Initialize a Fund object.
35
+
36
+ Args:
37
+ fund_code: TEFAS fund code (e.g., "AAK", "TTE", "YAF")
38
+ """
39
+ self._fund_code = fund_code.upper()
40
+ self._provider = get_tefas_provider()
41
+ self._info_cache: dict[str, Any] | None = None
42
+
43
+ @property
44
+ def fund_code(self) -> str:
45
+ """Return the fund code."""
46
+ return self._fund_code
47
+
48
+ @property
49
+ def symbol(self) -> str:
50
+ """Return the fund code (alias)."""
51
+ return self._fund_code
52
+
53
+ @property
54
+ def info(self) -> dict[str, Any]:
55
+ """
56
+ Get detailed fund information.
57
+
58
+ Returns:
59
+ Dictionary with fund details:
60
+ - fund_code: TEFAS fund code
61
+ - name: Fund full name
62
+ - date: Last update date
63
+ - price: Current unit price
64
+ - fund_size: Total fund size (TRY)
65
+ - investor_count: Number of investors
66
+ - founder: Fund founder company
67
+ - manager: Fund manager company
68
+ - fund_type: Fund type
69
+ - category: Fund category
70
+ - risk_value: Risk rating (1-7)
71
+ - return_1m, return_3m, return_6m: Period returns
72
+ - return_ytd: Year-to-date return
73
+ - return_1y, return_3y, return_5y: Annual returns
74
+ - daily_return: Daily return
75
+ """
76
+ if self._info_cache is None:
77
+ self._info_cache = self._provider.get_fund_detail(self._fund_code)
78
+ return self._info_cache
79
+
80
+ @property
81
+ def detail(self) -> dict[str, Any]:
82
+ """Alias for info property."""
83
+ return self.info
84
+
85
+ @property
86
+ def performance(self) -> dict[str, Any]:
87
+ """
88
+ Get fund performance metrics only.
89
+
90
+ Returns:
91
+ Dictionary with performance data:
92
+ - daily_return: Daily return
93
+ - return_1m, return_3m, return_6m: Period returns
94
+ - return_ytd: Year-to-date return
95
+ - return_1y, return_3y, return_5y: Annual returns
96
+ """
97
+ info = self.info
98
+ return {
99
+ "daily_return": info.get("daily_return"),
100
+ "return_1m": info.get("return_1m"),
101
+ "return_3m": info.get("return_3m"),
102
+ "return_6m": info.get("return_6m"),
103
+ "return_ytd": info.get("return_ytd"),
104
+ "return_1y": info.get("return_1y"),
105
+ "return_3y": info.get("return_3y"),
106
+ "return_5y": info.get("return_5y"),
107
+ }
108
+
109
+ @property
110
+ def allocation(self) -> pd.DataFrame:
111
+ """
112
+ Get current portfolio allocation (asset breakdown) for last 7 days.
113
+
114
+ For longer periods, use allocation_history() method.
115
+
116
+ Returns:
117
+ DataFrame with columns: Date, asset_type, asset_name, weight.
118
+
119
+ Examples:
120
+ >>> fund = Fund("AAK")
121
+ >>> fund.allocation
122
+ Date asset_type asset_name weight
123
+ 0 2024-12-20 HS Hisse Senedi 45.32
124
+ 1 2024-12-20 DB Devlet Bonusu 30.15
125
+ ...
126
+ """
127
+ return self._provider.get_allocation(self._fund_code)
128
+
129
+ def allocation_history(
130
+ self,
131
+ period: str = "1mo",
132
+ start: datetime | str | None = None,
133
+ end: datetime | str | None = None,
134
+ ) -> pd.DataFrame:
135
+ """
136
+ Get historical portfolio allocation (asset breakdown).
137
+
138
+ Note: TEFAS API supports maximum ~100 days (3 months) of data.
139
+
140
+ Args:
141
+ period: How much data to fetch. Valid periods:
142
+ 1d, 5d, 1mo, 3mo (max ~100 days).
143
+ Ignored if start is provided.
144
+ start: Start date (string or datetime).
145
+ end: End date (string or datetime). Defaults to today.
146
+
147
+ Returns:
148
+ DataFrame with columns: Date, asset_type, asset_name, weight.
149
+
150
+ Examples:
151
+ >>> fund = Fund("AAK")
152
+ >>> fund.allocation_history(period="1mo") # Last month
153
+ >>> fund.allocation_history(period="3mo") # Last 3 months (max)
154
+ >>> fund.allocation_history(start="2024-10-01", end="2024-12-31")
155
+ """
156
+ start_dt = self._parse_date(start) if start else None
157
+ end_dt = self._parse_date(end) if end else None
158
+
159
+ # If no start date, calculate from period
160
+ if start_dt is None:
161
+ from datetime import timedelta
162
+ end_dt = end_dt or datetime.now()
163
+ days = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90}.get(period, 30)
164
+ # Cap at 100 days (API limit)
165
+ days = min(days, 100)
166
+ start_dt = end_dt - timedelta(days=days)
167
+
168
+ return self._provider.get_allocation(
169
+ fund_code=self._fund_code,
170
+ start=start_dt,
171
+ end=end_dt,
172
+ )
173
+
174
+ def history(
175
+ self,
176
+ period: str = "1mo",
177
+ start: datetime | str | None = None,
178
+ end: datetime | str | None = None,
179
+ ) -> pd.DataFrame:
180
+ """
181
+ Get historical price data.
182
+
183
+ Args:
184
+ period: How much data to fetch. Valid periods:
185
+ 1d, 5d, 1mo, 3mo, 6mo, 1y.
186
+ Ignored if start is provided.
187
+ start: Start date (string or datetime).
188
+ end: End date (string or datetime). Defaults to now.
189
+
190
+ Returns:
191
+ DataFrame with columns: Price, FundSize, Investors.
192
+ Index is the Date.
193
+
194
+ Examples:
195
+ >>> fund = Fund("AAK")
196
+ >>> fund.history(period="1mo") # Last month
197
+ >>> fund.history(period="1y") # Last year
198
+ >>> fund.history(start="2024-01-01", end="2024-06-30") # Date range
199
+ """
200
+ start_dt = self._parse_date(start) if start else None
201
+ end_dt = self._parse_date(end) if end else None
202
+
203
+ return self._provider.get_history(
204
+ fund_code=self._fund_code,
205
+ period=period,
206
+ start=start_dt,
207
+ end=end_dt,
208
+ )
209
+
210
+ def _parse_date(self, date: str | datetime) -> datetime:
211
+ """Parse a date string to datetime."""
212
+ if isinstance(date, datetime):
213
+ return date
214
+ for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"]:
215
+ try:
216
+ return datetime.strptime(date, fmt)
217
+ except ValueError:
218
+ continue
219
+ raise ValueError(f"Could not parse date: {date}")
220
+
221
+ def sharpe_ratio(self, period: str = "1y", risk_free_rate: float | None = None) -> float:
222
+ """
223
+ Calculate the Sharpe ratio for the fund.
224
+
225
+ Sharpe Ratio = (Rp - Rf) / σp
226
+ Where:
227
+ - Rp = Annualized return of the fund
228
+ - Rf = Risk-free rate (default: 10Y government bond yield)
229
+ - σp = Annualized standard deviation of returns
230
+
231
+ Args:
232
+ period: Period for calculation ("1y", "3y", "5y"). Default is "1y".
233
+ risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
234
+ If None, uses current 10Y bond yield from bp.risk_free_rate().
235
+
236
+ Returns:
237
+ Sharpe ratio as float. Higher is better (>1 good, >2 very good, >3 excellent).
238
+
239
+ Examples:
240
+ >>> fund = bp.Fund("YAY")
241
+ >>> fund.sharpe_ratio() # 1-year Sharpe with current risk-free rate
242
+ 0.85
243
+
244
+ >>> fund.sharpe_ratio(period="3y") # 3-year Sharpe
245
+ 1.23
246
+
247
+ >>> fund.sharpe_ratio(risk_free_rate=0.25) # Custom risk-free rate
248
+ 0.92
249
+ """
250
+ metrics = self.risk_metrics(period=period, risk_free_rate=risk_free_rate)
251
+ return metrics.get("sharpe_ratio", np.nan)
252
+
253
+ def risk_metrics(
254
+ self,
255
+ period: str = "1y",
256
+ risk_free_rate: float | None = None,
257
+ ) -> dict[str, Any]:
258
+ """
259
+ Calculate comprehensive risk metrics for the fund.
260
+
261
+ Args:
262
+ period: Period for calculation ("1y", "3y", "5y"). Default is "1y".
263
+ risk_free_rate: Annual risk-free rate as decimal (e.g., 0.28 for 28%).
264
+ If None, uses current 10Y bond yield.
265
+
266
+ Returns:
267
+ Dictionary with risk metrics:
268
+ - annualized_return: Annualized return (%)
269
+ - annualized_volatility: Annualized standard deviation (%)
270
+ - sharpe_ratio: Risk-adjusted return (Rp - Rf) / σp
271
+ - sortino_ratio: Downside risk-adjusted return
272
+ - max_drawdown: Maximum peak-to-trough decline (%)
273
+ - risk_free_rate: Risk-free rate used (%)
274
+ - trading_days: Number of trading days in the period
275
+
276
+ Examples:
277
+ >>> fund = bp.Fund("YAY")
278
+ >>> metrics = fund.risk_metrics()
279
+ >>> print(f"Sharpe: {metrics['sharpe_ratio']:.2f}")
280
+ >>> print(f"Max Drawdown: {metrics['max_drawdown']:.1f}%")
281
+ """
282
+ # Get historical data
283
+ df = self.history(period=period)
284
+
285
+ if df.empty or len(df) < 20:
286
+ return {
287
+ "annualized_return": np.nan,
288
+ "annualized_volatility": np.nan,
289
+ "sharpe_ratio": np.nan,
290
+ "sortino_ratio": np.nan,
291
+ "max_drawdown": np.nan,
292
+ "risk_free_rate": np.nan,
293
+ "trading_days": 0,
294
+ }
295
+
296
+ # Calculate daily returns
297
+ prices = df["Price"]
298
+ daily_returns = prices.pct_change().dropna()
299
+ trading_days = len(daily_returns)
300
+
301
+ # Annualization factor (trading days per year)
302
+ annualization_factor = 252
303
+
304
+ # Annualized return
305
+ total_return = (prices.iloc[-1] / prices.iloc[0]) - 1
306
+ years = trading_days / annualization_factor
307
+ annualized_return = ((1 + total_return) ** (1 / years) - 1) * 100
308
+
309
+ # Annualized volatility
310
+ daily_volatility = daily_returns.std()
311
+ annualized_volatility = daily_volatility * np.sqrt(annualization_factor) * 100
312
+
313
+ # Get risk-free rate
314
+ if risk_free_rate is None:
315
+ try:
316
+ from borsapy.bond import risk_free_rate as get_rf_rate
317
+ rf = get_rf_rate() * 100 # Returns decimal like 0.28, convert to %
318
+ except Exception:
319
+ rf = 30.0 # Fallback: approximate Turkish 10Y yield
320
+ else:
321
+ rf = risk_free_rate * 100 # Convert decimal to percentage
322
+
323
+ # Sharpe Ratio
324
+ if annualized_volatility > 0:
325
+ sharpe = (annualized_return - rf) / annualized_volatility
326
+ else:
327
+ sharpe = np.nan
328
+
329
+ # Sortino Ratio (uses downside deviation)
330
+ negative_returns = daily_returns[daily_returns < 0]
331
+ if len(negative_returns) > 0:
332
+ downside_deviation = negative_returns.std() * np.sqrt(annualization_factor) * 100
333
+ if downside_deviation > 0:
334
+ sortino = (annualized_return - rf) / downside_deviation
335
+ else:
336
+ sortino = np.nan
337
+ else:
338
+ sortino = np.inf # No negative returns
339
+
340
+ # Maximum Drawdown
341
+ cumulative = (1 + daily_returns).cumprod()
342
+ running_max = cumulative.cummax()
343
+ drawdowns = (cumulative - running_max) / running_max
344
+ max_drawdown = drawdowns.min() * 100 # Negative percentage
345
+
346
+ return {
347
+ "annualized_return": round(annualized_return, 2),
348
+ "annualized_volatility": round(annualized_volatility, 2),
349
+ "sharpe_ratio": round(sharpe, 2) if not np.isnan(sharpe) else np.nan,
350
+ "sortino_ratio": round(sortino, 2) if not np.isnan(sortino) and not np.isinf(sortino) else sortino,
351
+ "max_drawdown": round(max_drawdown, 2),
352
+ "risk_free_rate": round(rf, 2),
353
+ "trading_days": trading_days,
354
+ }
355
+
356
+ def __repr__(self) -> str:
357
+ return f"Fund('{self._fund_code}')"
358
+
359
+
360
+ def search_funds(query: str, limit: int = 20) -> list[dict[str, Any]]:
361
+ """
362
+ Search for funds by name or code.
363
+
364
+ Args:
365
+ query: Search query (fund code or name)
366
+ limit: Maximum number of results
367
+
368
+ Returns:
369
+ List of matching funds with fund_code, name, fund_type, return_1y.
370
+
371
+ Examples:
372
+ >>> import borsapy as bp
373
+ >>> bp.search_funds("ak portföy")
374
+ [{'fund_code': 'AAK', 'name': 'Ak Portföy...', ...}, ...]
375
+ >>> bp.search_funds("TTE")
376
+ [{'fund_code': 'TTE', 'name': 'Türkiye...', ...}]
377
+ """
378
+ provider = get_tefas_provider()
379
+ return provider.search(query, limit)
380
+
381
+
382
+ def screen_funds(
383
+ fund_type: str = "YAT",
384
+ founder: str | None = None,
385
+ min_return_1m: float | None = None,
386
+ min_return_3m: float | None = None,
387
+ min_return_6m: float | None = None,
388
+ min_return_ytd: float | None = None,
389
+ min_return_1y: float | None = None,
390
+ min_return_3y: float | None = None,
391
+ limit: int = 50,
392
+ ) -> pd.DataFrame:
393
+ """
394
+ Screen funds based on fund type and return criteria.
395
+
396
+ Args:
397
+ fund_type: Fund type filter:
398
+ - "YAT": Investment Funds (Yatırım Fonları) - default
399
+ - "EMK": Pension Funds (Emeklilik Fonları)
400
+ founder: Filter by fund management company code (e.g., "AKP", "GPY", "ISP")
401
+ min_return_1m: Minimum 1-month return (%)
402
+ min_return_3m: Minimum 3-month return (%)
403
+ min_return_6m: Minimum 6-month return (%)
404
+ min_return_ytd: Minimum year-to-date return (%)
405
+ min_return_1y: Minimum 1-year return (%)
406
+ min_return_3y: Minimum 3-year return (%)
407
+ limit: Maximum number of results (default: 50)
408
+
409
+ Returns:
410
+ DataFrame with funds matching the criteria, sorted by 1-year return.
411
+
412
+ Examples:
413
+ >>> import borsapy as bp
414
+ >>> bp.screen_funds(fund_type="EMK") # All pension funds
415
+ fund_code name return_1y ...
416
+
417
+ >>> bp.screen_funds(min_return_1y=50) # Funds with >50% 1Y return
418
+ fund_code name return_1y ...
419
+
420
+ >>> bp.screen_funds(fund_type="EMK", min_return_ytd=20)
421
+ fund_code name return_ytd ...
422
+ """
423
+ provider = get_tefas_provider()
424
+ results = provider.screen_funds(
425
+ fund_type=fund_type,
426
+ founder=founder,
427
+ min_return_1m=min_return_1m,
428
+ min_return_3m=min_return_3m,
429
+ min_return_6m=min_return_6m,
430
+ min_return_ytd=min_return_ytd,
431
+ min_return_1y=min_return_1y,
432
+ min_return_3y=min_return_3y,
433
+ limit=limit,
434
+ )
435
+
436
+ if not results:
437
+ return pd.DataFrame(columns=["fund_code", "name", "fund_type", "return_1y"])
438
+
439
+ return pd.DataFrame(results)
440
+
441
+
442
+ def compare_funds(fund_codes: list[str]) -> dict[str, Any]:
443
+ """
444
+ Compare multiple funds side by side.
445
+
446
+ Args:
447
+ fund_codes: List of TEFAS fund codes to compare (max 10)
448
+
449
+ Returns:
450
+ Dictionary with:
451
+ - funds: List of fund details with performance metrics
452
+ - rankings: Ranking by different criteria (by_return_1y, by_return_ytd, by_size, by_risk_asc)
453
+ - summary: Aggregate statistics (avg_return_1y, best/worst returns, total_size)
454
+
455
+ Examples:
456
+ >>> import borsapy as bp
457
+ >>> result = bp.compare_funds(["AAK", "TTE", "YAF"])
458
+ >>> result['rankings']['by_return_1y']
459
+ ['TTE', 'YAF', 'AAK']
460
+
461
+ >>> result['summary']
462
+ {'fund_count': 3, 'avg_return_1y': 45.2, 'best_return_1y': 72.1, ...}
463
+
464
+ >>> for fund in result['funds']:
465
+ ... print(f"{fund['fund_code']}: {fund['return_1y']}%")
466
+ AAK: 32.5%
467
+ TTE: 72.1%
468
+ YAF: 31.0%
469
+ """
470
+ provider = get_tefas_provider()
471
+ return provider.compare_funds(fund_codes)