mftool 3.2__tar.gz → 3.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mftool
3
- Version: 3.2
3
+ Version: 3.3
4
4
  Summary: Library for getting real time Mutual funds info
5
5
  Home-page: https://github.com/NayakwadiS/mftool
6
6
  Author: SujitN
@@ -21,5 +21,5 @@
21
21
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
22
  SOFTWARE.
23
23
  """
24
- __VERSION__='3.2'
24
+ __VERSION__='3.3'
25
25
  from .mftool import Mftool
@@ -0,0 +1,233 @@
1
+ """
2
+ Cache utility for mftool
3
+ Implements TTL-based caching to reduce redundant API calls
4
+ """
5
+ import time
6
+ import json
7
+ import os
8
+ from functools import wraps
9
+ from threading import Lock
10
+
11
+
12
+ class CacheManager:
13
+ """
14
+ Thread-safe in-memory cache with TTL support
15
+ """
16
+ def __init__(self, default_ttl=86400): # 24 hours default
17
+ self._cache = {}
18
+ self._lock = Lock()
19
+ self.default_ttl = default_ttl
20
+ self.enabled = True
21
+
22
+ def get(self, key):
23
+ """Get value from cache if not expired"""
24
+ if not self.enabled:
25
+ return None
26
+
27
+ with self._lock:
28
+ if key in self._cache:
29
+ value, expiry = self._cache[key]
30
+ if time.time() < expiry:
31
+ return value
32
+ else:
33
+ # Remove expired entry
34
+ del self._cache[key]
35
+ return None
36
+
37
+ def set(self, key, value, ttl=None):
38
+ """Set value in cache with TTL"""
39
+ if not self.enabled:
40
+ return
41
+
42
+ if ttl is None:
43
+ ttl = self.default_ttl
44
+
45
+ expiry = time.time() + ttl
46
+ with self._lock:
47
+ self._cache[key] = (value, expiry)
48
+
49
+ def clear(self):
50
+ """Clear all cache entries"""
51
+ with self._lock:
52
+ self._cache.clear()
53
+
54
+ def clear_expired(self):
55
+ """Remove all expired entries"""
56
+ current_time = time.time()
57
+ with self._lock:
58
+ expired_keys = [k for k, (_, expiry) in self._cache.items() if current_time >= expiry]
59
+ for key in expired_keys:
60
+ del self._cache[key]
61
+
62
+ def disable(self):
63
+ """Disable caching"""
64
+ self.enabled = False
65
+
66
+ def enable(self):
67
+ """Enable caching"""
68
+ self.enabled = True
69
+
70
+ def get_stats(self):
71
+ """Get cache statistics"""
72
+ with self._lock:
73
+ total_entries = len(self._cache)
74
+ current_time = time.time()
75
+ valid_entries = sum(1 for _, expiry in self._cache.values() if current_time < expiry)
76
+ expired_entries = total_entries - valid_entries
77
+
78
+ return {
79
+ 'total_entries': total_entries,
80
+ 'valid_entries': valid_entries,
81
+ 'expired_entries': expired_entries,
82
+ 'cache_enabled': self.enabled
83
+ }
84
+
85
+
86
+ class DiskCache:
87
+ """
88
+ Persistent disk-based cache with TTL support
89
+ """
90
+ def __init__(self, cache_dir=None, default_ttl=86400):
91
+ if cache_dir is None:
92
+ cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.cache')
93
+
94
+ self.cache_dir = cache_dir
95
+ self.default_ttl = default_ttl
96
+ self.enabled = True
97
+ self._lock = Lock()
98
+
99
+ # Create cache directory if it doesn't exist
100
+ if not os.path.exists(self.cache_dir):
101
+ os.makedirs(self.cache_dir)
102
+
103
+ def _get_cache_path(self, key):
104
+ """Generate cache file path for key"""
105
+ # Use hash to create safe filename
106
+ safe_key = str(abs(hash(key)))
107
+ return os.path.join(self.cache_dir, f"{safe_key}.json")
108
+
109
+ def get(self, key):
110
+ """Get value from disk cache if not expired"""
111
+ if not self.enabled:
112
+ return None
113
+
114
+ cache_path = self._get_cache_path(key)
115
+
116
+ with self._lock:
117
+ if os.path.exists(cache_path):
118
+ try:
119
+ with open(cache_path, 'r') as f:
120
+ cache_data = json.load(f)
121
+
122
+ if time.time() < cache_data['expiry']:
123
+ return cache_data['value']
124
+ else:
125
+ # Remove expired file
126
+ os.remove(cache_path)
127
+ except (json.JSONDecodeError, KeyError, IOError):
128
+ # Invalid cache file, remove it
129
+ if os.path.exists(cache_path):
130
+ os.remove(cache_path)
131
+
132
+ return None
133
+
134
+ def set(self, key, value, ttl=None):
135
+ """Set value in disk cache with TTL"""
136
+ if not self.enabled:
137
+ return
138
+
139
+ if ttl is None:
140
+ ttl = self.default_ttl
141
+
142
+ cache_path = self._get_cache_path(key)
143
+ expiry = time.time() + ttl
144
+
145
+ cache_data = {
146
+ 'value': value,
147
+ 'expiry': expiry,
148
+ 'created': time.time()
149
+ }
150
+
151
+ with self._lock:
152
+ try:
153
+ with open(cache_path, 'w') as f:
154
+ json.dump(cache_data, f)
155
+ except IOError:
156
+ pass # Silently fail if can't write cache
157
+
158
+ def clear(self):
159
+ """Clear all cache files"""
160
+ with self._lock:
161
+ if os.path.exists(self.cache_dir):
162
+ for filename in os.listdir(self.cache_dir):
163
+ file_path = os.path.join(self.cache_dir, filename)
164
+ if os.path.isfile(file_path):
165
+ os.remove(file_path)
166
+
167
+ def clear_expired(self):
168
+ """Remove all expired cache files"""
169
+ current_time = time.time()
170
+ with self._lock:
171
+ if os.path.exists(self.cache_dir):
172
+ for filename in os.listdir(self.cache_dir):
173
+ file_path = os.path.join(self.cache_dir, filename)
174
+ if os.path.isfile(file_path):
175
+ try:
176
+ with open(file_path, 'r') as f:
177
+ cache_data = json.load(f)
178
+ if current_time >= cache_data['expiry']:
179
+ os.remove(file_path)
180
+ except (json.JSONDecodeError, KeyError, IOError):
181
+ os.remove(file_path)
182
+
183
+ def disable(self):
184
+ """Disable caching"""
185
+ self.enabled = False
186
+
187
+ def enable(self):
188
+ """Enable caching"""
189
+ self.enabled = True
190
+
191
+
192
+ def cached(ttl=86400, cache_type='memory'):
193
+ """
194
+ Decorator for caching function results
195
+
196
+ :param ttl: Time to live in seconds (default 24 hours)
197
+ :param cache_type: 'memory' or 'disk'
198
+ """
199
+ def decorator(func):
200
+ # Create cache instance for this function
201
+ if cache_type == 'disk':
202
+ cache = DiskCache(default_ttl=ttl)
203
+ else:
204
+ cache = CacheManager(default_ttl=ttl)
205
+
206
+ @wraps(func)
207
+ def wrapper(*args, **kwargs):
208
+ # Create cache key from function name and arguments
209
+ # Skip 'self' for instance methods
210
+ cache_args = args[1:] if args and hasattr(args[0], func.__name__) else args
211
+ cache_key = f"{func.__name__}:{str(cache_args)}:{str(kwargs)}"
212
+
213
+ # Try to get from cache
214
+ cached_value = cache.get(cache_key)
215
+ if cached_value is not None:
216
+ return cached_value
217
+
218
+ # Call function and cache result
219
+ result = func(*args, **kwargs)
220
+ if result is not None:
221
+ cache.set(cache_key, result, ttl)
222
+
223
+ return result
224
+
225
+ # Attach cache management methods
226
+ wrapper.clear_cache = cache.clear
227
+ wrapper.clear_expired = cache.clear_expired
228
+ wrapper.disable_cache = cache.disable
229
+ wrapper.enable_cache = cache.enable
230
+
231
+ return wrapper
232
+ return decorator
233
+
@@ -25,8 +25,11 @@ import yfinance as yf
25
25
  import datetime
26
26
  from deprecated import deprecated
27
27
  from matplotlib import pyplot as plt
28
- from .utils import Utilities, is_holiday, get_today, get_friday, render_response, get_52_week_friday, get_52_week_high_low
28
+ from .utils import Utilities, is_holiday, get_today, get_friday, render_response, get_52_week_high_low
29
+ from .cache import CacheManager
29
30
  import pandas as pd
31
+ from concurrent.futures import ThreadPoolExecutor, as_completed
32
+ from typing import List, Dict, Union
30
33
 
31
34
 
32
35
  class Mftool:
@@ -51,6 +54,13 @@ class Mftool:
51
54
  self._amc=self._const['amc']
52
55
  self._user_agent = self._const['user_agent']
53
56
  self._codes = self._const['codes']
57
+
58
+ # Initialize caching layer
59
+ # NAV data: 24 hours TTL (86400 seconds) - updates once daily
60
+ self._cache = CacheManager(default_ttl=86400)
61
+ # Scheme codes: 7 days TTL - rarely changes
62
+ self._scheme_codes_cache = CacheManager(default_ttl=604800)
63
+
54
64
  self._scheme_codes = self.get_scheme_codes().keys()
55
65
 
56
66
  def set_proxy(self, proxy):
@@ -69,6 +79,12 @@ class Mftool:
69
79
  cache handled internally
70
80
  :return: dict / json
71
81
  """
