diff --git a/examples/django_reference/reference_app/ofr_bridge.py b/examples/django_reference/reference_app/ofr_bridge.py index e4b1a72..990b2dd 100644 --- a/examples/django_reference/reference_app/ofr_bridge.py +++ b/examples/django_reference/reference_app/ofr_bridge.py @@ -146,10 +146,12 @@ def process_source_file(source_file_id: int) -> dict[str, Any]: normalized["transaction_date"] = pd.to_datetime( normalized["transaction_date"] ).dt.date - normalized["units"] = normalized["units"].astype(float) - normalized["amount"] = normalized["amount"].astype(float) + from openreversefeed.adapters.parse_utils import to_number + + normalized["units"] = normalized["units"].map(to_number) + normalized["amount"] = normalized["amount"].map(to_number) if "nav" in normalized.columns: - normalized["nav"] = normalized["nav"].astype(float) + normalized["nav"] = normalized["nav"].map(to_number) cleaned = Cleaner().run(normalized, adapter) diff --git a/src/openreversefeed/adapters/cams.py b/src/openreversefeed/adapters/cams.py index dbc269f..7600ae0 100644 --- a/src/openreversefeed/adapters/cams.py +++ b/src/openreversefeed/adapters/cams.py @@ -1,4 +1,5 @@ """CAMS_FORMAT1 adapter. See spec §5 step 3.""" + from __future__ import annotations from pathlib import Path @@ -32,6 +33,17 @@ "REINVEST_F": "dividend_option_flag", } +# CAMS WBR2C (DBF/CSV) uses SCHEME (name) + PRODCODE (code) instead of SCHEME_CODE. +_WBR2C_FIELD_MAP: dict[str, str] = { + **_FIELD_MAP, + "TRXNNO": "transaction_number", + "PAN": "pan", + "INV_NAME": "investor_name", + "PURPRICE": "nav", + "STAMP_DUTY": "stamp_duty", + "AMC_CODE": "amc_code", +} + # Mapping from the raw CAMS REINVEST_F flag to the canonical plan_type # vocabulary the scheme master stores. 'Y' means reinvest, 'N' means # payout. Anything else (empty, 'P' for pure growth, nulls) → None, @@ -192,12 +204,58 @@ def classify_row(self, row: dict[str, Any]) -> tuple[Action, str, bool]: return action, tag, False def composite_key(self, row: dict[str, Any]) -> str: - date_val = row["transaction_date"] - date_str = date_val.strftime("%Y%m%d") if hasattr(date_val, "strftime") else str(date_val) + from openreversefeed.core.composite_key import _date_str + return ( f"{row['original_trans_number']}_{row['transaction_type']}_" - f"{row['transaction_number']}_{date_str}" + f"{row['transaction_number']}_{_date_str(row['transaction_date'])}" ) +class CamsWbr2cAdapter(CamsAdapter): + """CAMS WBR2C transaction feed — DBF/CSV with SCHEME + AMC_CODE columns.""" + + name = "cams_wbr2c" + priority = 110 + mandatory_headers = { + "USRTRXNO", + "FOLIO_NO", + "PRODCODE", + "SCHEME", + "UNITS", + "AMOUNT", + "TRADDATE", + "TRXNMODE", + "TRXNTYPE", + } + discriminator_headers = {"AMC_CODE", "TRXNNO"} + field_map = _WBR2C_FIELD_MAP + + def parse(self, file_path: str | Path) -> pd.DataFrame: + path = Path(file_path) + if path.suffix.lower() == ".csv": + from openreversefeed.adapters.parse_utils import ( + has_quoted_headers, + normalize_quoted_csv, + ) + + if has_quoted_headers(path): + return normalize_quoted_csv(path) + return super().parse(file_path) + + def normalize(self, raw: pd.DataFrame) -> pd.DataFrame: + df = super().normalize(raw) + # WBR2C ships PRODCODE as the scheme identifier (no SCHEME_CODE column). + if "scheme_code" not in df.columns and "product_code" in df.columns: + df["scheme_code"] = df["product_code"] + # Include stamp duty in cost basis, matching Investwell / KFintech handling. + if "stamp_duty" in df.columns: + df["amount"] = pd.to_numeric(df["amount"], errors="coerce").fillna(0) + pd.to_numeric( + df["stamp_duty"], errors="coerce" + ).fillna(0) + df.drop(columns=["stamp_duty"], inplace=True) + return df + + default_registry.register(CamsAdapter) +default_registry.register(CamsWbr2cAdapter) diff --git a/src/openreversefeed/adapters/kfintech.py b/src/openreversefeed/adapters/kfintech.py index fd1c0a4..37d4a86 100644 --- a/src/openreversefeed/adapters/kfintech.py +++ b/src/openreversefeed/adapters/kfintech.py @@ -1,4 +1,5 @@ """KFintech (formerly KARVY) adapters. Three sub-formats: FORMAT1, FORMAT2, CSV.""" + from __future__ import annotations from pathlib import Path @@ -163,10 +164,10 @@ def classify_row(self, row: dict[str, Any]) -> tuple[Action, str, bool]: return action, tag, False def composite_key(self, row: dict[str, Any]) -> str: + from openreversefeed.core.composite_key import _date_str + parent = row.get("parent_transaction_number") or "0" - date_val = row["transaction_date"] - date_str = date_val.strftime("%Y%m%d") if hasattr(date_val, "strftime") else str(date_val) - return f"{row['transaction_number']}_{parent}_{row['folio_number']}_{date_str}" + return f"{row['transaction_number']}_{parent}_{row['folio_number']}_{_date_str(row['transaction_date'])}" class KFintechFormat1Adapter(_KFintechBase): @@ -237,6 +238,69 @@ def normalize(self, raw: pd.DataFrame) -> pd.DataFrame: return self._normalize_with_map(raw, _CSV_FIELD_MAP) +# Field map for the KFintech "detailed" English-header CSV format that broker +# portals / BSE STAR MF export. Column names differ from the simple CSV format +# in capitalization and naming (e.g. "Nav" not "NAV", "Transaction Head" not +# "Transaction Purred", "PAN1" not "PAN", "Product Code" not "AMC Code"). +_DETAILED_CSV_FIELD_MAP = { + "Inward Number": "transaction_id", + "Transaction Number": "transaction_number", + "Purchase Transaction No": "parent_transaction_number", + "Folio Number": "folio_number", + "Product Code": "product_code", + "Scheme Code": "scheme_code", + "Units": "units", + "Amount": "amount", + "Nav": "nav", + "Transaction Date": "transaction_date", + "Transaction Mode": "transaction_mode", + # "Transaction Head" carries P/R/D/DP — the purred-style indicator + "Transaction Head": "transaction_purred", + # "Transaction Flag" carries TI/TO/SI/SO for transfers and switches + "Transaction Flag": "transaction_flag", + "Transaction Type": "transaction_type", + "PAN1": "pan", + "Investor Name": "investor_name", + "Agent Code": "broker_code", + # Stamp duty is a separate column; we capture it so normalize() can fold + # it into the canonical amount for correct cost-basis accounting. + "Stamp Duty Charges": "stamp_duty", +} + + +class KFintechDetailedCsvAdapter(_KFintechBase): + """KFintech English-header CSV as exported by BSE STAR MF / broker portals. + + Distinguishable from KFintechCsvAdapter by: + - "Nav" (not "NAV") as the NAV column + - "Transaction Head" (not "Transaction Purred") for the P/R/D indicator + - "PAN1" (not "PAN") for the investor PAN + - "Product Code" (not "AMC Code") for the AMC identifier + """ + + name = "kfintech_detailed_csv" + priority = 75 + mandatory_headers = {"Inward Number", "Folio Number", "Units", "Transaction Date"} + discriminator_headers = {"Transaction Head", "PAN1"} + field_map = _DETAILED_CSV_FIELD_MAP + + def parse(self, file_path: str | Path) -> pd.DataFrame: + return pd.read_csv(Path(file_path), dtype=str) + + def normalize(self, raw: pd.DataFrame) -> pd.DataFrame: + df = self._normalize_with_map(raw, _DETAILED_CSV_FIELD_MAP) + # Fold stamp duty into amount so cost-basis reflects the true debit. + # Stamp duty is always a cost on purchases; on redemptions it is zero. + if "stamp_duty" in df.columns: + df["amount"] = ( + pd.to_numeric(df["amount"], errors="coerce").fillna(0) + + pd.to_numeric(df["stamp_duty"], errors="coerce").fillna(0) + ).astype(str) + df.drop(columns=["stamp_duty"], inplace=True) + return df + + default_registry.register(KFintechFormat1Adapter) default_registry.register(KFintechFormat2Adapter) default_registry.register(KFintechCsvAdapter) +default_registry.register(KFintechDetailedCsvAdapter) diff --git a/src/openreversefeed/adapters/parse_utils.py b/src/openreversefeed/adapters/parse_utils.py new file mode 100644 index 0000000..d7224af --- /dev/null +++ b/src/openreversefeed/adapters/parse_utils.py @@ -0,0 +1,71 @@ +"""Feed parsing helpers — quote normalization, header peeking, numeric coercion.""" + +from __future__ import annotations + +import csv +from pathlib import Path +from typing import Any + +import pandas as pd + + +def _strip_outer_quotes(value: str) -> str: + """Strip matched leading/trailing single quotes from a string value.""" + if len(value) >= 2 and value[0] == "'" and value[-1] == "'": + return value[1:-1] + return value + + +def normalize_quoted_csv(file_path: str | Path) -> pd.DataFrame: + """Read a CSV that may have single-quoted headers and cell values. + + Strips matched leading/trailing single quotes from column names and + from all string cell values. Leaves unquoted values and internal + apostrophes untouched. + """ + df = pd.read_csv(Path(file_path), dtype=str) + df.columns = pd.Index([_strip_outer_quotes(c) for c in df.columns]) + for col in df.columns: + if df[col].dtype == object: + df[col] = df[col].map(lambda v: _strip_outer_quotes(v) if isinstance(v, str) else v) + return df + + +def peek_feed_headers(file_path: str | Path) -> set[str]: + """Read only the first line of a CSV and return normalized header names. + + Applies the same single-quote stripping as normalize_quoted_csv so + detection works on quoted CAMS lot-level files. + """ + path = Path(file_path) + with path.open(newline="", encoding="utf-8-sig") as f: + reader = csv.reader(f) + try: + raw_headers = next(reader) + except StopIteration: + return set() + return {_strip_outer_quotes(h.strip()) for h in raw_headers if h.strip()} + + +def has_quoted_headers(file_path: str | Path) -> bool: + """Return True if the CSV header row contains single-quoted column names.""" + path = Path(file_path) + with path.open(newline="", encoding="utf-8-sig") as f: + first_line = f.readline() + return first_line.startswith("'") and "'," in first_line + + +def to_number(value: Any) -> float: + """Convert a value to float, stripping commas from Indian-formatted numbers. + + Returns float('nan') for empty strings and None. + """ + if isinstance(value, (int, float)): + return float(value) + if value is None: + return float("nan") + s = str(value).strip() + if not s: + return float("nan") + s = s.replace(",", "") + return float(s) diff --git a/src/openreversefeed/core/composite_key.py b/src/openreversefeed/core/composite_key.py index f7da8aa..0c9fa75 100644 --- a/src/openreversefeed/core/composite_key.py +++ b/src/openreversefeed/core/composite_key.py @@ -1,17 +1,84 @@ """Deterministic composite key builders. See spec §5 step 5.""" + from __future__ import annotations +import re from typing import Any import pandas as pd from openreversefeed.core.models import Registrar +_JS_DATE_RE = re.compile(r"[A-Z][a-z]{2}\s+([A-Z][a-z]{2})\s+(\d{1,2})\s+(\d{4})") +_MONTH_ABBR = { + "Jan": 1, + "Feb": 2, + "Mar": 3, + "Apr": 4, + "May": 5, + "Jun": 6, + "Jul": 7, + "Aug": 8, + "Sep": 9, + "Oct": 10, + "Nov": 11, + "Dec": 12, +} +_SLASH_AMPM_RE = re.compile( + r"^(\d{1,2})/(\d{1,2})/(\d{4})\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M$", + re.IGNORECASE, +) +_ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$") +_SLASH_RE = re.compile(r"^(\d{1,2})/(\d{1,2})/(\d{4})$") +_DASH_DMY_RE = re.compile(r"^(\d{1,2})-(\d{1,2})-(\d{4})$") +_ALREADY_YYYYMMDD_RE = re.compile(r"^\d{8}$") + def _date_str(value: Any) -> str: if hasattr(value, "strftime"): return value.strftime("%Y%m%d") - return str(value) + + s = str(value).strip() + + if _ALREADY_YYYYMMDD_RE.match(s): + return s + + # ISO: YYYY-MM-DD + m = _ISO_RE.match(s) + if m: + return f"{m.group(1)}{m.group(2)}{m.group(3)}" + + # US-style with AM/PM timestamp: M/D/YYYY HH:MM:SS AM + m = _SLASH_AMPM_RE.match(s) + if m: + month, day, year = int(m.group(1)), int(m.group(2)), int(m.group(3)) + return f"{year:04d}{month:02d}{day:02d}" + + # Slash without timestamp: first > 12 means DD/MM, else DD/MM (Indian default) + m = _SLASH_RE.match(s) + if m: + a, b, year = int(m.group(1)), int(m.group(2)), int(m.group(3)) + if a > 12: + day, month = a, b + else: + day, month = a, b + return f"{year:04d}{month:02d}{day:02d}" + + # Dash DD-MM-YYYY + m = _DASH_DMY_RE.match(s) + if m: + day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3)) + return f"{year:04d}{month:02d}{day:02d}" + + # JavaScript Date.toString(): "Fri Aug 28 2026 ..." + m = _JS_DATE_RE.match(s) + if m: + month_abbr, day_s, year_s = m.group(1), m.group(2), m.group(3) + month = _MONTH_ABBR.get(month_abbr, 0) + if month: + return f"{int(year_s):04d}{month:02d}{int(day_s):02d}" + + return s def build_cams_key(row: dict[str, Any]) -> str: diff --git a/tests/unit/test_cams_adapter.py b/tests/unit/test_cams_adapter.py index 2d8155e..38028db 100644 --- a/tests/unit/test_cams_adapter.py +++ b/tests/unit/test_cams_adapter.py @@ -1,8 +1,10 @@ +import tempfile from datetime import date +from pathlib import Path import pandas as pd -from openreversefeed.adapters.cams import CamsAdapter +from openreversefeed.adapters.cams import CamsAdapter, CamsWbr2cAdapter from openreversefeed.adapters.registry import default_registry from openreversefeed.core.models import Action, Registrar @@ -99,9 +101,7 @@ def test_cams_classify_non_financial_no_effect(): def test_cams_classify_nfo_is_buy_new_fund_offer(): adapter = CamsAdapter() - action, tag, is_rev = adapter.classify_row( - {"transaction_type": "NFO", "transaction_mode": "N"} - ) + action, tag, is_rev = adapter.classify_row({"transaction_type": "NFO", "transaction_mode": "N"}) assert action is Action.BUY assert tag == "new_fund_offer" assert is_rev is False @@ -126,3 +126,40 @@ def test_cams_registered_in_default_registry(): import openreversefeed.adapters.cams # noqa: F401 assert any(a is CamsAdapter for a in default_registry._adapters) + + +def test_cams_wbr2c_parse_handles_quoted_csv(): + """Quoted CAMS lot-level CSV should be parsed with stripped quotes.""" + csv_content = ( + "'USRTRXNO','FOLIO_NO','PRODCODE','SCHEME','UNITS','AMOUNT'," + "'TRADDATE','TRXNMODE','TRXNTYPE','TRXNNO','AMC_CODE','PAN','INV_NAME','PURPRICE'\n" + "'TXN001','1234567/89','P14S','DSP Fund','45.735','5000'," + "'28/08/2026','N','P','1','D','ABCDE1234F','Test Investor','109.32'\n" + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f: + f.write(csv_content) + path = Path(f.name) + + adapter = CamsWbr2cAdapter() + raw = adapter.parse(path) + assert "USRTRXNO" in raw.columns + assert raw.iloc[0]["USRTRXNO"] == "TXN001" + assert raw.iloc[0]["PAN"] == "ABCDE1234F" + + +def test_cams_wbr2c_parse_ordinary_csv_unchanged(): + """Non-quoted CAMS CSV goes through standard pandas parsing.""" + csv_content = ( + "USRTRXNO,FOLIO_NO,PRODCODE,SCHEME,UNITS,AMOUNT," + "TRADDATE,TRXNMODE,TRXNTYPE,TRXNNO,AMC_CODE,PAN,INV_NAME,PURPRICE\n" + "TXN001,1234567/89,P14S,DSP Fund,45.735,5000," + "28/08/2026,N,P,1,D,ABCDE1234F,Test Investor,109.32\n" + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f: + f.write(csv_content) + path = Path(f.name) + + adapter = CamsWbr2cAdapter() + raw = adapter.parse(path) + assert "USRTRXNO" in raw.columns + assert raw.iloc[0]["USRTRXNO"] == "TXN001" diff --git a/tests/unit/test_composite_key.py b/tests/unit/test_composite_key.py index fe4ea5d..c4f74f0 100644 --- a/tests/unit/test_composite_key.py +++ b/tests/unit/test_composite_key.py @@ -1,8 +1,9 @@ -from datetime import date +from datetime import date, datetime import pandas as pd from openreversefeed.core.composite_key import ( + _date_str, assign_composite_keys, build_cams_key, build_kfintech_key, @@ -66,3 +67,84 @@ def test_assign_keys_writes_column_for_kfintech(): ) out = assign_composite_keys(df, Registrar.KFINTECH) assert out["composite_key"].iloc[0] == "1227_0_91046479506_20200708" + + +# --- _date_str regression tests for real registrar date formats --- + + +class TestDateStrFormats: + """All registrar date representations must normalize to YYYYMMDD.""" + + def test_python_date_object(self): + assert _date_str(date(2026, 8, 28)) == "20260828" + + def test_python_datetime_object(self): + assert _date_str(datetime(2026, 8, 28, 14, 30)) == "20260828" + + def test_iso_date_string(self): + assert _date_str("2026-08-28") == "20260828" + + def test_dd_mm_yyyy_slash(self): + assert _date_str("28/08/2026") == "20260828" + + def test_dd_mm_yyyy_dash(self): + assert _date_str("28-08-2026") == "20260828" + + def test_us_date_with_ampm(self): + assert _date_str("8/28/2026 12:00:00 AM") == "20260828" + + def test_us_date_with_ampm_single_digit(self): + assert _date_str("1/5/2026 12:00:00 AM") == "20260105" + + def test_js_date_tostring(self): + assert _date_str("Fri Aug 28 2026 00:00:00 GMT+0530 (India Standard Time)") == "20260828" + + def test_day_gt_12_unambiguous_slash(self): + """Day > 12 forces DD/MM interpretation.""" + assert _date_str("28/08/2026") == "20260828" + + def test_ambiguous_slash_defaults_to_dd_mm(self): + """When both parts <= 12 and no AM/PM, default to DD/MM (Indian convention).""" + assert _date_str("1/2/2026") == "20260201" + + def test_single_digit_day_month_dd_mm(self): + assert _date_str("5/1/2026") == "20260105" + + def test_already_normalized_yyyymmdd(self): + assert _date_str("20260828") == "20260828" + + def test_cams_key_with_string_date(self): + """Composite key builder works with string dates from feeds.""" + row = { + "original_trans_number": "462400251803", + "transaction_type": "P0818S", + "transaction_number": "1648940947", + "transaction_date": "28/08/2026", + } + assert build_cams_key(row) == "462400251803_P0818S_1648940947_20260828" + + def test_kfintech_key_with_string_date(self): + row = { + "transaction_number": "1227", + "parent_transaction_number": "0", + "folio_number": "91046479506", + "transaction_date": "8/28/2026 12:00:00 AM", + } + assert build_kfintech_key(row) == "1227_0_91046479506_20260828" + + def test_all_formats_produce_same_key(self): + """Multiple date representations for the same calendar day produce identical keys.""" + base = { + "original_trans_number": "TXN001", + "transaction_type": "P", + "transaction_number": "999", + } + date_variants = [ + date(2026, 8, 28), + "28/08/2026", + "8/28/2026 12:00:00 AM", + "28-08-2026", + "2026-08-28", + ] + keys = {build_cams_key({**base, "transaction_date": d}) for d in date_variants} + assert len(keys) == 1, f"Expected 1 unique key, got {keys}" diff --git a/tests/unit/test_parse_utils.py b/tests/unit/test_parse_utils.py new file mode 100644 index 0000000..306bcaf --- /dev/null +++ b/tests/unit/test_parse_utils.py @@ -0,0 +1,132 @@ +"""Tests for parse_utils — quote normalization, header peeking, numeric coercion.""" + +from __future__ import annotations + +import math +import tempfile +from pathlib import Path + +from openreversefeed.adapters.parse_utils import ( + has_quoted_headers, + normalize_quoted_csv, + peek_feed_headers, + to_number, +) + + +def _write_csv(content: str) -> Path: + """Write CSV content to a temp file and return its path.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f: + f.write(content) + return Path(f.name) + + +# --- normalize_quoted_csv --- + + +class TestNormalizeQuotedCsv: + def test_strips_quoted_headers(self): + path = _write_csv("'USRTRXNO','FOLIO','AMOUNT'\n'12345','67890','100.000'\n") + df = normalize_quoted_csv(path) + assert list(df.columns) == ["USRTRXNO", "FOLIO", "AMOUNT"] + + def test_strips_quoted_values(self): + path = _write_csv("'USRTRXNO','FOLIO','AMOUNT'\n'12345','67890','100.000'\n") + df = normalize_quoted_csv(path) + assert df.iloc[0]["USRTRXNO"] == "12345" + assert df.iloc[0]["FOLIO"] == "67890" + assert df.iloc[0]["AMOUNT"] == "100.000" + + def test_ordinary_csv_unchanged(self): + path = _write_csv("USRTRXNO,FOLIO,AMOUNT\n12345,67890,100.000\n") + df = normalize_quoted_csv(path) + assert list(df.columns) == ["USRTRXNO", "FOLIO", "AMOUNT"] + assert df.iloc[0]["USRTRXNO"] == "12345" + + def test_internal_apostrophe_preserved(self): + path = _write_csv("NAME,VALUE\nInvestor's Fund,100\n") + df = normalize_quoted_csv(path) + assert df.iloc[0]["NAME"] == "Investor's Fund" + + def test_mixed_quoted_and_unquoted(self): + path = _write_csv("'USRTRXNO',FOLIO,'AMOUNT'\n'12345',67890,'100.000'\n") + df = normalize_quoted_csv(path) + assert list(df.columns) == ["USRTRXNO", "FOLIO", "AMOUNT"] + assert df.iloc[0]["USRTRXNO"] == "12345" + assert df.iloc[0]["FOLIO"] == "67890" + + +# --- peek_feed_headers --- + + +class TestPeekFeedHeaders: + def test_returns_normalized_headers(self): + path = _write_csv("'USRTRXNO','FOLIO','AMOUNT'\n'12345','67890','100'\n") + headers = peek_feed_headers(path) + assert headers == {"USRTRXNO", "FOLIO", "AMOUNT"} + + def test_returns_unquoted_headers(self): + path = _write_csv("USRTRXNO,FOLIO,AMOUNT\n12345,67890,100\n") + headers = peek_feed_headers(path) + assert headers == {"USRTRXNO", "FOLIO", "AMOUNT"} + + def test_empty_file_returns_empty_set(self): + path = _write_csv("") + headers = peek_feed_headers(path) + assert headers == set() + + +# --- has_quoted_headers --- + + +class TestHasQuotedHeaders: + def test_detects_quoted(self): + path = _write_csv("'USRTRXNO','FOLIO','AMOUNT'\n") + assert has_quoted_headers(path) is True + + def test_detects_unquoted(self): + path = _write_csv("USRTRXNO,FOLIO,AMOUNT\n") + assert has_quoted_headers(path) is False + + +# --- to_number --- + + +class TestToNumber: + def test_indian_comma_format(self): + assert to_number("1,00,000.50") == 100000.50 + + def test_western_comma_format(self): + assert to_number("100,000.50") == 100000.50 + + def test_plain_decimal(self): + assert to_number("100.50") == 100.50 + + def test_integer_string(self): + assert to_number("100000") == 100000.0 + + def test_float_passthrough(self): + assert to_number(100.50) == 100.50 + + def test_int_passthrough(self): + assert to_number(100) == 100.0 + + def test_empty_string_returns_nan(self): + assert math.isnan(to_number("")) + + def test_none_returns_nan(self): + assert math.isnan(to_number(None)) + + def test_whitespace_only_returns_nan(self): + assert math.isnan(to_number(" ")) + + def test_dataframe_map_with_indian_commas(self): + """to_number works as a pandas .map() target for mixed-format columns.""" + import pandas as pd + + col = pd.Series(["1,00,000.50", "5000", "12,345.00", ""]) + result = col.map(to_number) + assert result.iloc[0] == 100000.50 + assert result.iloc[1] == 5000.0 + assert result.iloc[2] == 12345.00 + assert math.isnan(result.iloc[3])