mftool 3.1__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.
- {mftool-3.1 → mftool-3.3}/PKG-INFO +1 -1
- {mftool-3.1 → mftool-3.3}/mftool/__init__.py +1 -1
- mftool-3.3/mftool/cache.py +233 -0
- {mftool-3.1 → mftool-3.3}/mftool/mftool.py +460 -39
- {mftool-3.1 → mftool-3.3}/mftool/utils.py +9 -0
- {mftool-3.1 → mftool-3.3}/mftool.egg-info/PKG-INFO +1 -1
- {mftool-3.1 → mftool-3.3}/mftool.egg-info/SOURCES.txt +1 -0
- {mftool-3.1 → mftool-3.3}/setup.py +1 -1
- {mftool-3.1 → mftool-3.3}/README.md +0 -0
- {mftool-3.1 → mftool-3.3}/mftool/const.json +0 -0
- {mftool-3.1 → mftool-3.3}/mftool.egg-info/dependency_links.txt +0 -0
- {mftool-3.1 → mftool-3.3}/mftool.egg-info/requires.txt +0 -0
- {mftool-3.1 → mftool-3.3}/mftool.egg-info/top_level.txt +0 -0
- {mftool-3.1 → mftool-3.3}/setup.cfg +0 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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,34 +486,45 @@ class Mftool:
|
|
|
172
486
|
"""
|
|
173
487
|
code = str(code)
|
|
174
488
|
if self.is_valid_code(code):
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
scheme_info['
|
|
190
|
-
|
|
191
|
-
scheme_info['
|
|
192
|
-
|
|
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
|
|
|
196
|
-
|
|
197
|
-
friday = pd.to_datetime(get_52_week_friday())
|
|
198
|
-
df = pd.DataFrame.from_records(data)
|
|
199
|
-
df['date'] = pd.to_datetime(df['date'], dayfirst=True)
|
|
200
|
-
df_high = df[df['date'] >= friday].sort_values(by='nav', ascending=False).head(1)
|
|
201
|
-
df_low = df[df['date'] >= friday].sort_values(by='nav', ascending=True).head(1)
|
|
202
|
-
return {"52_week_high": df_high['nav'].values[0], "52_week_low": df_low['nav'].values[0]}
|
|
527
|
+
|
|
203
528
|
|
|
204
529
|
def calculate_balance_units_value(self, code, balance_units, as_json=False):
|
|
205
530
|
"""
|
|
@@ -510,3 +835,99 @@ class Mftool:
|
|
|
510
835
|
plt.xlabel("Date")
|
|
511
836
|
plt.ylabel("NAV")
|
|
512
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)
|
|
@@ -26,6 +26,15 @@ def get_52_week_friday():
|
|
|
26
26
|
return (date.today() - timedelta(weeks=52)).strftime("%d-%m-%Y")
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def get_52_week_high_low(data):
|
|
30
|
+
friday = pd.to_datetime(get_52_week_friday(), dayfirst=True)
|
|
31
|
+
df = pd.DataFrame.from_records(data)
|
|
32
|
+
df['date'] = pd.to_datetime(df['date'], dayfirst=True)
|
|
33
|
+
df_high = df[df['date'] >= friday].sort_values(by='nav', ascending=False).head(1)
|
|
34
|
+
df_low = df[df['date'] >= friday].sort_values(by='nav', ascending=True).head(1)
|
|
35
|
+
return {"52_week_high": df_high['nav'].values[0], "52_week_low": df_low['nav'].values[0]}
|
|
36
|
+
|
|
37
|
+
|
|
29
38
|
def render_response(data, as_json=False, as_Dataframe=False):
|
|
30
39
|
if as_json is True:
|
|
31
40
|
return json.dumps(data)
|
|
@@ -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.
|
|
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
|