82
+ # Try to get from cache first
83
+ cache_key = f"scheme_codes:{as_json}"
84
+ cached_result = self._scheme_codes_cache.get(cache_key)
85
+ if cached_result is not None:
86
+ return cached_result
87
+
72
88
  scheme_info = {}
73
89
  url = self._get_quote_url
74
90
  response = self._session.get(url)
@@ -77,7 +93,11 @@ class Mftool:
77
93
  if ";" in scheme_data:
78
94
  scheme = scheme_data.split(";")
79
95
  scheme_info[scheme[0]] = scheme[3]
80
- return render_response(scheme_info, as_json)
96
+
97
+ result = render_response(scheme_info, as_json)
98
+ # Cache the result
99
+ self._scheme_codes_cache.set(cache_key, result)
100
+ return result
81
101
 
82
102
  def get_available_schemes(self, amc_name):
83
103
  """
@@ -121,6 +141,12 @@ class Mftool:
121
141
  """
122
142
  code = str(code)
123
143
  if self.is_valid_code(code):
144
+ # Try to get from cache first
145
+ cache_key = f"quote:{code}:{as_json}"
146
+ cached_result = self._cache.get(cache_key)
147
+ if cached_result is not None:
148
+ return cached_result
149
+
124
150
  scheme_info = {}
