From 2926d6081863e7d32f5e0fc9320cf06b7a739032 Mon Sep 17 00:00:00 2001 From: Sandeep Somasekharan Date: Wed, 24 Jun 2026 22:41:21 +1000 Subject: [PATCH] fix: classify STP as switch across both RTAs (discussion #141) --- CHANGELOG.md | 7 +++++++ casparser/__init__.py | 2 +- casparser/parsers/_classify.py | 13 +++++++++++-- tests/test_helpers.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd7f66..0d5255e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.2.1 + +### Fixed + +- Fix mis-classification of STP (Systematic Transfer Plan) for + Kfintech-serviced schemes. (discussion #141) + ## 1.2.0 ### New diff --git a/casparser/__init__.py b/casparser/__init__.py index b106f27..19ccb3b 100644 --- a/casparser/__init__.py +++ b/casparser/__init__.py @@ -9,4 +9,4 @@ "CapitalGainsReport", ] -__version__ = "1.2.0" +__version__ = "1.2.1" diff --git a/casparser/parsers/_classify.py b/casparser/parsers/_classify.py index f92a055..52baa1f 100644 --- a/casparser/parsers/_classify.py +++ b/casparser/parsers/_classify.py @@ -37,6 +37,15 @@ ) REINVEST_RE = re.compile(r"reinvest", re.I) +# Systematic Transfer Plan. A switch by another name — money is moved +# between two schemes on a schedule. The two RTAs word it differently: +# CAMS prints "Systematic Transfer Plan Switch In/Out …" (already caught by +# the "switch" keyword), while KFintech prints it letter-spaced as +# "S T P In/Out (…)", which matches none of the plain substring checks and +# used to fall through to PURCHASE / REDEMPTION. Detecting it here — spacing +# tolerated — lets both RTAs classify consistently as SWITCH_IN / SWITCH_OUT. +STP_RE = re.compile(r"\bs\s*t\s*p\b|systematic\s+transfer", re.I) + # Counterparty folio embedded in a gift transfer description. Both RTAs # name the other folio but punctuate differently — KFin uses a colon # ("Folio No: 12345678901"), CAMS a dot ("Folio No.87654321") — so accept @@ -82,7 +91,7 @@ def get_transaction_type( elif units > 0: if "gift" in description: txn_type = TransactionType.GIFT_IN - elif "switch" in description: + elif "switch" in description or STP_RE.search(description): txn_type = ( TransactionType.SWITCH_IN_MERGER if "merger" in description @@ -109,7 +118,7 @@ def get_transaction_type( re.I, ): txn_type = TransactionType.REVERSAL - elif "switch" in description: + elif "switch" in description or STP_RE.search(description): txn_type = ( TransactionType.SWITCH_OUT_MERGER if "merger" in description diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 8adb91f..56e334f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -119,6 +119,39 @@ def test_gifts(self): Decimal("4085.662"), ) == (TransactionType.GIFT_IN, None) + def test_stp(self): + # Systematic Transfer Plan legs must classify as SWITCH_OUT (source, + # negative units) / SWITCH_IN (target, positive units) regardless of + # how the RTA words them. CAMS spells it out ("Systematic Transfer + # Plan Switch …", already caught by the "switch" keyword); KFintech + # prints it letter-spaced as "S T P In/Out", which previously fell + # through to PURCHASE / REDEMPTION. (discussion #141) + assert get_transaction_type( + "Systematic Transfer Plan Switch Out - To SBI Small Cap Fund Dir Growth-", + Decimal("-5.938"), + ) == (TransactionType.SWITCH_OUT, None) + assert get_transaction_type( + "Systematic Transfer Plan Switch In - From SBI Liquid Fund Direct Growth-", + Decimal("126.972"), + ) == (TransactionType.SWITCH_IN, None) + # KFintech, letter-spaced. + assert get_transaction_type( + "S T P Out (To Axis Mid Cap Fund - Direct Growth F.No:91064932471)", + Decimal("-24.933"), + ) == (TransactionType.SWITCH_OUT, None) + assert get_transaction_type( + "S T P In (From Axis Liquid Fund - Direct Growth F.No:91064932471)", + Decimal("571.574"), + ) == (TransactionType.SWITCH_IN, None) + + def test_sip_not_misread_as_stp(self): + # A genuine SIP must stay PURCHASE_SIP — the STP detection must not + # swallow "Systematic Investment" or the "SIP" token. + assert get_transaction_type( + "Systematic Investment-Instalment No 12", + Decimal("10"), + ) == (TransactionType.PURCHASE_SIP, None) + def test_dividends(self): assert get_transaction_type( "IDCW Reinvestment @ Rs.2.00 per unit",