Source code for kaxanuk.attribution_analysis.input_handlers.input_handlers_helpers

"""
Helper functions for input handlers.

This module provides utility functions used by various input handlers
(CSV, Parquet, Excel) for common data processing operations including:

- Column name mapping and transformation
- Data quality validation
- Date range filtering
- Decimal precision inference
- Date parsing with ISO format support

All functions are designed to work with PyArrow tables and arrays for
efficient columnar data processing.
"""

import datetime
import logging
from typing import Literal

import pyarrow as pa
import pyarrow.compute as pc

from kaxanuk.attribution_analysis.entities.configuration import Configuration
from kaxanuk.attribution_analysis.entities.backtest_results import DecimalPrecisionScale
from kaxanuk.attribution_analysis.exceptions import DataIntegrityError, DateParsingError

logger = logging.getLogger(__name__)

# Maximum precision for decimal types (PyArrow decimal256 limit)
MAX_PRECISION = 76

[docs] def apply_column_mapping( table: pa.Table, equivalence_dict: dict[str, str] ) -> pa.Table: """ Apply column name mapping to a PyArrow table using an equivalence dictionary. This function transforms column names from source format to standardized format by applying the reverse mapping of the provided equivalence dictionary. If no equivalence dictionary is provided, the original table is returned unchanged. Parameters ---------- table : pa.Table The PyArrow table whose column names need to be mapped. equivalence_dict : dict[str, str] Dictionary mapping standard column names to source column names. The function will reverse this mapping internally. Returns ------- pa.Table A new PyArrow table with mapped column names. Columns not found in the mapping retain their original names. Examples -------- >>> import pyarrow as pa >>> table = pa.table({'old_name': [1, 2, 3], 'other_col': [4, 5, 6]}) >>> equiv_dict = {'new_name': 'old_name'} >>> result = apply_column_mapping(table, equiv_dict) >>> result.column_names ['new_name', 'other_col'] """ if not equivalence_dict: return table column_mapping = get_reverse_equivalence_dict( equivalence_dict=equivalence_dict) new_column_names = [ column_mapping.get(col_name, col_name) for col_name in table.column_names ] return pa.table(table.columns, names=new_column_names)
[docs] def check_for_empty_values( col: pa.ChunkedArray, column_name: str, ticker: str ) -> None: """ Validate a PyArrow column for data quality issues including empty and null values. This function performs comprehensive data quality checks on a column, identifying empty strings, null values, and whitespace-only entries. If any issues are found, a detailed ValueError is raised with specific row indices and error descriptions. Parameters ---------- col : pa.ChunkedArray The PyArrow chunked array column to validate. column_name : str The name of the column being validated, used in error messages. ticker : str The ticker symbol associated with the data, used in error messages for better context and debugging. Returns ------- None This function doesn't return a value but raises an exception if issues are found. Raises ------ ValueError If any data quality issues are detected, including: - Empty strings ('') - Null/NaN values - Whitespace-only strings (e.g., ' ') The exception message includes the ticker, column name, and specific row indices where issues were found. Examples -------- >>> import pyarrow as pa >>> col = pa.chunked_array([['valid', '', None, ' ']]) >>> check_for_empty_values(col, 'price', 'AAPL') ValueError: Data quality issues in ticker 'AAPL', column 'price': Empty strings at rows: [1] Null values at rows: [2] Whitespace-only at rows: [3] """ arr = col.combine_chunks() null_mask = pc.is_null(arr) null_indices = pc.indices_nonzero(null_mask).to_pylist() valid_arr = pc.if_else(null_mask, pa.scalar("__NULL__"), arr) empty_mask = pc.equal(valid_arr, "") empty_indices = pc.indices_nonzero(empty_mask).to_pylist() stripped = pc.utf8_trim_whitespace(valid_arr) whitespace_mask = pc.and_(pc.equal(stripped, ""), pc.invert(empty_mask)) whitespace_mask = pc.and_(whitespace_mask, pc.invert(null_mask)) whitespace_indices = pc.indices_nonzero(whitespace_mask).to_pylist() error_details = [] if empty_indices: error_details.append(f"Empty strings at rows: {empty_indices}") if null_indices: error_details.append(f"Null values at rows: {null_indices}") if whitespace_indices: error_details.append(f"Whitespace-only at rows: {whitespace_indices}") if error_details: raise DataIntegrityError( f"Data quality issues in ticker '{ticker}', column '{column_name}':\n" + "\n".join(error_details) )
[docs] def filter_by_date_range( table: pa.Table, start_date: datetime.date | Literal["auto"] | None, end_date: datetime.date | Literal["auto"] | None ) -> pa.Table: """ Filter a PyArrow table to include only rows within the specified date range. This function filters the input table based on a date column named 'date', keeping only rows where the date falls within the specified range (inclusive). If both start_date and end_date are None, the original table is returned unchanged. Parameters ---------- table : pa.Table The PyArrow table to filter. Must contain a column named 'date'. start_date : datetime.date or {"auto"} or None The earliest date to include in the filtered result. Can be None or "auto" to indicate no lower bound. Should be compatible with pa.date32() type. end_date : datetime.date or {"auto"} or None The latest date to include in the filtered result. Can be None or "auto" to indicate no upper bound. Should be compatible with pa.date32() type. Returns ------- pa.Table A new PyArrow table containing only rows where the date column falls within the specified range (inclusive of both bounds). Notes ----- - The table must have a column named 'date' - Date comparisons are inclusive (>= start_date and <= end_date) - If only one bound is specified, filtering is applied only for that bound - Dates are converted to pa.date32() format for comparison Examples -------- >>> import pyarrow as pa >>> from datetime import date >>> table = pa.table({ ... 'date': [date(2023, 1, 1), date(2023, 6, 15), date(2023, 12, 31)], ... 'value': [100, 200, 300] ... }) >>> filtered = filter_by_date_range(table, date(2023, 6, 1), date(2023, 12, 1)) >>> len(filtered) 1 # Only the June 15 row remains """ if start_date == "auto": start_date = None if end_date == "auto": end_date = None if start_date is None and end_date is None: return table from kaxanuk.attribution_analysis.market_data_pipeline.field_definitions import StandardField # noqa: PLC0415 date_col = table[StandardField.DATE.value] filters = [] if start_date is not None: filters.append(pc.greater_equal(date_col, pa.scalar(start_date, type=pa.date32()))) if end_date is not None: filters.append(pc.less_equal(date_col, pa.scalar(end_date, type=pa.date32()))) combined_filter = filters[0] if len(filters) == 1 else pc.and_(*filters) return pc.filter(table, combined_filter)
[docs] def get_reverse_equivalence_dict( equivalence_dict: dict[str, str] ) -> dict[str, str]: """ Create a reverse mapping dictionary by swapping keys and values. This function takes an equivalence dictionary that maps standard column names to source column names and returns a new dictionary with the mapping reversed, allowing lookup from source names to standard names. Parameters ---------- equivalence_dict : dict[str, str] A dictionary mapping standard column names (keys) to source column names (values). Returns ------- dict[str, str] A new dictionary with reversed key-value pairs, mapping source column names (keys) to standard column names (values). Examples -------- >>> equiv_dict = {'standard_name': 'source_name', 'price': 'Price_USD'} >>> reversed_dict = get_reverse_equivalence_dict(equiv_dict) >>> reversed_dict {'source_name': 'standard_name', 'Price_USD': 'price'} Notes ----- This function assumes that all values in the input dictionary are unique. If duplicate values exist, only the last key-value pair will be preserved in the reversed dictionary. """ return {csv_col: standard_col for standard_col, csv_col in equivalence_dict.items()}
[docs] def get_decimal_type( precision: int, scale: int, precision_limit: int ) -> pa.DataType: """ Return the appropriate PyArrow decimal type for the given precision. Select ``decimal128`` when *precision* fits within *precision_limit*, otherwise fall back to ``decimal256``. Parameters ---------- precision : int Total number of significant digits. scale : int Number of digits after the decimal point. precision_limit : int Upper bound for ``decimal128``; values above this threshold use ``decimal256``. Returns ------- pa.DataType Either ``pa.decimal128(precision, scale)`` or ``pa.decimal256(precision, scale)``. """ if precision > precision_limit: return pa.decimal256(precision, scale) return pa.decimal128(precision, scale)
[docs] def infer_decimal_precision_and_scale( col: pa.ChunkedArray ) -> DecimalPrecisionScale: """ Infer the optimal precision and scale for decimal representation of numeric data. This function analyzes a PyArrow column containing numeric data (as strings) to determine the appropriate precision (total number of digits) and scale (number of decimal places) for decimal type conversion. It handles edge cases such as maximum precision limits and ensures valid decimal type constraints. Parameters ---------- col : pa.ChunkedArray A PyArrow chunked array containing numeric data as strings (e.g., "123.45", "67.890"). The function expects string representation of numbers with optional decimal points. Returns ------- DecimalPrecisionScale A result object containing: - precision: Total number of significant digits (1 to MAX_PRECISION) - scale: Number of digits after the decimal point (0 to precision) Notes ----- - MAX_PRECISION constant defines the maximum allowed precision (76) - If inferred precision exceeds MAX_PRECISION, scale is reduced accordingly - Minimum precision is 1, and scale cannot exceed precision - Numbers without decimal points are treated as having scale 0 - The function uses PyArrow compute functions for efficient processing Examples -------- >>> import pyarrow as pa >>> col = pa.chunked_array([["123.45", "67.890", "1000"]]) >>> result = infer_decimal_precision_and_scale(col) >>> result.precision, result.scale (6, 3) # 6 total digits, 3 decimal places (from "67.890") >>> # Using result to create decimal type >>> decimal_type = pa.decimal128(result.precision, result.scale) Raises ------ pyarrow.ArrowInvalid If PyArrow compute operations fail on invalid input data. """ dot_index = pc.find_substring(col, pattern=".") total_len = pc.binary_length(col) zero_i32 = pa.scalar(0, type=pa.int32()) integer_digits_arr = pc.if_else(pc.equal(dot_index, -1), total_len, dot_index) decimal_digits_arr = pc.if_else( pc.equal(dot_index, -1), zero_i32, pc.subtract(total_len, pc.add(dot_index, 1)) ) max_integers = pc.max(integer_digits_arr).as_py() or 0 max_decimals = pc.max(decimal_digits_arr).as_py() or 0 precision = max_integers + max_decimals scale = max_decimals if precision > MAX_PRECISION: scale = max(0, scale - (precision - MAX_PRECISION)) precision = MAX_PRECISION precision = max(precision, 1, scale) return DecimalPrecisionScale(precision=precision, scale=scale)
[docs] def parse_date_column( date_col: pa.ChunkedArray ) -> pa.ChunkedArray: """ Parse a date column with strict ISO format validation and comprehensive error handling. This function attempts to parse date strings using only ISO-compliant formats: YYYY-MM-DD and YYYY/MM/DD. It employs multiple parsing strategies and provides detailed error messages with sample data when parsing fails, making it easier to diagnose data format issues. Parameters ---------- date_col : pa.ChunkedArray A PyArrow chunked array containing date strings to be parsed. Expected formats are ISO-compliant date strings. Returns ------- pa.ChunkedArray A PyArrow chunked array with dates converted to pa.date32() type, suitable for date-based operations and filtering. Raises ------ DateParsingError If the date column cannot be parsed using any of the supported ISO formats. The exception includes: - Data type information - Array length - Sample values from the column - List of supported formats - Specific parsing error details Notes ----- - Only ISO date formats are accepted for data consistency: - YYYY-MM-DD (ISO format with hyphens, e.g., "2023-12-25") - YYYY/MM/DD (ISO format with slashes, e.g., "2023/12/25") - The function logs debug information during parsing attempts - Error messages include sample data to help identify format issues - Parsed dates are returned as pa.date32() type for memory efficiency Examples -------- >>> import pyarrow as pa >>> date_col = pa.chunked_array([["2023-01-15", "2023/06/30", "2023-12-25"]]) >>> parsed_dates = parse_date_column(date_col) >>> parsed_dates.type date32[day] """ date_array = date_col.combine_chunks() parsing_strategies = [ ('%Y-%m-%d', 'YYYY-MM-DD (ISO format)'), ('%Y/%m/%d', 'YYYY/MM/DD (ISO format with slashes)') ] for date_format, description in parsing_strategies: try: logger.debug("Attempting to parse dates using format: %s", description) parsed_timestamps = pc.strptime(date_array, format=date_format, unit='s') return pc.cast(parsed_timestamps, pa.date32()) except (pa.ArrowInvalid, pa.ArrowTypeError, pa.ArrowNotImplementedError) as parse_error: logger.debug("Failed to parse with %s: %s", description, parse_error) # If none of the ISO formats work, raise error with useful information first_value = date_array[0].as_py() if len(date_array) > 0 else "No data available" data_type = str(date_array.type) array_length = len(date_array) try: unique_sample = pc.unique(date_array) sample_values = [ str(unique_sample[i].as_py()) for i in range(min(5, len(unique_sample))) if unique_sample[i].is_valid ] except (pa.ArrowInvalid, pa.ArrowTypeError, pa.ArrowNotImplementedError): sample_values = [str(first_value)] supported_formats = [desc for _, desc in parsing_strategies] error_message = ( f"Unable to parse date column. Only ISO formats are accepted. " f"Data type: {data_type}, Array length: {array_length}, " f"Sample values: {sample_values}, Supported formats: {supported_formats}" ) logger.error(error_message) raise DateParsingError(error_message)
[docs] def process_table_columns( *, table: pa.Table, ticker: str, configuration: Configuration, equivalence_dict: dict[str, str], ) -> pa.Table: """ Process raw table columns into standardized, typed, and date-filtered output. Consolidate the common processing pipeline shared by CSV, Parquet, and other input handlers: parse date columns, validate and cast numeric columns to decimal types, rename to standard field names, and filter by date range. Parameters ---------- table : pa.Table Raw input table with columns as strings. ticker : str Ticker symbol, used in validation error messages. configuration : Configuration Configuration containing ``start_date`` and ``end_date`` for date range filtering. equivalence_dict : dict[str, str] Mapping from standard field names to the actual column names present in *table*. Returns ------- pa.Table Processed table with standard column names, typed columns (dates as ``date32``, numerics as ``decimal256``), and rows filtered to the configured date range. Raises ------ DateParsingError If the date column cannot be parsed. ValueError If data quality checks fail on any column. """ from kaxanuk.attribution_analysis.market_data_pipeline.field_definitions import ( # noqa: PLC0415 StandardField, STANDARD_FIELDS_ORDER, ) # Process each column processed_columns: dict[str, pa.Array] = {} for std_name in STANDARD_FIELDS_ORDER: csv_col = equivalence_dict[std_name] col = table[csv_col] if std_name == StandardField.DATE.value: processed_columns[csv_col] = parse_date_column(date_col=col) else: check_for_empty_values(col=col, column_name=csv_col, ticker=ticker) decimal_spec = infer_decimal_precision_and_scale(col=col) processed_columns[csv_col] = pc.cast( col, pa.decimal256(decimal_spec.precision, decimal_spec.scale) ) # Build final table with standard names new_columns = [] final_column_names = [] for std_name in STANDARD_FIELDS_ORDER: csv_col = equivalence_dict[std_name] new_columns.append(processed_columns[csv_col]) final_column_names.append(std_name) processed_table = pa.table(new_columns, names=final_column_names) # Filter by date range return filter_by_date_range( table=processed_table, start_date=configuration.start_date, end_date=configuration.end_date, )