125
151
  url = self._get_quote_url
126
152
  response = self._session.get(url)
@@ -133,10 +159,281 @@ class Mftool:
133
159
  scheme_info['last_updated'] = scheme[5].replace("\r", "")
134
160
  scheme_info['nav'] = scheme[4]
135
161
  break
136
- return render_response(scheme_info, as_json)
162
+
163
+ result = render_response(scheme_info, as_json)
164
+ # Cache the result
165
+ self._cache.set(cache_key, result)
166
+ return result
137
167
  else:
138
168
  return None
139
169
 
170
+ def get_bulk_quotes(self, scheme_codes: List[Union[str, int]], as_json=False,
171
+ max_workers=10, show_progress=False) -> Dict[str, Union[dict, None]]:
172
+ """
173
+ Fetch quotes for multiple schemes concurrently for better performance.
174
+ Perfect for portfolio-level operations.
175
+
176
+ :param scheme_codes: List of scheme codes to fetch
177
+ :param as_json: Return data in JSON format (default: False)
178
+ :param max_workers: Maximum number of concurrent threads (default: 10)
179
+ :param show_progress: Print progress during fetching (default: False)
180
+ :return: Dictionary with scheme codes as keys and quote data as values
181
+ :raises: HTTPError, URLError
182
+
183
+ Example:
184
+ >>> mf = Mftool()
185
+ >>> codes = ['119597', '119062', '119061']
186
+ >>> quotes = mf.get_bulk_quotes(codes)
187
+ >>> print(quotes['119597']['nav'])
188
+ """
189
+ # Validate all codes first
190
+ valid_codes = []
191
+ invalid_codes = []
192
+
193
+ for code in scheme_codes:
194
+ code = str(code)
195
+ if self.is_valid_code(code):
196
+ valid_codes.append(code)
197
+ else:
198
+ invalid_codes.append(code)
199
+
200
+ if invalid_codes and show_progress:
201
+ print(f"Warning: {len(invalid_codes)} invalid scheme codes found: {invalid_codes[:5]}")
202
+
203
+ results = {}
204
+
205
+ # Check cache first for all codes
206
+ uncached_codes = []
207
+ for code in valid_codes:
208
+ cache_key = f"quote:{code}:{as_json}"
209
+ cached_result = self._cache.get(cache_key)
210
+ if cached_result is not None:
211
+ results[code] = cached_result
212
+ else:
213
+ uncached_codes.append(code)
214
+
215
+ if show_progress:
216
+ print(f"Fetching {len(uncached_codes)} quotes ({len(results)} from cache)...")
217
+
218
+ # Fetch uncached quotes concurrently
219
+ if uncached_codes:
220
+ def fetch_single_quote(code):
221
+ try:
222
+ quote = self.get_scheme_quote(code, as_json=as_json)
223
+ return code, quote
224
+ except Exception as e:
225
+ if show_progress:
226
+ print(f"Error fetching {code}: {str(e)[:50]}")
227
+ return code, None
228
+
229
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
230
+ futures = {executor.submit(fetch_single_quote, code): code for code in uncached_codes}
231
+
232
+ completed = 0
233
+ for future in as_completed(futures):
234
+ code, quote = future.result()
235
+ results[code] = quote
236
+ completed += 1
237
+
238
+ if show_progress and completed % 10 == 0:
239
+ print(f"Progress: {completed}/{len(uncached_codes)} completed")
240
+
241
+ if show_progress:
242
+ print(f"Fetched {len(results)} quotes successfully")
243
+
244
+ return results
245
+
246
+ def search_schemes(self, search_term: str, limit: int = 10, as_json=False) -> Union[List[Dict[str, str]], str]:
247
+ """
248
+ Search for mutual fund schemes by name using fuzzy matching.
249
+ Makes it easy to find schemes without knowing exact codes.
250
+
251
+ :param search_term: Name or partial name to search for (case-insensitive)
252
+ :param limit: Maximum number of results to return (default: 10, use 0 for all)
253
+ :param as_json: Return data in JSON format (default: False)
254
+ :return: List of matching schemes with code and name
255
+
256
+ Example:
257
+ >>> mf = Mftool()
258
+ >>> results = mf.search_schemes("HDFC midcap")
259
+ >>> for scheme in results:
260
+ ... print(f"{scheme['code']}: {scheme['name']}")
261
+
262
+ >>> # Get scheme code for first match
263
+ >>> matches = mf.search_schemes("Axis bluechip", limit=1)
264
+ >>> code = matches[0]['code'] if matches else None
265
+ """
266
+ search_term = search_term.lower().strip()
267
+
268
+ if not search_term:
269
+ return render_response([], as_json)
270
+
271
+ # Get all scheme codes and names
272
+ all_schemes = self.get_scheme_codes(as_json=False)
273
+
274
+ # Search for matches
275
+ matches = []
276
+ for code, name in all_schemes.items():
277
+ name_lower = name.lower()
278
+
279
+ # Check if search term is in the scheme name
280
+ if search_term in name_lower:
281
+ # Calculate relevance score (lower is better)
282
+ # Exact matches get highest priority
283
+ if name_lower == search_term:
284
+ score = 0
285
+ # Matches at the start of the name get high priority
286
+ elif name_lower.startswith(search_term):
287
+ score = 1
288
+ # Matches of whole words get medium priority
289
+ elif f" {search_term} " in f" {name_lower} ":
290
+ score = 2
291
+ # Partial matches get lower priority
292
+ else:
293
+ score = 3
294
+
295
+ matches.append({
296
+ 'code': code,
297
+ 'name': name,
298
+ 'score': score
299
+ })
300
+
301
+ # Sort by relevance (score) and then alphabetically by name
302
+ matches.sort(key=lambda x: (x['score'], x['name']))
303
+
304
+ # Remove score from results
305
+ results = [{'code': m['code'], 'name': m['name']} for m in matches]
306
+
307
+ # Apply limit if specified
308
+ if limit > 0:
309
+ results = results[:limit]
310
+
311
+ return render_response(results, as_json)
312
+
313
+ def search_schemes_by_amc(self, amc_name: str, search_term: str = "",
314
+ limit: int = 10, as_json=False) -> Union[List[Dict[str, str]], str]:
315
+ """
316
+ Search for schemes within a specific AMC (fund house).
317
+
318
+ :param amc_name: Name of AMC (e.g., "HDFC", "ICICI", "Axis")
319
+ :param search_term: Optional search term to filter schemes within the AMC
320
+ :param limit: Maximum number of results to return (default: 10, use 0 for all)
321
+ :param as_json: Return data in JSON format (default: False)
322
+ :return: List of matching schemes with code and name
323
+
324
+ Example:
325
+ >>> mf = Mftool()
326
+ >>> # Get all HDFC schemes
327
+ >>> hdfc_schemes = mf.search_schemes_by_amc("HDFC")
328
+ >>>
329
+ >>> # Get HDFC midcap schemes
330
+ >>> hdfc_midcap = mf.search_schemes_by_amc("HDFC", "midcap")
331
+ """
332
+ # Get all schemes from the AMC
333
+ amc_schemes = self.get_available_schemes(amc_name)
334
+
335
+ # If no search term, return AMC schemes
336
+ if not search_term:
337
+ results = [{'code': code, 'name': name} for code, name in amc_schemes.items()]
338
+ if limit > 0:
339
+ results = results[:limit]
340
+ return render_response(results, as_json)
341
+
342
+ # Filter by search term
343
+ search_term = search_term.lower().strip()
344
+ matches = []
345
+
346
+ for code, name in amc_schemes.items():
347
+ name_lower = name.lower()
348
+ if search_term in name_lower:
349
+ # Calculate relevance score
350
+ if name_lower == search_term:
351
+ score = 0
352
+ elif name_lower.startswith(search_term):
353
+ score = 1
354
+ elif f" {search_term} " in f" {name_lower} ":
355
+ score = 2
356
+ else:
357
+ score = 3
358
+
359
+ matches.append({
360
+ 'code': code,
361
+ 'name': name,
362
+ 'score': score
363
+ })
364
+
365
+ # Sort by relevance
366
+ matches.sort(key=lambda x: (x['score'], x['name']))
367
+
368
+ # Remove score and apply limit
369
+ results = [{'code': m['code'], 'name': m['name']} for m in matches]
370
+ if limit > 0:
371
+ results = results[:limit]
372
+
373
+ return render_response(results, as_json)
374
+
375
+ def search_schemes_by_type(self, scheme_type: str, search_term: str = "",
376
+ limit: int = 10, as_json=False) -> Union[List[Dict[str, str]], str]:
377
+ """
378
+ Search for schemes by type/category (Equity, Debt, Hybrid, etc.).
379
+
380
+ :param scheme_type: Type keywords like "equity", "debt", "hybrid", "elss", "index", "liquid"
381
+ :param search_term: Optional additional search term
382
+ :param limit: Maximum number of results (default: 10, use 0 for all)
383
+ :param as_json: Return data in JSON format (default: False)
384
+ :return: List of matching schemes with code and name
385
+
386
+ Example:
387
+ >>> mf = Mftool()
388
+ >>> # Find all ELSS schemes
389
+ >>> elss = mf.search_schemes_by_type("elss")
390
+ >>>
391
+ >>> # Find HDFC ELSS schemes
392
+ >>> hdfc_elss = mf.search_schemes_by_type("elss", "hdfc")
393
+ """
394
+ all_schemes = self.get_scheme_codes(as_json=False)
395
+ scheme_type = scheme_type.lower().strip()
396
+ search_term = search_term.lower().strip() if search_term else ""
397
+
398
+ matches = []
399
+ for code, name in all_schemes.items():
400
+ name_lower = name.lower()
401
+
402
+ # Check if scheme type is in the name
403
+ if scheme_type in name_lower:
404
+ # If search term provided, check if it's also in the name
405
+ if search_term and search_term not in name_lower:
406
+ continue
407
+
408
+ # Calculate relevance score
409
+ score = 0
410
+ if search_term:
411
+ # Both type and search term match
412
+ if scheme_type in name_lower and search_term in name_lower:
413
+ score = 1
414
+ else:
415
+ # Only type matches
416
+ if name_lower.startswith(scheme_type):
417
+ score = 2
418
+ else:
419
+ score = 3
420
+
421
+ matches.append({
422
+ 'code': code,
423
+ 'name': name,
424
+ 'score': score
425
+ })
426
+
427
+ # Sort by relevance
428
+ matches.sort(key=lambda x: (x['score'], x['name']))
429
+
430
+ # Remove score and apply limit
431
+ results = [{'code': m['code'], 'name': m['name']} for m in matches]
432
+ if limit > 0:
433
+ results = results[:limit]
434
+
435
+ return render_response(results, as_json)
436
+
140
437
  def get_scheme_details(self, code, as_json=False):
