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