tickertape-client 0.1.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.
- tickertape/__init__.py +69 -0
- tickertape/client.py +162 -0
- tickertape/constants.py +28 -0
- tickertape/errors.py +40 -0
- tickertape/lookup/__init__.py +13 -0
- tickertape/lookup/indexer.py +181 -0
- tickertape/lookup/models.py +45 -0
- tickertape/lookup/resolver.py +159 -0
- tickertape/lookup/table.py +349 -0
- tickertape/models.py +115 -0
- tickertape/parsers/__init__.py +47 -0
- tickertape/parsers/base.py +27 -0
- tickertape/parsers/etf.py +14 -0
- tickertape/parsers/mf.py +153 -0
- tickertape/parsers/screens.py +16 -0
- tickertape/parsers/sitemap.py +145 -0
- tickertape/parsers/stocks.py +14 -0
- tickertape/resources/__init__.py +11 -0
- tickertape/resources/base.py +15 -0
- tickertape/resources/mf.py +194 -0
- tickertape/resources/sitemap.py +119 -0
- tickertape/storage.py +94 -0
- tickertape_client-0.1.0.dist-info/METADATA +402 -0
- tickertape_client-0.1.0.dist-info/RECORD +26 -0
- tickertape_client-0.1.0.dist-info/WHEEL +4 -0
- tickertape_client-0.1.0.dist-info/licenses/LICENSE +201 -0
tickertape/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Unofficial TickerTape Client & SDK Package."""
|
|
2
|
+
|
|
3
|
+
from tickertape.client import TickerTapeClient
|
|
4
|
+
from tickertape.constants import (
|
|
5
|
+
BASE_URL,
|
|
6
|
+
DEFAULT_CACHE_DIR,
|
|
7
|
+
DEFAULT_HEADERS,
|
|
8
|
+
SITEMAP_URLS,
|
|
9
|
+
URLS,
|
|
10
|
+
)
|
|
11
|
+
from tickertape.errors import (
|
|
12
|
+
TickerTapeError,
|
|
13
|
+
TickerTapeHTTPError,
|
|
14
|
+
TickerTapeNotFoundError,
|
|
15
|
+
TickerTapeParseError,
|
|
16
|
+
)
|
|
17
|
+
from tickertape.lookup import (
|
|
18
|
+
ISINIndexer,
|
|
19
|
+
ISINLookupTable,
|
|
20
|
+
ISINMapping,
|
|
21
|
+
ISINResolver,
|
|
22
|
+
)
|
|
23
|
+
from tickertape.models import (
|
|
24
|
+
MFMeta,
|
|
25
|
+
MFScorecardItem,
|
|
26
|
+
MFSecurityInfo,
|
|
27
|
+
MutualFundDetail,
|
|
28
|
+
SitemapCacheData,
|
|
29
|
+
SitemapReference,
|
|
30
|
+
SitemapURL,
|
|
31
|
+
)
|
|
32
|
+
from tickertape.parsers import (
|
|
33
|
+
BaseParser,
|
|
34
|
+
MFParser,
|
|
35
|
+
SitemapParser,
|
|
36
|
+
get_parser,
|
|
37
|
+
register_parser,
|
|
38
|
+
)
|
|
39
|
+
from tickertape.storage import SitemapCacheManager
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"TickerTapeClient",
|
|
43
|
+
"TickerTapeError",
|
|
44
|
+
"TickerTapeHTTPError",
|
|
45
|
+
"TickerTapeNotFoundError",
|
|
46
|
+
"TickerTapeParseError",
|
|
47
|
+
"SitemapURL",
|
|
48
|
+
"SitemapReference",
|
|
49
|
+
"SitemapCacheData",
|
|
50
|
+
"MutualFundDetail",
|
|
51
|
+
"MFSecurityInfo",
|
|
52
|
+
"MFMeta",
|
|
53
|
+
"MFScorecardItem",
|
|
54
|
+
"BaseParser",
|
|
55
|
+
"SitemapParser",
|
|
56
|
+
"MFParser",
|
|
57
|
+
"register_parser",
|
|
58
|
+
"get_parser",
|
|
59
|
+
"SitemapCacheManager",
|
|
60
|
+
"ISINMapping",
|
|
61
|
+
"ISINLookupTable",
|
|
62
|
+
"ISINResolver",
|
|
63
|
+
"ISINIndexer",
|
|
64
|
+
"BASE_URL",
|
|
65
|
+
"DEFAULT_CACHE_DIR",
|
|
66
|
+
"DEFAULT_HEADERS",
|
|
67
|
+
"SITEMAP_URLS",
|
|
68
|
+
"URLS",
|
|
69
|
+
]
|
tickertape/client.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Unofficial TickerTape API and Scraper Client.
|
|
2
|
+
|
|
3
|
+
An unofficial, asynchronous, typed Python client for TickerTape.
|
|
4
|
+
Provides access to XML sitemaps (with persistent local caching) and mutual fund data
|
|
5
|
+
with an extensible architecture for future asset types (stocks, ETFs, screens).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from tickertape.constants import (
|
|
17
|
+
BASE_URL,
|
|
18
|
+
DEFAULT_CACHE_DIR,
|
|
19
|
+
DEFAULT_HEADERS,
|
|
20
|
+
SITEMAP_URLS,
|
|
21
|
+
)
|
|
22
|
+
from tickertape.errors import (
|
|
23
|
+
TickerTapeHTTPError,
|
|
24
|
+
TickerTapeNotFoundError,
|
|
25
|
+
)
|
|
26
|
+
from tickertape.resources.mf import MutualFundsResource
|
|
27
|
+
from tickertape.resources.sitemap import SitemapResource
|
|
28
|
+
from tickertape.storage import SitemapCacheManager
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TickerTapeClient:
|
|
34
|
+
"""Asynchronous client for interacting with TickerTape.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
Example:
|
|
38
|
+
async with TickerTapeClient() as client:
|
|
39
|
+
# Reads from persistent cache (.cache/tickertape) or fetches live XML if not cached:
|
|
40
|
+
mf_sitemap = await client.sitemap.get("mf")
|
|
41
|
+
|
|
42
|
+
# Force live refresh and update disk cache:
|
|
43
|
+
refreshed = await client.sitemap.refresh("mf")
|
|
44
|
+
|
|
45
|
+
# Fetch fund details and ISIN:
|
|
46
|
+
fund = await client.mf.get("quant-infrastructure-fund-M_QUNG")
|
|
47
|
+
print(fund.isin, fund.nav)
|
|
48
|
+
```
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
base_url: str = BASE_URL,
|
|
54
|
+
timeout: float = 30.0,
|
|
55
|
+
cache_dir: Path | str = DEFAULT_CACHE_DIR,
|
|
56
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
57
|
+
headers: Optional[dict[str, str]] = None,
|
|
58
|
+
sitemap_urls: Optional[dict[str, str]] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.base_url = base_url.rstrip("/")
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self.cache_dir = Path(cache_dir)
|
|
63
|
+
self.sitemap_urls = dict(sitemap_urls or SITEMAP_URLS)
|
|
64
|
+
|
|
65
|
+
req_headers = dict(DEFAULT_HEADERS)
|
|
66
|
+
if headers:
|
|
67
|
+
req_headers.update(headers)
|
|
68
|
+
self._default_headers = req_headers
|
|
69
|
+
|
|
70
|
+
self._owns_http_client = http_client is None
|
|
71
|
+
self._http_client = http_client or httpx.AsyncClient(
|
|
72
|
+
headers=self._default_headers,
|
|
73
|
+
timeout=self.timeout,
|
|
74
|
+
follow_redirects=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Storage & persistent cache manager
|
|
78
|
+
self.cache_manager = SitemapCacheManager(cache_dir=self.cache_dir)
|
|
79
|
+
|
|
80
|
+
# Resource sub-clients
|
|
81
|
+
self.sitemap = SitemapResource(self)
|
|
82
|
+
self.mf = MutualFundsResource(self)
|
|
83
|
+
|
|
84
|
+
async def __aenter__(self) -> TickerTapeClient:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
88
|
+
await self.close()
|
|
89
|
+
|
|
90
|
+
async def close(self) -> None:
|
|
91
|
+
"""Close the underlying HTTP transport."""
|
|
92
|
+
if self._owns_http_client and not self._http_client.is_closed:
|
|
93
|
+
await self._http_client.aclose()
|
|
94
|
+
|
|
95
|
+
def _build_url(self, url_or_path: str) -> str:
|
|
96
|
+
"""Build full URL if given relative path."""
|
|
97
|
+
url = url_or_path.strip()
|
|
98
|
+
if url.startswith(("http://", "https://")):
|
|
99
|
+
return url
|
|
100
|
+
if not url.startswith("/"):
|
|
101
|
+
url = f"/{url}"
|
|
102
|
+
return f"{self.base_url}{url}"
|
|
103
|
+
|
|
104
|
+
async def request(
|
|
105
|
+
self,
|
|
106
|
+
method: str,
|
|
107
|
+
url_or_path: str,
|
|
108
|
+
*,
|
|
109
|
+
params: Optional[dict[str, Any]] = None,
|
|
110
|
+
headers: Optional[dict[str, str]] = None,
|
|
111
|
+
**kwargs: Any,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Execute an HTTP request and return text response.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
method: HTTP method ('GET', 'POST', etc.)
|
|
117
|
+
url_or_path: Full URL or path relative to base_url.
|
|
118
|
+
params: Optional query parameters.
|
|
119
|
+
headers: Optional extra headers.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Response body string.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
TickerTapeNotFoundError: On HTTP 404.
|
|
126
|
+
TickerTapeHTTPError: On other HTTP 4xx/5xx status codes.
|
|
127
|
+
"""
|
|
128
|
+
full_url = self._build_url(url_or_path)
|
|
129
|
+
|
|
130
|
+
req_headers = dict(self._default_headers)
|
|
131
|
+
if headers:
|
|
132
|
+
req_headers.update(headers)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
response = await self._http_client.request(
|
|
136
|
+
method=method,
|
|
137
|
+
url=full_url,
|
|
138
|
+
params=params,
|
|
139
|
+
headers=req_headers,
|
|
140
|
+
**kwargs,
|
|
141
|
+
)
|
|
142
|
+
except httpx.RequestError as exc:
|
|
143
|
+
raise TickerTapeHTTPError(
|
|
144
|
+
f"Request to {full_url} failed: {exc}",
|
|
145
|
+
status_code=0,
|
|
146
|
+
) from exc
|
|
147
|
+
|
|
148
|
+
if response.status_code == 404:
|
|
149
|
+
raise TickerTapeNotFoundError(
|
|
150
|
+
f"Resource not found at {full_url}",
|
|
151
|
+
status_code=404,
|
|
152
|
+
response_data=response.text[:200],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if response.is_error:
|
|
156
|
+
raise TickerTapeHTTPError(
|
|
157
|
+
f"HTTP {response.status_code} error from {full_url}",
|
|
158
|
+
status_code=response.status_code,
|
|
159
|
+
response_data=response.text[:500],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return response.text
|
tickertape/constants.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Constants required for proper execution of TickerTapeClient."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
BASE_URL = "https://www.tickertape.in"
|
|
6
|
+
|
|
7
|
+
SITEMAP_URLS = {
|
|
8
|
+
"stocks": "https://www.tickertape.in/sitemaps/stocks/sitemap.xml",
|
|
9
|
+
"etf": "https://www.tickertape.in/sitemaps/etfs/sitemap.xml",
|
|
10
|
+
"mf": "https://www.tickertape.in/sitemaps/mutualfunds/sitemap.xml",
|
|
11
|
+
"us-stocks": "https://www.tickertape.in/sitemaps/us-stocks/sitemap.xml",
|
|
12
|
+
"us-etf": "https://www.tickertape.in/sitemaps/us-etfs/sitemap.xml",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Kept for backward compatibility
|
|
16
|
+
URLS = SITEMAP_URLS
|
|
17
|
+
|
|
18
|
+
DEFAULT_HEADERS = {
|
|
19
|
+
"User-Agent": (
|
|
20
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
21
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
22
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
23
|
+
),
|
|
24
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,application/json,*/*;q=0.8",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
DEFAULT_CACHE_DIR = Path(".cache/tickertape")
|
|
28
|
+
SITEMAP_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9"
|
tickertape/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Exception hierarchy for the TickerTape client.
|
|
2
|
+
|
|
3
|
+
Keep exceptions focused, practical, and small.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TickerTapeError(Exception):
|
|
12
|
+
"""Base exception for all TickerTape client errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TickerTapeHTTPError(TickerTapeError):
|
|
16
|
+
"""Raised when an HTTP request to TickerTape fails."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
status_code: int,
|
|
22
|
+
response_data: Any | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.response_data = response_data
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
base = super().__str__()
|
|
30
|
+
if self.response_data:
|
|
31
|
+
return f"[{self.status_code}] {base} - {self.response_data}"
|
|
32
|
+
return f"[{self.status_code}] {base}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TickerTapeNotFoundError(TickerTapeHTTPError):
|
|
36
|
+
"""Raised when the requested TickerTape resource is not found (HTTP 404)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TickerTapeParseError(TickerTapeError):
|
|
40
|
+
"""Raised when parsing response data (XML sitemap, HTML, __NEXT_DATA__) fails."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ISIN to TickerTape lookup and mapping package."""
|
|
2
|
+
|
|
3
|
+
from tickertape.lookup.indexer import ISINIndexer
|
|
4
|
+
from tickertape.lookup.models import ISINMapping
|
|
5
|
+
from tickertape.lookup.resolver import ISINResolver
|
|
6
|
+
from tickertape.lookup.table import ISINLookupTable
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ISINMapping",
|
|
10
|
+
"ISINLookupTable",
|
|
11
|
+
"ISINResolver",
|
|
12
|
+
"ISINIndexer",
|
|
13
|
+
]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Bulk indexer for crawling sitemap URLs and populating the SQLite ISIN lookup table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
8
|
+
|
|
9
|
+
from tickertape.lookup.models import ISINMapping
|
|
10
|
+
from tickertape.lookup.table import ISINLookupTable
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tickertape.client import TickerTapeClient
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
ProgressCallback = Callable[
|
|
18
|
+
[int, int, int, int], None
|
|
19
|
+
] # (completed, total, successful, failed)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ISINIndexer:
|
|
23
|
+
"""Crawls mutual fund pages from sitemap and populates SQLite ISIN lookup table."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, client: TickerTapeClient, table: Optional[ISINLookupTable] = None
|
|
27
|
+
) -> None:
|
|
28
|
+
self.client = client
|
|
29
|
+
self.table = table or ISINLookupTable(cache_dir=client.cache_dir)
|
|
30
|
+
|
|
31
|
+
async def build_index(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
force_refresh: bool = False,
|
|
35
|
+
concurrency: int = 5,
|
|
36
|
+
delay: float = 0.5,
|
|
37
|
+
limit: Optional[int] = None,
|
|
38
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
39
|
+
) -> int:
|
|
40
|
+
"""Build or update the SQLite ISIN lookup table from the mutual funds sitemap.
|
|
41
|
+
|
|
42
|
+
Force Refresh Logic:
|
|
43
|
+
When force_refresh=True:
|
|
44
|
+
1. Fetches live sitemap XML from TickerTape.
|
|
45
|
+
2. Parses sitemap and updates the local sitemap cache (.cache/tickertape/sitemaps/mf.json).
|
|
46
|
+
3. Crawls fund pages and updates the SQLite database (.cache/tickertape/isin_lookup.db).
|
|
47
|
+
|
|
48
|
+
When force_refresh=False:
|
|
49
|
+
1. Loads sitemap from local disk cache.
|
|
50
|
+
2. Skips record_ids already present in SQLite database (resumable).
|
|
51
|
+
3. Crawls only missing funds and inserts into SQLite database.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
force_refresh: If True, forces live sitemap fetch, updates sitemap cache, and re-indexes.
|
|
55
|
+
concurrency: Number of concurrent HTTP workers.
|
|
56
|
+
delay: Delay in seconds between requests per worker to respect rate limits.
|
|
57
|
+
limit: Optional maximum number of funds to process (useful for testing or batch runs).
|
|
58
|
+
progress_callback: Optional callback(completed, total, successful, failed).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Number of mappings successfully indexed/updated in SQLite database.
|
|
62
|
+
"""
|
|
63
|
+
# Step 1: Get sitemap items (force_refresh updates live XML and disk cache)
|
|
64
|
+
if force_refresh:
|
|
65
|
+
logger.info(
|
|
66
|
+
"Force refresh triggered: fetching live sitemap and updating sitemap cache..."
|
|
67
|
+
)
|
|
68
|
+
sitemap_items = await self.client.sitemap.refresh("mf")
|
|
69
|
+
else:
|
|
70
|
+
sitemap_items = await self.client.sitemap.get("mf", force_refresh=False)
|
|
71
|
+
|
|
72
|
+
if not sitemap_items:
|
|
73
|
+
logger.warning("No sitemap items found to index")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
# Step 2: Determine items to process
|
|
77
|
+
if force_refresh:
|
|
78
|
+
items_to_process = sitemap_items
|
|
79
|
+
else:
|
|
80
|
+
existing_ids = self.table.get_all_record_ids()
|
|
81
|
+
items_to_process = [
|
|
82
|
+
item for item in sitemap_items if item.record_id not in existing_ids
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
if limit is not None:
|
|
86
|
+
items_to_process = items_to_process[:limit]
|
|
87
|
+
|
|
88
|
+
total = len(items_to_process)
|
|
89
|
+
if total == 0:
|
|
90
|
+
logger.info("All sitemap items are already indexed in SQLite lookup table.")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
logger.info(
|
|
94
|
+
"Starting ISIN indexing for %d funds (concurrency=%d)...",
|
|
95
|
+
total,
|
|
96
|
+
concurrency,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
100
|
+
completed = 0
|
|
101
|
+
successful = 0
|
|
102
|
+
failed = 0
|
|
103
|
+
batch: list[ISINMapping] = []
|
|
104
|
+
lock = asyncio.Lock()
|
|
105
|
+
|
|
106
|
+
async def worker(item):
|
|
107
|
+
nonlocal completed, successful, failed
|
|
108
|
+
async with semaphore:
|
|
109
|
+
try:
|
|
110
|
+
fund = await self.client.mf.get(item.url)
|
|
111
|
+
if fund.isin:
|
|
112
|
+
raw_slug = fund.slug or item.url
|
|
113
|
+
clean_slug = raw_slug.split("/mutualfunds/")[-1].lstrip("/")
|
|
114
|
+
|
|
115
|
+
sec_info = fund.security_info
|
|
116
|
+
meta = fund.meta
|
|
117
|
+
|
|
118
|
+
mapping = ISINMapping(
|
|
119
|
+
isin=fund.isin.strip().upper(),
|
|
120
|
+
record_id=fund.mf_id or item.record_id,
|
|
121
|
+
slug=clean_slug,
|
|
122
|
+
name=fund.name,
|
|
123
|
+
amc=(meta.amc if meta else None)
|
|
124
|
+
or (sec_info.amc if sec_info else None),
|
|
125
|
+
amc_code=sec_info.amc_code if sec_info else None,
|
|
126
|
+
sector=(sec_info.sector if sec_info else None)
|
|
127
|
+
or (meta.sector if meta else None),
|
|
128
|
+
subsector=(sec_info.subsector if sec_info else None)
|
|
129
|
+
or (meta.subsector if meta else None),
|
|
130
|
+
fund_type=meta.fund_type if meta else None,
|
|
131
|
+
fund_class=meta.type if meta else None,
|
|
132
|
+
plan=meta.plan if meta else None,
|
|
133
|
+
option=(sec_info.option if sec_info else None)
|
|
134
|
+
or (meta.option if meta else None),
|
|
135
|
+
risk_level=meta.risk_classification if meta else None,
|
|
136
|
+
benchmark=meta.benchmark_index if meta else None,
|
|
137
|
+
url=item.url,
|
|
138
|
+
nav=fund.nav,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async with lock:
|
|
142
|
+
batch.append(mapping)
|
|
143
|
+
successful += 1
|
|
144
|
+
if len(batch) >= 20:
|
|
145
|
+
self.table.upsert_batch(batch)
|
|
146
|
+
batch.clear()
|
|
147
|
+
else:
|
|
148
|
+
async with lock:
|
|
149
|
+
failed += 1
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.debug("Failed to index %s: %s", item.url, exc)
|
|
152
|
+
async with lock:
|
|
153
|
+
failed += 1
|
|
154
|
+
finally:
|
|
155
|
+
async with lock:
|
|
156
|
+
completed += 1
|
|
157
|
+
if progress_callback:
|
|
158
|
+
try:
|
|
159
|
+
progress_callback(completed, total, successful, failed)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
if delay > 0:
|
|
164
|
+
await asyncio.sleep(delay)
|
|
165
|
+
|
|
166
|
+
# Run workers concurrently
|
|
167
|
+
tasks = [asyncio.create_task(worker(item)) for item in items_to_process]
|
|
168
|
+
await asyncio.gather(*tasks)
|
|
169
|
+
|
|
170
|
+
# Flush remaining batch
|
|
171
|
+
if batch:
|
|
172
|
+
self.table.upsert_batch(batch)
|
|
173
|
+
batch.clear()
|
|
174
|
+
|
|
175
|
+
logger.info(
|
|
176
|
+
"Indexing completed: %d/%d successfully saved to SQLite (%d failed)",
|
|
177
|
+
successful,
|
|
178
|
+
total,
|
|
179
|
+
failed,
|
|
180
|
+
)
|
|
181
|
+
return successful
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Data models for ISIN to TickerTape lookup table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ISINMapping(BaseModel):
|
|
11
|
+
"""Mapping entry linking a mutual fund ISIN to TickerTape record and categorization metadata.
|
|
12
|
+
|
|
13
|
+
Contains stable classification and grouping attributes used by AI agents
|
|
14
|
+
to cluster and identify similar/peer mutual funds.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra="ignore")
|
|
18
|
+
|
|
19
|
+
isin: str
|
|
20
|
+
record_id: str
|
|
21
|
+
slug: str
|
|
22
|
+
name: str
|
|
23
|
+
amc: Optional[str] = None
|
|
24
|
+
amc_code: Optional[str] = None
|
|
25
|
+
sector: Optional[str] = (
|
|
26
|
+
None # Primary asset class (e.g. 'Equity', 'Debt', 'Hybrid')
|
|
27
|
+
)
|
|
28
|
+
subsector: Optional[str] = (
|
|
29
|
+
None # Granular category (e.g. 'Sectoral Fund - Infrastructure', 'Large Cap Fund')
|
|
30
|
+
)
|
|
31
|
+
fund_type: Optional[str] = None # Fund type (e.g. 'Equity')
|
|
32
|
+
fund_class: Optional[str] = None # Structure type (e.g. 'Open', 'Close')
|
|
33
|
+
plan: Optional[str] = None # 'Direct' vs 'Regular'
|
|
34
|
+
option: Optional[str] = None # 'Growth' vs 'IDCW'
|
|
35
|
+
risk_level: Optional[str] = (
|
|
36
|
+
None # Risk classification (e.g. 'Very High', 'Moderate', 'Low')
|
|
37
|
+
)
|
|
38
|
+
benchmark: Optional[str] = (
|
|
39
|
+
None # Benchmark Index (e.g. 'Nifty Infrastructure - TRI')
|
|
40
|
+
)
|
|
41
|
+
url: str
|
|
42
|
+
nav: Optional[float] = None
|
|
43
|
+
updated_at: str = Field(
|
|
44
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
45
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Targeted on-demand resolver for matching ISINs to TickerTape records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Optional
|
|
8
|
+
|
|
9
|
+
from tickertape.lookup.models import ISINMapping
|
|
10
|
+
from tickertape.lookup.table import ISINLookupTable
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tickertape.client import TickerTapeClient
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
STOP_WORDS = {
|
|
18
|
+
"fund",
|
|
19
|
+
"mutual",
|
|
20
|
+
"plan",
|
|
21
|
+
"growth",
|
|
22
|
+
"direct",
|
|
23
|
+
"regular",
|
|
24
|
+
"idcw",
|
|
25
|
+
"dividend",
|
|
26
|
+
"reinvestment",
|
|
27
|
+
"payout",
|
|
28
|
+
"scheme",
|
|
29
|
+
"equity",
|
|
30
|
+
"debt",
|
|
31
|
+
"the",
|
|
32
|
+
"and",
|
|
33
|
+
"of",
|
|
34
|
+
"in",
|
|
35
|
+
"to",
|
|
36
|
+
"for",
|
|
37
|
+
"a",
|
|
38
|
+
"an",
|
|
39
|
+
"is",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ISINResolver:
|
|
44
|
+
"""Resolves specific ISINs on demand using local sitemap slug matching and verification."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, client: TickerTapeClient, table: Optional[ISINLookupTable] = None
|
|
48
|
+
) -> None:
|
|
49
|
+
self.client = client
|
|
50
|
+
self.table = table or ISINLookupTable(cache_dir=client.cache_dir)
|
|
51
|
+
|
|
52
|
+
async def resolve(
|
|
53
|
+
self,
|
|
54
|
+
isin: str,
|
|
55
|
+
hint_name: Optional[str] = None,
|
|
56
|
+
max_candidates: int = 3,
|
|
57
|
+
) -> Optional[ISINMapping]:
|
|
58
|
+
"""Resolve an ISIN to a TickerTape mapping.
|
|
59
|
+
|
|
60
|
+
Checks the persistent SQLite table first. If not found, uses hint_name
|
|
61
|
+
to search candidate slugs in the cached sitemap, verifies the page ISIN,
|
|
62
|
+
and saves it to the SQLite lookup table upon match.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
isin: Mutual fund ISIN string (e.g. 'INF966L01721').
|
|
66
|
+
hint_name: Optional scheme name to narrow candidates.
|
|
67
|
+
max_candidates: Maximum candidate pages to inspect.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
ISINMapping if successfully resolved, otherwise None.
|
|
71
|
+
"""
|
|
72
|
+
clean_isin = isin.strip().upper()
|
|
73
|
+
|
|
74
|
+
# 1. Check persistent SQLite table
|
|
75
|
+
existing = self.table.get(clean_isin)
|
|
76
|
+
if existing:
|
|
77
|
+
return existing
|
|
78
|
+
|
|
79
|
+
# 2. If no hint_name, we cannot narrow down 5,900+ sitemap URLs safely
|
|
80
|
+
if not hint_name:
|
|
81
|
+
logger.debug(
|
|
82
|
+
"Cannot resolve ISIN %s without a hint name or pre-built index",
|
|
83
|
+
clean_isin,
|
|
84
|
+
)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
# 3. Load cached sitemap
|
|
88
|
+
sitemap_items = await self.client.sitemap.get("mf")
|
|
89
|
+
if not sitemap_items:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
# 4. Extract search tokens from hint_name
|
|
93
|
+
tokens = [
|
|
94
|
+
t.lower()
|
|
95
|
+
for t in re.findall(r"[a-zA-Z0-9]+", hint_name)
|
|
96
|
+
if t.lower() not in STOP_WORDS and len(t) > 2
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
if not tokens:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
# 5. Score sitemap slugs based on token overlap
|
|
103
|
+
scored_candidates = []
|
|
104
|
+
for item in sitemap_items:
|
|
105
|
+
slug_lower = item.url.lower()
|
|
106
|
+
matched_count = sum(1 for token in tokens if token in slug_lower)
|
|
107
|
+
if matched_count > 0:
|
|
108
|
+
scored_candidates.append((matched_count, item))
|
|
109
|
+
|
|
110
|
+
# Sort highest match count first
|
|
111
|
+
scored_candidates.sort(key=lambda x: x[0], reverse=True)
|
|
112
|
+
top_candidates = [item for _, item in scored_candidates[:max_candidates]]
|
|
113
|
+
|
|
114
|
+
# 6. Fetch candidate pages and verify ISIN
|
|
115
|
+
for candidate in top_candidates:
|
|
116
|
+
try:
|
|
117
|
+
fund = await self.client.mf.get(candidate.url)
|
|
118
|
+
if fund.isin and fund.isin.strip().upper() == clean_isin:
|
|
119
|
+
raw_slug = fund.slug or candidate.url
|
|
120
|
+
clean_slug = raw_slug.split("/mutualfunds/")[-1].lstrip("/")
|
|
121
|
+
|
|
122
|
+
sec_info = fund.security_info
|
|
123
|
+
meta = fund.meta
|
|
124
|
+
|
|
125
|
+
mapping = ISINMapping(
|
|
126
|
+
isin=clean_isin,
|
|
127
|
+
record_id=fund.mf_id or candidate.record_id,
|
|
128
|
+
slug=clean_slug,
|
|
129
|
+
name=fund.name,
|
|
130
|
+
amc=(meta.amc if meta else None)
|
|
131
|
+
or (sec_info.amc if sec_info else None),
|
|
132
|
+
amc_code=sec_info.amc_code if sec_info else None,
|
|
133
|
+
sector=(sec_info.sector if sec_info else None)
|
|
134
|
+
or (meta.sector if meta else None),
|
|
135
|
+
subsector=(sec_info.subsector if sec_info else None)
|
|
136
|
+
or (meta.subsector if meta else None),
|
|
137
|
+
fund_type=meta.fund_type if meta else None,
|
|
138
|
+
fund_class=meta.type if meta else None,
|
|
139
|
+
plan=meta.plan if meta else None,
|
|
140
|
+
option=(sec_info.option if sec_info else None)
|
|
141
|
+
or (meta.option if meta else None),
|
|
142
|
+
risk_level=meta.risk_classification if meta else None,
|
|
143
|
+
benchmark=meta.benchmark_index if meta else None,
|
|
144
|
+
url=candidate.url,
|
|
145
|
+
nav=fund.nav,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
self.table.upsert(mapping)
|
|
149
|
+
logger.info(
|
|
150
|
+
"Successfully resolved %s -> %s (%s)",
|
|
151
|
+
clean_isin,
|
|
152
|
+
mapping.record_id,
|
|
153
|
+
mapping.name,
|
|
154
|
+
)
|
|
155
|
+
return mapping
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
logger.debug("Candidate check failed for %s: %s", candidate.url, exc)
|
|
158
|
+
|
|
159
|
+
return None
|