From 5cf05e7148008f957c926375c6dc92917641d895 Mon Sep 17 00:00:00 2001 From: Sandeep Somasekharan Date: Sun, 5 Jul 2026 18:35:45 +1000 Subject: [PATCH 1/3] feat(cdsl): parse NPS holdings (holdings-only) Adds NPSScheme/NPSAccount to NSDLCASData.nps; resolves the NPS holding statement by order + units*nav. CDSL only; transactions out of scope. --- README.md | 11 +++ casparser/parsers/cdsl.py | 136 +++++++++++++++++++++++++++++++++ casparser/types.py | 40 ++++++++++ schema/NSDLCASData.schema.json | 126 ++++++++++++++++++++++++++++++ tests/test_demat_units.py | 101 ++++++++++++++++++++++++ 5 files changed, 414 insertions(+) diff --git a/README.md b/README.md index bc29fa7..ae1174f 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,17 @@ Demat statements return holdings (no transactions), grouped per demat account: }[], }[], parse_warnings: string[], // non-fatal demat data-quality warnings + nps: { // National Pension System, if present (CDSL) + pran: string | null, // PRAN (as printed; often masked) + nps_sp: string | null, // NPS service provider / CRA, e.g. "PROT" + value: decimal, // reported NPS portfolio value + schemes: { + scheme: string, fund_manager: string | null, + tier: string | null, // "I" | "II" + asset_class: string | null, // "E" | "C" | "G" | "A" + units: decimal, nav: decimal, value: decimal, + }[], + } | null, } ``` diff --git a/casparser/parsers/cdsl.py b/casparser/parsers/cdsl.py index 7fc0828..3b23f5d 100644 --- a/casparser/parsers/cdsl.py +++ b/casparser/parsers/cdsl.py @@ -42,6 +42,8 @@ DematOwner, Equity, MutualFund, + NPSAccount, + NPSScheme, NSDLCASData, StatementPeriod, ) @@ -97,6 +99,23 @@ re.I | re.S, ) +# --- NPS section patterns --- +# An NPS holding row's scheme name always begins with this marker. +NPS_SCHEME_MARKER = "nps trust" +# `NPS-SP : PROT PRAN ID : ` — service provider (CRA) + PRAN. +NPS_SP_RE = re.compile(r"NPS[-\s]*SP\s*:\s*([A-Za-z0-9]+)", re.I) +NPS_PRAN_RE = re.compile(r"PRAN\s*ID\s*:\s*([A-Z0-9]+)", re.I) +# `Portfolio Value ` 56,05,171.60 as on 31-03-2026` — reported NPS total. +NPS_VALUE_RE = re.compile(r"Portfolio\s+Value[^\d]*([\d,]+\.\d+)", re.I) +# Tier ("TIER I"/"TIER II") and asset class ("SCHEME E/C/G/A") in the name. +NPS_TIER_RE = re.compile(r"\bTIER\s*[-\s]*(I{1,2}|1|2)\b", re.I) +NPS_ASSET_RE = re.compile(r"\bSCHEME\s+([A-Za-z])\b") +# Coarse split between the two text columns (scheme name | fund manager). +# The holding table has exactly two text columns and two numeric columns; +# numerics are ordered (units, then nav), so only the text split needs an x +# hint — deliberately loose, well inside the wide inter-column gap. +_NPS_FUND_MGR_MIN_X = 1500.0 + # --- decimal helpers --- @@ -220,6 +239,122 @@ def _split_bo_id(bo_id: str) -> Tuple[str, str, str]: # --- parser entry point --- +def _norm_tier(t: str) -> str: + return {"1": "I", "2": "II"}.get(t.upper(), t.upper()) + + +def _build_nps_scheme(cells: list) -> Optional[NPSScheme]: + """Build one NPSScheme from an accumulated holding-row cell buffer. + + A scheme row spans several extractor lines: two text columns (scheme + name, fund manager) and two numeric cells (units, then nav). Text is + grouped into the two columns by a coarse x split; the numeric cells are + assigned by order (left = units, right = nav). value = units * nav. + Rows without both numerics (e.g. redacted) are skipped. + """ + text_cells = [c for c in cells if c.text.strip() and not _looks_numeric(c.text)] + num_cells = sorted((c for c in cells if _looks_numeric(c.text)), key=lambda c: c.x_left) + name_cells = sorted( + (c for c in text_cells if c.x_left < _NPS_FUND_MGR_MIN_X), key=lambda c: -c.y_top + ) + fm_cells = sorted( + (c for c in text_cells if c.x_left >= _NPS_FUND_MGR_MIN_X), key=lambda c: -c.y_top + ) + scheme = " ".join(c.text.replace("\n", " ").strip() for c in name_cells).strip() + scheme = re.sub(r"\s+", " ", scheme) + if NPS_SCHEME_MARKER not in scheme.lower(): + return None + if len(num_cells) < 2: + return None # units / nav not present (redacted) — cannot form a holding + fund_manager = " ".join(c.text.replace("\n", " ").strip() for c in fm_cells).strip() + fund_manager = re.sub(r"\s+", " ", fund_manager) or None + units = _to_decimal(num_cells[0].text) + nav = _to_decimal(num_cells[1].text) + tier = None + if m := NPS_TIER_RE.search(scheme): + tier = _norm_tier(m.group(1)) + asset_class = None + if m := NPS_ASSET_RE.search(scheme): + asset_class = m.group(1).upper() + return NPSScheme( + scheme=scheme, + fund_manager=fund_manager, + tier=tier, + asset_class=asset_class, + units=units, + nav=nav, + value=(units * nav).quantize(Decimal("0.01")), + ) + + +def _parse_nps(blocks: List[Block]) -> Optional[NPSAccount]: + """Extract the NPS holdings section (if present) from a CDSL CAS. + + Holdings only — the NPS transaction statement is intentionally not + parsed. Scans for the NPS `HOLDING STATEMENT` region (identified by its + `NPS TRUST-` scheme rows) and reads scheme name / fund manager / + units / nav; PRAN + NPS-SP + reported portfolio value from the section + text. Returns None when the CAS has no NPS section. + """ + nps_sp: Optional[str] = None + pran: Optional[str] = None + value: Optional[Decimal] = None + schemes: List[NPSScheme] = [] + mode: Optional[str] = None # None | "txn" | "holding" + buf: list = [] + + def flush() -> None: + nonlocal buf + if buf: + sc = _build_nps_scheme(buf) + if sc is not None: + schemes.append(sc) + buf = [] + + for b in blocks: + txt = b.text() + low = txt.lower() + if nps_sp is None and (m := NPS_SP_RE.search(txt)): + nps_sp = m.group(1) + if pran is None and (m := NPS_PRAN_RE.search(txt)): + pran = m.group(1) + if "statement of transactions" in low: + flush() + mode = "txn" + continue + if "holding statement" in low and "as on" in low: + flush() + mode = "holding" + continue + if "portfolio value" in low: + # Closes the NPS holding region; capture the reported total only + # when we are actually closing NPS holdings (schemes seen). + if (schemes or buf) and (m := NPS_VALUE_RE.search(txt)): + value = _to_decimal(m.group(1)) + flush() + mode = None + continue + if "nps investment summary" in low: + flush() + mode = None + continue + if mode != "holding": + continue + starts_scheme = any(c.text.strip().lower().startswith(NPS_SCHEME_MARKER) for c in b.cells) + if starts_scheme: + flush() + buf = list(b.cells) + elif buf: + buf.extend(b.cells) + flush() + + if not schemes and value is None and nps_sp is None and pran is None: + return None + if value is None: + value = sum((s.value for s in schemes), Decimal(0)) + return NPSAccount(pran=pran, nps_sp=nps_sp, value=value, schemes=schemes) + + def parse_cdsl( pdf_path: str, password: str, @@ -411,6 +546,7 @@ def parse_cdsl( _atoms=atoms, ), file_type=file_type, + nps=_parse_nps(blocks), ) diff --git a/casparser/types.py b/casparser/types.py index b2d90e8..d9eea5c 100644 --- a/casparser/types.py +++ b/casparser/types.py @@ -269,11 +269,51 @@ def fix_float(cls, data: dict): return data +class NPSScheme(BaseModel): + """A single NPS scheme holding (one asset class within a tier). + + NPS schemes are not securities — they carry no ISIN/AMFI code. The + depository CAS prints the pension-fund scheme name, the fund manager, + and the unit balance + NAV; `value` is `units * nav`. + """ + + scheme: str + fund_manager: Optional[str] = None + tier: Optional[str] = None # "I" / "II" + asset_class: Optional[str] = None # "E" | "C" | "G" | "A" + units: Decimal + nav: Decimal + value: Decimal + + @model_validator(mode="before") + @classmethod + def fix_float(cls, data: dict): + for k, v in data.items(): + try: + if issubclass(Decimal, cls.__annotations__[k]) and isinstance(v, str): + data[k] = v.replace(",", "") + except TypeError: + pass + return data + + +class NPSAccount(BaseModel): + """NPS holdings for a single PRAN, as printed in the depository CAS.""" + + pran: Optional[str] = None # PRAN as printed (often masked) + nps_sp: Optional[str] = None # NPS Service Provider / CRA (e.g. "PROT" = Protean) + value: Decimal # reported "Portfolio Value" total for the PRAN + schemes: List[NPSScheme] = [] + + class NSDLCASData(BaseModel): accounts: List[DematAccount] statement_period: StatementPeriod investor_info: InvestorInfo file_type: FileType + # National Pension System holdings, when the CAS includes an NPS + # section (holdings only — the transaction ledger is not parsed). + nps: Optional[NPSAccount] = None # Non-fatal data-quality warnings from NSDL demat parsing (e.g. a # holdings row whose nav/value could not be confirmed arithmetically). parse_warnings: List[str] = [] diff --git a/schema/NSDLCASData.schema.json b/schema/NSDLCASData.schema.json index 3746bdc..f8f927b 100644 --- a/schema/NSDLCASData.schema.json +++ b/schema/NSDLCASData.schema.json @@ -455,6 +455,121 @@ "title": "MutualFund", "type": "object" }, + "NPSAccount": { + "description": "NPS holdings for a single PRAN, as printed in the depository CAS.", + "properties": { + "nps_sp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Nps Sp" + }, + "pran": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pran" + }, + "schemes": { + "default": [], + "items": { + "$ref": "#/$defs/NPSScheme" + }, + "title": "Schemes", + "type": "array" + }, + "value": { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "title": "Value", + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "NPSAccount", + "type": "object" + }, + "NPSScheme": { + "description": "A single NPS scheme holding (one asset class within a tier).\n\nNPS schemes are not securities \u2014 they carry no ISIN/AMFI code. The\ndepository CAS prints the pension-fund scheme name, the fund manager,\nand the unit balance + NAV; `value` is `units * nav`.", + "properties": { + "asset_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Asset Class" + }, + "fund_manager": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fund Manager" + }, + "nav": { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "title": "Nav", + "type": "string" + }, + "scheme": { + "title": "Scheme", + "type": "string" + }, + "tier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tier" + }, + "units": { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "title": "Units", + "type": "string" + }, + "value": { + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "title": "Value", + "type": "string" + } + }, + "required": [ + "scheme", + "units", + "nav", + "value" + ], + "title": "NPSScheme", + "type": "object" + }, "StatementPeriod": { "properties": { "from": { @@ -489,6 +604,17 @@ "investor_info": { "$ref": "#/$defs/InvestorInfo" }, + "nps": { + "anyOf": [ + { + "$ref": "#/$defs/NPSAccount" + }, + { + "type": "null" + } + ], + "default": null + }, "parse_warnings": { "default": [], "items": { diff --git a/tests/test_demat_units.py b/tests/test_demat_units.py index 83653d7..9512c48 100644 --- a/tests/test_demat_units.py +++ b/tests/test_demat_units.py @@ -1307,3 +1307,104 @@ def test_enriches_equity_symbol(self, tmp_path, monkeypatch): assert eqs["INE002A01018"].exchange == "NSE" # Unresolved ISIN stays None, doesn't raise. assert eqs["INE000X00X00"].symbol is None + + +class TestNPS: + """CDSL NPS holdings parser (`cdsl._parse_nps`). Blocks mirror the real + layout: each scheme spans a name-line-1 (+fund-mgr-1), an interleaved + units/nav line, and a name-line-2 (+fund-mgr-2).""" + + @staticmethod + def _scheme_blocks(asset: str, tier_word: str, units: str, nav: str, y: int): + # name/fund-manager wrap across two lines; numerics sit between them. + return [ + _block( + _cell("NPS TRUST- A/C HDFC PENSION FUND", 223, 2000, y, y - 40), + _cell("HDFC PENSION FUND MANAGEMENT", 2223, 3800, y, y - 40), + ), + _block( + _cell(units, 4379, 5100, y - 45, y - 85), + _cell(nav, 5457, 5900, y - 45, y - 85), + ), + _block( + _cell( + f"MANAGEMENT LIMITED SCHEME {asset} - TIER {tier_word}", + 223, + 2000, + y - 90, + y - 130, + ), + _cell("LIMITED", 2223, 3000, y - 90, y - 130), + ), + ] + + def _nps_blocks(self): + blocks = [ + _block(_cell("NPS-SP : PROT PRAN ID : 110099887766", 205, 4000, 7000, 6960)), + _block( + _cell( + "STATEMENT OF TRANSACTIONS FOR THE PERIOD FROM 01-04-2025 TO 31-03-2026", + 1048, + 5000, + 6800, + 6760, + ) + ), + # a transaction row mentioning NPS TRUST — must NOT be parsed as a holding + _block( + _cell("02-Feb-2026", 218, 700, 6700, 6660), + _cell( + "NPS TRUST- A/C HDFC PENSION FUND MANAGEMENT LIMITED SCHEME G - TIER I", + 829, + 2000, + 6700, + 6660, + ), + _cell("CR", 3551, 3700, 6700, 6660), + _cell("17,828.64", 4177, 4600, 6700, 6660), + ), + _block(_cell("HOLDING STATEMENT AS ON 31-03-2026", 2044, 4000, 6392, 6352)), + ] + blocks += self._scheme_blocks("G", "I", "45,982.3138", "27.6140", 6137) + blocks += self._scheme_blocks("E", "I", "12,000.0000", "50.0000", 5900) + blocks.append( + _block(_cell("Portfolio Value ` 8,69,755.61 as on 31-03-2026", 239, 3000, 5433, 5393)) + ) + return blocks + + def test_parse_nps_holdings(self): + nps = cdsl_p._parse_nps(self._nps_blocks()) + assert nps is not None + assert nps.nps_sp == "PROT" + assert nps.pran == "110099887766" + assert nps.value == Decimal("869755.61") + assert len(nps.schemes) == 2 # transaction row not counted + g, e = nps.schemes + assert g.asset_class == "G" and g.tier == "I" + assert g.units == Decimal("45982.3138") and g.nav == Decimal("27.6140") + assert g.value == Decimal("1269755.61") # units * nav + assert g.fund_manager == "HDFC PENSION FUND MANAGEMENT LIMITED" + assert e.asset_class == "E" and e.value == Decimal("600000.00") + + def test_parse_nps_skips_redacted_scheme(self): + # A scheme whose units/nav are blank (redacted) yields no numerics -> skipped. + blocks = [ + _block(_cell("HOLDING STATEMENT AS ON 31-03-2026", 2044, 4000, 6392, 6352)), + _block( + _cell("NPS TRUST- A/C HDFC PENSION FUND", 223, 2000, 6137, 6097), + _cell("HDFC PENSION FUND MANAGEMENT", 2223, 3800, 6137, 6097), + ), + _block(_cell("MANAGEMENT LIMITED SCHEME C - TIER I", 223, 2000, 6046, 6006)), + _block(_cell("Portfolio Value ` 5,00,000.00 as on 31-03-2026", 239, 3000, 5433, 5393)), + ] + nps = cdsl_p._parse_nps(blocks) + assert nps is not None + assert nps.value == Decimal("500000.00") + assert nps.schemes == [] + + def test_parse_nps_absent_returns_none(self): + blocks = [ + _block(_cell("HOLDING STATEMENT AS ON 31-03-2026", 2044, 4000, 100, 60)), + _block(_cell("INE000A01001", 20, 75, 50, 10), _cell("SOME EQUITY", 80, 200, 50, 10)), + ] + assert cdsl_p._parse_nps(blocks) is None From 055f8d73872e7a875363c9fe2d5f7cec99e26c40 Mon Sep 17 00:00:00 2001 From: Sandeep Somasekharan Date: Sun, 5 Jul 2026 18:46:58 +1000 Subject: [PATCH 2/3] feat(cli): print NPS holdings in the CDSL/NSDL summary --- casparser/cli.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/casparser/cli.py b/casparser/cli.py index caba8e6..f0bedb6 100644 --- a/casparser/cli.py +++ b/casparser/cli.py @@ -157,6 +157,35 @@ def print_nsdl(parsed_data: NSDLCASData): console.print(table) + # NPS holdings (CDSL, holdings-only) + if parsed_data.nps and parsed_data.nps.schemes: + nps = parsed_data.nps + nps_table = Table(title="NPS Holdings", show_lines=True) + nps_table.add_column("Scheme") + nps_table.add_column("Class") + nps_table.add_column("Tier") + nps_table.add_column("Units", justify="right") + nps_table.add_column("NAV", justify="right") + nps_table.add_column("Value", justify="right") + for s in nps.schemes: + nps_table.add_row( + s.scheme, + s.asset_class or "", + s.tier or "", + format_number(s.units), + format_number(s.nav), + formatINR(s.value), + ) + console.print(nps_table) + meta = [] + if nps.nps_sp: + meta.append(f"NPS-SP: [bold]{nps.nps_sp}[/]") + if nps.pran: + meta.append(f"PRAN: [bold]{nps.pran}[/]") + meta.append(f"Value: [bold green]{formatINR(nps.value)}[/]") + console.print(" ".join(meta)) + console.print("") + # Asset class breakdown equities_total = Decimal(0) mf_demat_total = Decimal(0) @@ -186,7 +215,10 @@ def print_nsdl(parsed_data: NSDLCASData): asset_table.add_row(" Mutual Funds (demat)", formatINR(mf_demat_total)) if mf_folio_total > 0: asset_table.add_row(" Mutual Fund Folios", formatINR(mf_folio_total)) - total_all = debts_total + equities_total + mf_demat_total + mf_folio_total + nps_total = parsed_data.nps.value if parsed_data.nps else Decimal(0) + if nps_total > 0: + asset_table.add_row(" National Pension System", formatINR(nps_total)) + total_all = debts_total + equities_total + mf_demat_total + mf_folio_total + nps_total asset_table.add_row(" " + "─" * 20, "") asset_table.add_row( f" Total Portfolio Value [As of {data['statement_period']['to']}]", From dd0ad0faca8130d72b16c980bab203b8674acb46 Mon Sep 17 00:00:00 2001 From: Sandeep Somasekharan Date: Mon, 6 Jul 2026 18:27:29 +1000 Subject: [PATCH 3/3] fix(cdsl): robust NPS scheme/fund-manager split + page-break handling Handle page breaks. --- casparser/parsers/cdsl.py | 55 ++++++++++++++++++++++++++++++--------- tests/test_demat_units.py | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/casparser/parsers/cdsl.py b/casparser/parsers/cdsl.py index 3b23f5d..7c1419e 100644 --- a/casparser/parsers/cdsl.py +++ b/casparser/parsers/cdsl.py @@ -110,11 +110,18 @@ # Tier ("TIER I"/"TIER II") and asset class ("SCHEME E/C/G/A") in the name. NPS_TIER_RE = re.compile(r"\bTIER\s*[-\s]*(I{1,2}|1|2)\b", re.I) NPS_ASSET_RE = re.compile(r"\bSCHEME\s+([A-Za-z])\b") -# Coarse split between the two text columns (scheme name | fund manager). -# The holding table has exactly two text columns and two numeric columns; -# numerics are ordered (units, then nav), so only the text split needs an x -# hint — deliberately loose, well inside the wide inter-column gap. -_NPS_FUND_MGR_MIN_X = 1500.0 +# Text that marks a block as genuine NPS holding content (scheme name / +# fund manager lines). Used to skip page furniture (bank header, tab bar, +# investor name, column headers, "Page N of M" footer) that gets interleaved +# when a scheme row straddles a page break. +NPS_HOLDING_TEXT_RE = re.compile( + r"NPS\s+TRUST|PENSION\s+FUND|MANAGEMENT\s+LIMITED|SCHEME\s+[A-Z0-9]\s*-\s*TIER", + re.I, +) +# Minimum x-gap (points) that separates the two text columns (scheme name | +# fund manager). Wrapped lines within a column share an x, so any gap this +# large is a real column boundary — layout-independent, unlike a fixed x. +_NPS_COL_GAP_MIN = 150.0 # --- decimal helpers --- @@ -243,6 +250,19 @@ def _norm_tier(t: str) -> str: return {"1": "I", "2": "II"}.get(t.upper(), t.upper()) +def _is_nps_holding_block(block: Block) -> bool: + """True when a block is part of an NPS holding row — it carries a numeric + (units/nav) cell or scheme/fund-manager text. Everything else in the + holding region (bank header, tab bar, investor name, column headers, + "Page N of M" footer) is page furniture and is skipped.""" + for c in block.cells: + if _looks_numeric(c.text): + return True + if NPS_HOLDING_TEXT_RE.search(c.text): + return True + return False + + def _build_nps_scheme(cells: list) -> Optional[NPSScheme]: """Build one NPSScheme from an accumulated holding-row cell buffer. @@ -254,12 +274,19 @@ def _build_nps_scheme(cells: list) -> Optional[NPSScheme]: """ text_cells = [c for c in cells if c.text.strip() and not _looks_numeric(c.text)] num_cells = sorted((c for c in cells if _looks_numeric(c.text)), key=lambda c: c.x_left) - name_cells = sorted( - (c for c in text_cells if c.x_left < _NPS_FUND_MGR_MIN_X), key=lambda c: -c.y_top - ) - fm_cells = sorted( - (c for c in text_cells if c.x_left >= _NPS_FUND_MGR_MIN_X), key=lambda c: -c.y_top - ) + # Split the two text columns (scheme name | fund manager) by the largest + # x-gap rather than a fixed threshold — column x-positions drift between + # CAS versions. Left cluster = scheme name, right = fund manager. + by_x = sorted(text_cells, key=lambda c: c.x_left) + split = len(by_x) + if len(by_x) >= 2: + max_gap, at = max( + (by_x[i + 1].x_left - by_x[i].x_left, i + 1) for i in range(len(by_x) - 1) + ) + if max_gap >= _NPS_COL_GAP_MIN: + split = at + name_cells = sorted(by_x[:split], key=lambda c: -c.y_top) + fm_cells = sorted(by_x[split:], key=lambda c: -c.y_top) scheme = " ".join(c.text.replace("\n", " ").strip() for c in name_cells).strip() scheme = re.sub(r"\s+", " ", scheme) if NPS_SCHEME_MARKER not in scheme.lower(): @@ -344,7 +371,11 @@ def flush() -> None: if starts_scheme: flush() buf = list(b.cells) - elif buf: + elif buf and _is_nps_holding_block(b): + # Only holding content extends the row buffer; page furniture + # (bank header, tab bar, investor name, column headers, footer) + # interleaved across a page break is skipped so it doesn't leak + # into the scheme name of a row that straddles two pages. buf.extend(b.cells) flush() diff --git a/tests/test_demat_units.py b/tests/test_demat_units.py index 9512c48..d9ef327 100644 --- a/tests/test_demat_units.py +++ b/tests/test_demat_units.py @@ -1408,3 +1408,54 @@ def test_parse_nps_absent_returns_none(self): _block(_cell("INE000A01001", 20, 75, 50, 10), _cell("SOME EQUITY", 80, 200, 50, 10)), ] assert cdsl_p._parse_nps(blocks) is None + + def test_parse_nps_scheme_spanning_page_break(self): + """A scheme row straddling a page break must not absorb the next + page's furniture (bank header, tab bar, investor name, column header, + footer), and the scheme/fund-manager split must not depend on a fixed + x (fund manager here sits at x=1000, below the old threshold).""" + blocks = [ + _block(_cell("HOLDING STATEMENT AS ON 31-05-2026", 2044, 4000, 700, 660)), + # scheme line 1 (name @200, fund-manager @1000) + units/nav + _block( + _cell("NPS TRUST A/C HDFC PENSION FUND", 200, 900, 600, 560), + _cell("HDFC PENSION FUND MANAGEMENT", 1000, 1800, 600, 560), + ), + _block( + _cell("45,982.3138", 4000, 4600, 555, 515), _cell("27.6140", 5000, 5400, 555, 515) + ), + # --- page break: furniture that must be skipped --- + _block(_cell("Central Depository Services (India) Limited", 2000, 3500, 550, 510)), + _block( + _cell( + "CONSOLIDATED ACCOUNT STATEMENT (CAS) FOR SECURITIES HELD IN DEMAT", + 800, + 4000, + 540, + 500, + ) + ), + _block(_cell("VINEET MENON", 250, 900, 530, 490)), + _block( + _cell("Scheme Name", 800, 1500, 520, 480), + _cell("Fund Manager", 2000, 2800, 520, 480), + ), + _block(_cell("Page 18 of 21", 500, 900, 510, 470)), + # scheme line 2 (continuation) + fund-manager line 2 + _block( + _cell("MANAGEMENT LIMITED SCHEME G - TIER I GS", 200, 900, 500, 460), + _cell("LIMITED", 1000, 1400, 500, 460), + ), + _block(_cell("Portfolio Value ` 12,69,755.61 as on 31-05-2026", 239, 3000, 440, 400)), + ] + nps = cdsl_p._parse_nps(blocks) + assert nps is not None + assert len(nps.schemes) == 1 + s = nps.schemes[0] + assert s.scheme == "NPS TRUST A/C HDFC PENSION FUND MANAGEMENT LIMITED SCHEME G - TIER I GS" + assert s.fund_manager == "HDFC PENSION FUND MANAGEMENT LIMITED" + assert s.asset_class == "G" and s.tier == "I" + assert s.units == Decimal("45982.3138") and s.nav == Decimal("27.6140") + # no page furniture leaked into the scheme name + for junk in ("VINEET", "Central", "Page", "CONSOLIDATED", "Scheme Name"): + assert junk not in s.scheme