141
438
  """
142
439
  gets the scheme info for a given scheme code
@@ -147,17 +444,34 @@ class Mftool:
147
444
  """
148
445
  code = str(code)
149
446
  if self.is_valid_code(code):
150
- scheme_info = {}
151
- url = self._get_scheme_url + code
152
- response = self._session.get(url).json()
153
- scheme_data = response['meta']
154
- scheme_info['fund_house'] = scheme_data['fund_house']
155
- scheme_info['scheme_type'] = scheme_data['scheme_type']
156
- scheme_info['scheme_category'] = scheme_data['scheme_category']
157
- scheme_info['scheme_code'] = scheme_data['scheme_code']
158
- scheme_info['scheme_name'] = scheme_data['scheme_name']
159
- scheme_info['scheme_start_date'] = response['data'][int(len(response['data']) -1)]
160
- return render_response(scheme_info, as_json)
447
+ # Try to get from cache first
448
+ cache_key = f"details:{code}:{as_json}"
449
+ cached_result = self._cache.get(cache_key)
450
+ if cached_result is not None:
451
+ return cached_result
452
+
453
+ try:
454
+ scheme_info = {}
455
+ url = self._get_scheme_url + code
456
+ response = self._session.get(url)
457
+ response.raise_for_status() # Raise exception for bad status codes
458
+ response_data = response.json()
459
+
460
+ scheme_data = response_data['meta']
461
+ scheme_info['fund_house'] = scheme_data['fund_house']
462
+ scheme_info['scheme_type'] = scheme_data['scheme_type']
463
+ scheme_info['scheme_category'] = scheme_data['scheme_category']
464
+ scheme_info['scheme_code'] = scheme_data['scheme_code']
465
+ scheme_info['scheme_name'] = scheme_data['scheme_name']
466
+ scheme_info['scheme_start_date'] = response_data['data'][int(len(response_data['data']) -1)]
467
+
468
+ result = render_response(scheme_info, as_json)
469
+ # Cache the result
470
+ self._cache.set(cache_key, result)
471
+ return result
472
+ except Exception as e:
473
+ # Return None on error, don't cache errors
474
+ return None
161
475
  else:
162
476
  return None
163
477
 
@@ -172,24 +486,41 @@ class Mftool:
172
486
  """
173
487
  code = str(code)
174
488
  if self.is_valid_code(code):
175
- scheme_info = {}
176
- url = self._get_scheme_url + code
177
- response = self._session.get(url).json()
178
- scheme_data = response['meta']
179
- scheme_info['fund_house'] = scheme_data['fund_house']
180
- scheme_info['scheme_type'] = scheme_data['scheme_type']
181
- scheme_info['scheme_category'] = scheme_data['scheme_category']
182
- scheme_info['scheme_code'] = scheme_data['scheme_code']
183
- scheme_info['scheme_name'] = scheme_data['scheme_name']
184
- scheme_info['scheme_start_date'] = response['data'][int(len(response['data']) - 1)]
185
- result = get_52_week_high_low(response['data'])
186
- scheme_info['52_week_high'] = result['52_week_high']
187
- scheme_info['52_week_low'] = result['52_week_low']
188
- if response['data']:
189
- scheme_info['data'] = response['data']
190
- else:
191
- scheme_info['data'] = "Underlying data not available"
192
- return render_response(scheme_info, as_json,as_Dataframe)
489
+ # Try to get from cache first
490
+ cache_key = f"historical:{code}:{as_json}:{as_Dataframe}"
491
+ cached_result = self._cache.get(cache_key)
492
+ if cached_result is not None:
493
+ return cached_result
494
+
495
+ try:
496
+ scheme_info = {}
497
+ url = self._get_scheme_url + code
498
+ response = self._session.get(url)
499
+ response.raise_for_status() # Raise exception for bad status codes
500
+ response_data = response.json()
501
+
502
+ scheme_data = response_data['meta']
503
+ scheme_info['fund_house'] = scheme_data['fund_house']
504
+ scheme_info['scheme_type'] = scheme_data['scheme_type']
505
+ scheme_info['scheme_category'] = scheme_data['scheme_category']
506
+ scheme_info['scheme_code'] = scheme_data['scheme_code']
507
+ scheme_info['scheme_name'] = scheme_data['scheme_name']
508
+ scheme_info['scheme_start_date'] = response_data['data'][int(len(response_data['data']) - 1)]
509
+ result = get_52_week_high_low(response_data['data'])
510
+ scheme_info['52_week_high'] = result['52_week_high']
511
+ scheme_info['52_week_low'] = result['52_week_low']
512
+ if response_data['data']:
513
+ scheme_info['data'] = response_data['data']
514
+ else:
515
+ scheme_info['data'] = "Underlying data not available"
516
+
517
+ final_result = render_response(scheme_info, as_json, as_Dataframe)
518
+ # Cache the result
519
+ self._cache.set(cache_key, final_result)
520
+ return final_result
521
+ except Exception as e:
522
+ # Return None on error, don't cache errors
523
+ return None
193
524
  else:
194
525
  return None
195
526
 
@@ -503,4 +834,100 @@ class Mftool:
503
834
  plt.title("Compare mutual funds")
504
835
  plt.xlabel("Date")
505
836
  plt.ylabel("NAV")
506
- plt.show()
837
+ plt.show()
838
+
839
+ def clear_cache(self):
840
+ """
841
+ Clear all cached data
842
+ :return: None
843
+ """
844
+ self._cache.clear()
845
+ self._scheme_codes_cache.clear()
846
+
847
+ def get_cache_stats(self):
848
+ """
849
+ Get cache statistics
850
+ :return: dict with cache stats
851
+ """
852
+ return {
853
+ 'nav_cache': self._cache.get_stats(),
854
+ 'scheme_codes_cache': self._scheme_codes_cache.get_stats()
855
+ }
856
+
857
+ def disable_cache(self):
858
+ """
859
+ Disable caching globally
860
+ :return: None
861
+ """
862
+ self._cache.disable()
863
+ self._scheme_codes_cache.disable()
864
+
865
+ def enable_cache(self):
866
+ """
867
+ Enable caching globally
868
+ :return: None
869
+ """
870
+ self._cache.enable()
871
+ self._scheme_codes_cache.enable()
872
+
873
+ def calculate_portfolio_value(self, holdings: List[Dict[str, Union[str, float]]],
874
+ as_json=False) -> Dict[str, Union[float, dict]]:
875
+ """
876
+ Calculate total portfolio value for multiple holdings concurrently.
877
+
878
+ :param holdings: List of dicts with 'scheme_code' and 'units' keys
879
+ :param as_json: Return data in JSON format (default: False)
880
+ :return: Dictionary with portfolio summary
881
+
882
+ Example:
883
+ >>> holdings = [
884
+ ... {'scheme_code': '119597', 'units': 100},
885
+ ... {'scheme_code': '119062', 'units': 50}
886
+ ... ]
887
+ >>> portfolio = mf.calculate_portfolio_value(holdings)
888
+ >>> print(f"Total value: {portfolio['total_value']}")
889
+ """
890
+ scheme_codes = [str(h['scheme_code']) for h in holdings]
891
+
892
+ # Fetch all quotes concurrently
893
+ quotes = self.get_bulk_quotes(scheme_codes, as_json=False)
894
+
895
+ portfolio_data = []
896
+ total_value = 0.0
897
+
898
+ for holding in holdings:
899
+ code = str(holding['scheme_code'])
900
+ units = float(holding['units'])
901
+
902
+ quote = quotes.get(code)
903
+ if quote and 'nav' in quote:
904
+ nav = float(quote['nav'])
905
+ value = units * nav
906
+ total_value += value
907
+
908
+ portfolio_data.append({
909
+ 'scheme_code': code,
910
+ 'scheme_name': quote.get('scheme_name', 'N/A'),
911
+ 'units': units,
912
+ 'nav': nav,
913
+ 'current_value': round(value, 2),
914
+ 'last_updated': quote.get('last_updated', 'N/A')
915
+ })
916
+ else:
917
+ portfolio_data.append({
918
+ 'scheme_code': code,
919
+ 'scheme_name': 'Error fetching data',
920
+ 'units': units,
921
+ 'nav': 0,
922
+ 'current_value': 0,
923
+ 'last_updated': 'N/A'
924
+ })
925
+
926
+ result = {
927
+ 'total_value': round(total_value, 2),
928
+ 'total_schemes': len(holdings),
929
+ 'holdings': portfolio_data,
930
+ 'currency': 'INR'
931
+ }
932
+
933
+ return render_response(result, as_json)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mftool
3
- Version: 3.2
3
+ Version: 3.3
4
4
  Summary: Library for getting real time Mutual funds info
5
5
  Home-page: https://github.com/NayakwadiS/mftool
6
6
  Author: SujitN
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  setup.py
3
3
  mftool/__init__.py
4
+ mftool/cache.py
4
5
  mftool/const.json
5
6
  mftool/mftool.py
6
7
  mftool/utils.py
@@ -7,7 +7,7 @@ with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f:
7
7
 
8
8
  setup(
9
9
  name="mftool",
10
- version="3.2",
10
+ version="3.3",
11
11
  author="SujitN",
12
12
  author_email="nayakwadi_sujit@rediffmail.com",
13
13
  description="Library for getting real time Mutual funds info",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes