diff --git a/src/methods/timeseries/alltypes/calendar.js b/src/methods/timeseries/alltypes/calendar.js new file mode 100644 index 0000000..4a7adb4 --- /dev/null +++ b/src/methods/timeseries/alltypes/calendar.js @@ -0,0 +1,264 @@ +/** + * Calendar utility functions for time series data + * @module methods/timeseries/alltypes/calendar + */ + +/** + * Checks if a date is a business day (Monday to Friday) + * + * @param {Date} date - Date to check + * @returns {boolean} - True if date is a business day, false otherwise + */ +function isBusinessDay(date) { + if (!(date instanceof Date) || isNaN(date)) { + return false; + } + + const day = date.getDay(); + // 0 is Sunday, 6 is Saturday + return day !== 0 && day !== 6; +} + +/** + * Gets the next business day after a given date + * + * @param {Date} date - Starting date + * @returns {Date} - Next business day + */ +function nextBusinessDay(date) { + if (!(date instanceof Date) || isNaN(date)) { + return null; + } + + const result = new Date(date); + result.setDate(result.getDate() + 1); + + // Keep adding days until we find a business day + while (!isBusinessDay(result)) { + result.setDate(result.getDate() + 1); + } + + return result; +} + +/** + * Gets the previous business day before a given date + * + * @param {Date} date - Starting date + * @returns {Date} - Previous business day + */ +function previousBusinessDay(date) { + if (!(date instanceof Date) || isNaN(date)) { + return null; + } + + const result = new Date(date); + result.setDate(result.getDate() - 1); + + // Keep subtracting days until we find a business day + while (!isBusinessDay(result)) { + result.setDate(result.getDate() - 1); + } + + return result; +} + +/** + * Gets the end of month date for a given date + * + * @param {Date} date - Input date + * @returns {Date} - Last day of the month + */ +function endOfMonth(date) { + if (!(date instanceof Date) || isNaN(date)) { + return null; + } + + const result = new Date(date); + // Set to first day of next month, then subtract one day + result.setMonth(result.getMonth() + 1, 0); + return result; +} + +/** + * Gets the start of month date for a given date + * + * @param {Date} date - Input date + * @returns {Date} - First day of the month + */ +function startOfMonth(date) { + if (!(date instanceof Date) || isNaN(date)) { + return null; + } + + const result = new Date(date); + result.setDate(1); + return result; +} + +/** + * Gets the end of quarter date for a given date + * + * @param {Date} date - Input date + * @returns {Date} - Last day of the quarter + */ +function endOfQuarter(date) { + if (!(date instanceof Date) || isNaN(date)) { + return null; + } + + const result = new Date(date); + const month = result.getMonth(); + // Determine the last month of the quarter + const lastMonthOfQuarter = Math.floor(month / 3) * 3 + 2; + result.setMonth(lastMonthOfQuarter + 1, 0); // Last day of the last month of the quarter + return result; +} + +/** + * Gets the start of quarter date for a given date + * + * @param {Date} date - Input date + * @returns {Date} - First day of the quarter + */ +function startOfQuarter(date) { + if (!(date instanceof Date) || isNaN(date)) { + return null; + } + + const result = new Date(date); + const month = result.getMonth(); + // Determine the first month of the quarter + const firstMonthOfQuarter = Math.floor(month / 3) * 3; + result.setMonth(firstMonthOfQuarter, 1); // First day of the first month of the quarter + return result; +} + +/** + * Gets the end of year date for a given date + * + * @param {Date} date - Input date + * @returns {Date} - Last day of the year + */ +function endOfYear(date) { + if (!(date instanceof Date) || isNaN(date)) { + return null; + } + + const result = new Date(date); + result.setMonth(11, 31); // December 31 + return result; +} + +/** + * Gets the start of year date for a given date + * + * @param {Date} date - Input date + * @returns {Date} - First day of the year + */ +function startOfYear(date) { + if (!(date instanceof Date) || isNaN(date)) { + return null; + } + + const result = new Date(date); + result.setMonth(0, 1); // January 1 + return result; +} + +/** + * Adds a specified number of business days to a date + * + * @param {Date} date - Starting date + * @param {number} days - Number of business days to add + * @returns {Date} - Resulting date + */ +function addBusinessDays(date, days) { + if (!(date instanceof Date) || isNaN(date) || typeof days !== 'number') { + return null; + } + + const result = new Date(date); + let remainingDays = days; + + while (remainingDays > 0) { + result.setDate(result.getDate() + 1); + if (isBusinessDay(result)) { + remainingDays--; + } + } + + return result; +} + +/** + * Generates a range of dates between start and end dates with a specified frequency + * + * @param {Date} startDate - Start date of the range + * @param {Date} endDate - End date of the range + * @param {string} freq - Frequency ('D' for daily, 'B' for business days, 'W' for weekly, 'M' for monthly, 'Q' for quarterly, 'Y' for yearly) + * @returns {Array} - Array of dates in the range + */ +function dateRange(startDate, endDate, freq = 'D') { + if ( + !(startDate instanceof Date) || + !(endDate instanceof Date) || + isNaN(startDate) || + isNaN(endDate) || + startDate > endDate + ) { + return []; + } + + const result = []; + let currentDate = new Date(startDate); + + // Add start date to result + result.push(new Date(currentDate)); + + while (currentDate < endDate) { + switch (freq) { + case 'D': // Daily + currentDate.setDate(currentDate.getDate() + 1); + break; + case 'B': // Business days + currentDate = nextBusinessDay(currentDate); + break; + case 'W': // Weekly + currentDate.setDate(currentDate.getDate() + 7); + break; + case 'M': // Monthly + currentDate.setMonth(currentDate.getMonth() + 1); + break; + case 'Q': // Quarterly + currentDate.setMonth(currentDate.getMonth() + 3); + break; + case 'Y': // Yearly + currentDate.setFullYear(currentDate.getFullYear() + 1); + break; + default: + currentDate.setDate(currentDate.getDate() + 1); + } + + // Add current date to result if it's not past the end date + if (currentDate <= endDate) { + result.push(new Date(currentDate)); + } + } + + return result; +} + +export { + isBusinessDay, + nextBusinessDay, + previousBusinessDay, + endOfMonth, + startOfMonth, + endOfQuarter, + startOfQuarter, + endOfYear, + startOfYear, + addBusinessDays, + dateRange, +}; diff --git a/src/methods/timeseries/alltypes/index.js b/src/methods/timeseries/alltypes/index.js new file mode 100644 index 0000000..ef50786 --- /dev/null +++ b/src/methods/timeseries/alltypes/index.js @@ -0,0 +1,52 @@ +/** + * Exports all timeseries utility functions for both DataFrame and Series + * @module methods/timeseries/alltypes + */ + +// Import utilities +import { inferFrequency, inferFrequencyFromData } from './inferFrequency.js'; +import { + validateRollingOptions, + applyRollingWindow, + validateExpandingOptions, + applyExpandingWindow, +} from './rollingCore.js'; +import { + isBusinessDay, + nextBusinessDay, + previousBusinessDay, + endOfMonth, + startOfMonth, + endOfQuarter, + startOfQuarter, + endOfYear, + startOfYear, + addBusinessDays, + dateRange, +} from './calendar.js'; + +// Export all utilities +export { + // Frequency inference + inferFrequency, + inferFrequencyFromData, + + // Rolling and expanding window core functions + validateRollingOptions, + applyRollingWindow, + validateExpandingOptions, + applyExpandingWindow, + + // Calendar functions + isBusinessDay, + nextBusinessDay, + previousBusinessDay, + endOfMonth, + startOfMonth, + endOfQuarter, + startOfQuarter, + endOfYear, + startOfYear, + addBusinessDays, + dateRange, +}; diff --git a/src/methods/timeseries/alltypes/inferFrequency.js b/src/methods/timeseries/alltypes/inferFrequency.js new file mode 100644 index 0000000..622f7f3 --- /dev/null +++ b/src/methods/timeseries/alltypes/inferFrequency.js @@ -0,0 +1,121 @@ +/** + * Utility functions for inferring frequency from time series data + * @module methods/timeseries/alltypes/inferFrequency + */ + +/** + * Infers the frequency of a time series from date values + * Works with both DataFrame and Series + * + * @param {Array} dateValues - Array of date values + * @returns {string} - Inferred frequency ('D' for daily, 'W' for weekly, 'M' for monthly, etc.) + */ +function inferFrequency(dateValues) { + if (!dateValues || dateValues.length < 2) { + return null; // Not enough data to infer frequency + } + + // Filter out invalid dates + const validDates = dateValues.filter((d) => d instanceof Date && !isNaN(d)); + if (validDates.length < 2) { + return null; // Not enough valid dates + } + + // Sort dates to ensure correct calculation + const sortedDates = [...validDates].sort((a, b) => a - b); + + // Calculate differences between consecutive dates (in milliseconds) + const diffs = []; + for (let i = 1; i < sortedDates.length; i++) { + diffs.push(sortedDates[i] - sortedDates[i - 1]); + } + + // Find the most common difference + const diffCounts = {}; + let maxCount = 0; + let mostCommonDiff = null; + + diffs.forEach((diff) => { + diffCounts[diff] = (diffCounts[diff] || 0) + 1; + if (diffCounts[diff] > maxCount) { + maxCount = diffCounts[diff]; + mostCommonDiff = diff; + } + }); + + // Convert milliseconds to days + const daysDiff = mostCommonDiff / (1000 * 60 * 60 * 24); + + // Infer frequency based on the most common difference + if (daysDiff < 0.1) { + // Less than 2.4 hours + return 'H'; // Hourly + } else if (daysDiff < 1.5) { + return 'D'; // Daily + } else if (daysDiff >= 1.5 && daysDiff < 10) { + // Check if it's weekly (around 7 days) + if (Math.abs(daysDiff - 7) < 1) { + return 'W'; // Weekly + } + return 'D'; // Daily with gaps + } else if (daysDiff >= 10 && daysDiff < 45) { + return 'M'; // Monthly + } else if (daysDiff >= 45 && daysDiff < 200) { + return 'Q'; // Quarterly + } else { + return 'Y'; // Yearly + } +} + +/** + * Extract date values from a DataFrame or Series + * + * @param {Object} data - DataFrame or Series object + * @param {string} dateColumn - Column name containing dates (for DataFrame) + * @returns {Array} - Array of date values + */ +function extractDateValues(data, dateColumn) { + // Check if data is a DataFrame (has _columns property) + if (data._columns) { + if (!dateColumn) { + // Try to find a date column + const columns = data.columns || Object.keys(data._columns); + dateColumn = columns.find((col) => { + const column = data._columns[col]; + if (!column || !column.get) return false; + const firstValue = column.get(0); + return firstValue instanceof Date; + }); + + if (!dateColumn) { + return []; // No date column found + } + } + + // Extract date values from the DataFrame using col() method + const column = data.col(dateColumn); + if (column && column.toArray) { + return column.toArray(); + } + return []; + } else if (data.toArray && typeof data.toArray === 'function') { + // Check if data is a Series + return data.toArray(); + } + + return []; // Unknown data type +} + +/** + * Infers the frequency of a time series from a DataFrame or Series + * + * @param {Object} data - DataFrame or Series object + * @param {string} dateColumn - Column name containing dates (for DataFrame) + * @returns {string} - Inferred frequency ('D' for daily, 'W' for weekly, 'M' for monthly, etc.) + */ +function inferFrequencyFromData(data, dateColumn) { + const dateValues = extractDateValues(data, dateColumn); + return inferFrequency(dateValues); +} + +export { inferFrequency, inferFrequencyFromData }; diff --git a/src/methods/timeseries/alltypes/rollingCore.js b/src/methods/timeseries/alltypes/rollingCore.js new file mode 100644 index 0000000..dbe6b15 --- /dev/null +++ b/src/methods/timeseries/alltypes/rollingCore.js @@ -0,0 +1,164 @@ +/** + * Core rolling window functionality for both DataFrame and Series + * @module methods/timeseries/alltypes/rollingCore + */ + +/** + * Validates rolling window options + * + * @param {Object} options - Rolling window options + * @param {number} options.window - Window size + * @param {Function} options.aggregation - Aggregation function + * @param {number} [options.minPeriods=null] - Minimum number of observations in window required to have a value + * @returns {Object} - Validated options with defaults applied + * @throws {Error} - If options are invalid + */ +function validateRollingOptions(options) { + if (!options) { + throw new Error('options must be provided'); + } + + const { window, aggregation, minPeriods = null } = options; + + // Validate window + if (typeof window !== 'number' || window <= 0) { + throw new Error('window must be a positive number'); + } + + // Validate aggregation + if (typeof aggregation !== 'function') { + throw new Error('aggregation must be a function'); + } + + // Validate minPeriods + const validatedMinPeriods = minPeriods === null ? window : minPeriods; + if (typeof validatedMinPeriods !== 'number' || validatedMinPeriods <= 0) { + throw new Error('minPeriods must be a positive number'); + } + + return { + window, + aggregation, + minPeriods: validatedMinPeriods, + }; +} + +/** + * Applies a rolling window operation to an array of values + * + * @param {Array} values - Array of values to apply rolling window to + * @param {Object} options - Rolling window options + * @param {number} options.window - Window size + * @param {Function} options.aggregation - Aggregation function + * @param {number} options.minPeriods - Minimum number of observations in window required to have a value + * @returns {Array} - Array of results after applying rolling window + */ +function applyRollingWindow(values, options) { + const { window, aggregation, minPeriods } = validateRollingOptions(options); + + // Create result array with same length as input, filled with null + const result = Array(values.length).fill(null); + + // Apply rolling window + for (let i = 0; i < values.length; i++) { + // Calculate start index of window + const startIdx = Math.max(0, i - window + 1); + // Get window values + const windowValues = values.slice(startIdx, i + 1); + + // Filter out null and NaN values + const validValues = windowValues.filter( + (v) => v !== null && typeof v !== 'undefined' && !Number.isNaN(v), + ); + + // Only calculate if we have enough valid values + if (validValues.length >= minPeriods) { + try { + result[i] = aggregation(validValues); + } catch (error) { + // If aggregation fails, keep as null + result[i] = null; + } + } + } + + return result; +} + +/** + * Validates expanding window options + * + * @param {Object} options - Expanding window options + * @param {Function} options.aggregation - Aggregation function + * @param {number} [options.minPeriods=1] - Minimum number of observations in window required to have a value + * @returns {Object} - Validated options with defaults applied + * @throws {Error} - If options are invalid + */ +function validateExpandingOptions(options) { + if (!options) { + throw new Error('options must be provided'); + } + + const { aggregation, minPeriods = 1 } = options; + + // Validate aggregation + if (typeof aggregation !== 'function') { + throw new Error('aggregation must be a function'); + } + + // Validate minPeriods + if (typeof minPeriods !== 'number' || minPeriods <= 0) { + throw new Error('minPeriods must be a positive number'); + } + + return { + aggregation, + minPeriods, + }; +} + +/** + * Applies an expanding window operation to an array of values + * + * @param {Array} values - Array of values to apply expanding window to + * @param {Object} options - Expanding window options + * @param {Function} options.aggregation - Aggregation function + * @param {number} options.minPeriods - Minimum number of observations in window required to have a value + * @returns {Array} - Array of results after applying expanding window + */ +function applyExpandingWindow(values, options) { + const { aggregation, minPeriods } = validateExpandingOptions(options); + + // Create result array with same length as input, filled with null + const result = Array(values.length).fill(null); + + // Apply expanding window + for (let i = 0; i < values.length; i++) { + // Get all values up to current index + const windowValues = values.slice(0, i + 1); + + // Filter out null and NaN values + const validValues = windowValues.filter( + (v) => v !== null && typeof v !== 'undefined' && !Number.isNaN(v), + ); + + // Only calculate if we have enough valid values + if (validValues.length >= minPeriods) { + try { + result[i] = aggregation(validValues); + } catch (error) { + // If aggregation fails, keep as null + result[i] = null; + } + } + } + + return result; +} + +export { + validateRollingOptions, + applyRollingWindow, + validateExpandingOptions, + applyExpandingWindow, +}; diff --git a/src/methods/timeseries/dataframe/expanding.js b/src/methods/timeseries/dataframe/expanding.js new file mode 100644 index 0000000..54adfb6 --- /dev/null +++ b/src/methods/timeseries/dataframe/expanding.js @@ -0,0 +1,59 @@ +/** + * Apply an expanding window function to DataFrame columns + * + * @param {DataFrame} df - DataFrame to apply expanding window to + * @param {Object} options - Options object + * @param {Object} options.aggregations - Object mapping column names to aggregation functions + * @param {number} [options.minPeriods=1] - Minimum number of observations required + * @returns {DataFrame} - DataFrame with expanding window calculations + */ +export function expanding(options) { + return function (df) { + const { aggregations = {}, minPeriods = 1 } = options || {}; + + // Validate options + if (Object.keys(aggregations).length === 0) { + throw new Error('At least one aggregation must be specified'); + } + + // Create a new object to hold the result columns + const resultColumns = {}; + + // First copy all original columns + for (const colName of df.columns) { + resultColumns[colName] = df.col(colName).toArray(); + } + + // Apply expanding window to each column with aggregation + for (const [colName, aggFunc] of Object.entries(aggregations)) { + if (!df.columns.includes(colName)) { + throw new Error(`Column '${colName}' not found in DataFrame`); + } + + const series = df.col(colName); + const values = series.toArray(); + const result = new Array(values.length).fill(null); + + // Apply expanding window + for (let i = 0; i < values.length; i++) { + // Extract window values (all values from start to current position) + const windowValues = values + .slice(0, i + 1) + .filter((v) => v !== null && v !== undefined && !isNaN(v)); + + // Apply aggregation function if we have enough values + if (windowValues.length >= minPeriods) { + result[i] = aggFunc(windowValues); + } + } + + // Add result to output columns + resultColumns[`${colName}_expanding`] = result; + } + + // Create a new DataFrame with the result columns + return new df.constructor(resultColumns); + }; +} + +export default expanding; diff --git a/src/methods/timeseries/dataframe/register.js b/src/methods/timeseries/dataframe/register.js new file mode 100644 index 0000000..a5f1c0c --- /dev/null +++ b/src/methods/timeseries/dataframe/register.js @@ -0,0 +1,96 @@ +/** + * Registrar for DataFrame time series methods + */ + +/** + * Registers all time series methods for DataFrame + * @param {Class} DataFrame - DataFrame class to extend + */ +export function registerDataFrameTimeSeries(DataFrame) { + /** + * Resamples a DataFrame to a different time frequency + * @param {Object} options - Options object + * @param {string} options.dateColumn - Name of the column containing dates + * @param {string} options.freq - Target frequency ('D' for day, 'W' for week, 'M' for month, 'Q' for quarter, 'Y' for year) + * @param {Object} options.aggregations - Object mapping column names to aggregation functions + * @param {boolean} [options.includeEmpty=false] - Whether to include empty periods + * @returns {DataFrame} - Resampled DataFrame + */ + DataFrame.prototype.resample = function (options) { + // Validate required options + const { dateColumn, freq, aggregations = {} } = options || {}; + + if (!dateColumn) { + throw new Error('dateColumn parameter is required'); + } + + if (!freq) { + throw new Error('freq parameter is required'); + } + + if (!this.columns.includes(dateColumn)) { + throw new Error(`Date column '${dateColumn}' not found in DataFrame`); + } + + if (Object.keys(aggregations).length === 0) { + throw new Error('At least one aggregation must be specified'); + } + + // Import the implementation dynamically to avoid circular dependencies + const resampleModule = require('./resample.js'); + return resampleModule.default(options)(this); + }; + + /** + * Applies a rolling window function to DataFrame columns + * @param {Object} options - Options object + * @param {number} options.window - Window size + * @param {Object} options.aggregations - Object mapping column names to aggregation functions + * @param {boolean} [options.center=false] - Whether to center the window + * @param {number} [options.minPeriods=null] - Minimum number of observations required + * @returns {DataFrame} - DataFrame with rolling window calculations + */ + DataFrame.prototype.rolling = function (options) { + // Import the implementation dynamically to avoid circular dependencies + const rollingModule = require('./rolling.js'); + return rollingModule.default(options)(this); + }; + + /** + * Applies an expanding window function to DataFrame columns + * @param {Object} options - Options object + * @param {Object} options.aggregations - Object mapping column names to aggregation functions + * @param {number} [options.minPeriods=1] - Minimum number of observations required + * @returns {DataFrame} - DataFrame with expanding window calculations + */ + DataFrame.prototype.expanding = function (options) { + // Import the implementation dynamically to avoid circular dependencies + const expandingModule = require('./expanding.js'); + return expandingModule.default(options)(this); + }; + + /** + * Shifts index by desired number of periods + * @param {number} periods - Number of periods to shift (positive for forward, negative for backward) + * @param {*} [fillValue=null] - Value to use for new periods + * @returns {DataFrame} - Shifted DataFrame + */ + DataFrame.prototype.shift = function (periods = 1, fillValue = null) { + // Import the implementation dynamically to avoid circular dependencies + const shiftModule = require('./shift.js'); + return shiftModule.shift(this, periods, fillValue); + }; + + /** + * Calculates percentage change between current and prior element + * @param {number} [periods=1] - Periods to shift for calculating percentage change + * @returns {DataFrame} - DataFrame with percentage changes + */ + DataFrame.prototype.pctChange = function (periods = 1) { + // Import the implementation dynamically to avoid circular dependencies + const pctChangeModule = require('./shift.js'); + return pctChangeModule.pctChange(this, periods); + }; +} + +export default registerDataFrameTimeSeries; diff --git a/src/methods/timeseries/dataframe/resample.js b/src/methods/timeseries/dataframe/resample.js new file mode 100644 index 0000000..16f5950 --- /dev/null +++ b/src/methods/timeseries/dataframe/resample.js @@ -0,0 +1,155 @@ +/** + * Resample a DataFrame to a different time frequency + * + * @param {Object} options - Options object + * @param {string} options.dateColumn - Name of the column containing dates + * @param {string} options.freq - Target frequency ('D' for day, 'W' for week, 'M' for month, 'Q' for quarter, 'Y' for year) + * @param {Object} options.aggregations - Object mapping column names to aggregation functions + * @param {boolean} [options.includeEmpty=false] - Whether to include empty periods + * @returns {Function} - Function that takes a DataFrame and returns a resampled DataFrame + */ +export const resample = (options) => (df) => { + const { + dateColumn, + freq, + aggregations = {}, + includeEmpty = false, + } = options || {}; + + // Validate options + if (!dateColumn || !df.columns.includes(dateColumn)) { + throw new Error(`Date column '${dateColumn}' not found in DataFrame`); + } + + if (!freq) { + throw new Error('freq parameter is required'); + } + + if (Object.keys(aggregations).length === 0) { + throw new Error('At least one aggregation must be specified'); + } + + // Get date column values + const dateValues = df.col(dateColumn).toArray(); + + // Convert dates to Date objects if they are strings + const dates = dateValues.map((d) => (d instanceof Date ? d : new Date(d))); + + // Group data by time periods + const groups = groupByTimePeriod(dates, freq); + + // Create a new object to hold the result columns + const resultColumns = {}; + + // Add date column with period start dates + resultColumns[dateColumn] = Object.keys(groups).map( + (period) => new Date(period), + ); + + // Apply aggregations to each column + for (const [colName, aggFunc] of Object.entries(aggregations)) { + if (!df.columns.includes(colName)) { + throw new Error(`Column '${colName}' not found in DataFrame`); + } + + const colValues = df.col(colName).toArray(); + const aggregatedValues = []; + + // Aggregate values for each period + for (const period of Object.keys(groups)) { + const indices = groups[period]; + const periodValues = indices + .map((i) => colValues[i]) + .filter((v) => v !== null && v !== undefined && !isNaN(v)); + + if (periodValues.length > 0) { + aggregatedValues.push(aggFunc(periodValues)); + } else { + aggregatedValues.push(null); + } + } + + // Add aggregated values to result columns + resultColumns[colName] = aggregatedValues; + } + + // Create a new DataFrame with the result columns + return df.constructor.create(resultColumns); +}; + +/** + * Group dates by time period + * + * @param {Date[]} dates - Array of dates + * @param {string} freq - Frequency ('D', 'W', 'M', 'Q', 'Y') + * @returns {Object} - Object mapping period start dates to arrays of indices + */ +function groupByTimePeriod(dates, freq) { + const groups = {}; + + // Group dates by period + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + if (!(date instanceof Date) || isNaN(date)) { + continue; + } + + const periodStart = getPeriodStart(date, freq); + const periodKey = periodStart.toISOString(); + + if (!groups[periodKey]) { + groups[periodKey] = []; + } + + groups[periodKey].push(i); + } + + return groups; +} + +/** + * Get the start date of a period + * + * @param {Date} date - Date to get period start for + * @param {string} freq - Frequency ('D', 'W', 'M', 'Q', 'Y') + * @returns {Date} - Start date of the period + */ +function getPeriodStart(date, freq) { + const result = new Date(date); + + switch (freq.toUpperCase()) { + case 'D': + // Start of day + result.setHours(0, 0, 0, 0); + break; + case 'W': + // Start of week (Sunday) + const day = result.getDay(); + result.setDate(result.getDate() - day); + result.setHours(0, 0, 0, 0); + break; + case 'M': + // Start of month + result.setDate(1); + result.setHours(0, 0, 0, 0); + break; + case 'Q': + // Start of quarter + const month = result.getMonth(); + const quarterMonth = Math.floor(month / 3) * 3; + result.setMonth(quarterMonth, 1); + result.setHours(0, 0, 0, 0); + break; + case 'Y': + // Start of year + result.setMonth(0, 1); + result.setHours(0, 0, 0, 0); + break; + default: + throw new Error(`Unsupported frequency: ${freq}`); + } + + return result; +} + +export default resample; diff --git a/src/methods/timeseries/dataframe/rolling.js b/src/methods/timeseries/dataframe/rolling.js new file mode 100644 index 0000000..dda283a --- /dev/null +++ b/src/methods/timeseries/dataframe/rolling.js @@ -0,0 +1,94 @@ +/** + * Apply a rolling window function to DataFrame columns + * + * @param {Object} options - Options object + * @param {number} options.window - Window size + * @param {Object} options.aggregations - Object mapping column names to aggregation functions + * @param {boolean} [options.center=false] - Whether to center the window + * @param {number} [options.minPeriods=null] - Minimum number of observations required + * @returns {Function} - Function that takes a DataFrame and returns a DataFrame with rolling window calculations + */ +export const rolling = (options) => (df) => { + const { + window, + aggregations = {}, + center = false, + minPeriods = null, + } = options || {}; + + // Validate options + if (!window || typeof window !== 'number' || window <= 0) { + throw new Error('window must be a positive number'); + } + + if (Object.keys(aggregations).length === 0) { + throw new Error('At least one aggregation must be specified'); + } + + // Create a new object to hold the result columns + const resultColumns = {}; + + // Copy all original columns + for (const colName of df.columns) { + resultColumns[colName] = df.col(colName).toArray(); + } + + // Apply rolling window to each column with aggregation + for (const [colName, aggFunc] of Object.entries(aggregations)) { + if (!df.columns.includes(colName)) { + throw new Error(`Column '${colName}' not found in DataFrame`); + } + + const series = df.col(colName); + const values = series.toArray(); + const result = new Array(values.length).fill(null); + + // Calculate effective min periods + const effectiveMinPeriods = + minPeriods === null ? window : Math.min(minPeriods, window); + + // Apply rolling window + for (let i = 0; i < values.length; i++) { + // Calculate window bounds + let start, end; + + if (center) { + // Center the window + start = Math.max(0, i - Math.floor(window / 2)); + end = Math.min(values.length, i + Math.ceil(window / 2)); + } else { + // Right-aligned window + start = Math.max(0, i - window + 1); + end = i + 1; + } + + // Skip if not enough observations + if (end - start < effectiveMinPeriods) { + continue; + } + + // Extract window values + const windowValues = values + .slice(start, end) + .filter((v) => v !== null && v !== undefined && !Number.isNaN(v)); + + // Apply aggregation function + if (windowValues.length >= effectiveMinPeriods) { + try { + result[i] = aggFunc(windowValues); + } catch (e) { + console.error('Error applying aggregation function:', e); + result[i] = null; + } + } + } + + // Add result to output columns + resultColumns[`${colName}_rolling`] = result; + } + + // Create a new DataFrame with the result columns + return df.constructor.create(resultColumns); +}; + +export default rolling; diff --git a/src/methods/timeseries/dataframe/shift.js b/src/methods/timeseries/dataframe/shift.js new file mode 100644 index 0000000..386d5fe --- /dev/null +++ b/src/methods/timeseries/dataframe/shift.js @@ -0,0 +1,88 @@ +/** + * Shift values in a DataFrame by a specified number of periods + * + * @param {DataFrame} df - DataFrame to shift + * @param {number} periods - Number of periods to shift (positive for forward, negative for backward) + * @param {*} fillValue - Value to use for new periods + * @returns {DataFrame} - Shifted DataFrame + */ +export function shift(df, periods = 1, fillValue = null) { + // Create a new object to hold the shifted columns + const shiftedColumns = {}; + + // Shift each column + for (const colName of df.columns) { + const values = df.col(colName).toArray(); + const result = new Array(values.length).fill(fillValue); + + if (periods > 0) { + // Shift forward (positive periods) + for (let i = 0; i < values.length - periods; i++) { + result[i + periods] = values[i]; + } + } else if (periods < 0) { + // Shift backward (negative periods) + const absShift = Math.abs(periods); + for (let i = absShift; i < values.length; i++) { + result[i - absShift] = values[i]; + } + } else { + // No shift (periods = 0) + for (let i = 0; i < values.length; i++) { + result[i] = values[i]; + } + } + + shiftedColumns[colName] = result; + } + + // Create a new DataFrame with the shifted columns + return new df.constructor(shiftedColumns); +} + +/** + * Calculate percentage change between current and prior element + * + * @param {DataFrame} df - DataFrame to calculate percentage change + * @param {number} periods - Periods to shift for calculating percentage change + * @returns {DataFrame} - DataFrame with percentage changes + */ +export function pctChange(df, periods = 1) { + // Create a new object to hold the percentage change columns + const pctChangeColumns = {}; + + // Calculate percentage change for each column + for (const colName of df.columns) { + // Manual calculation: (current - previous) / previous + const values = df.col(colName).toArray(); + const result = new Array(values.length).fill(null); + + for (let i = periods; i < values.length; i++) { + const current = values[i]; + const previous = values[i - periods]; + + // Skip if either value is not a number + if ( + typeof current !== 'number' || + typeof previous !== 'number' || + Number.isNaN(current) || + Number.isNaN(previous) || + previous === 0 + ) { + continue; + } + + result[i] = (current - previous) / previous; + } + + pctChangeColumns[colName] = result; + } + + // Create a new DataFrame with the percentage change columns + return new df.constructor(pctChangeColumns); +} + +export default { + shift, + pctChange, +}; diff --git a/src/methods/timeseries/index.js b/src/methods/timeseries/index.js new file mode 100644 index 0000000..6fffa0a --- /dev/null +++ b/src/methods/timeseries/index.js @@ -0,0 +1,19 @@ +/** + * Timeseries methods for DataFrame and Series + * @module methods/timeseries + */ + +// Import registrars +import registerDataFrameTimeSeries from './dataframe/register.js'; +import registerSeriesTimeSeries from './series/register.js'; + +// Import DataFrame and Series classes +import { DataFrame } from '../../core/dataframe/DataFrame.js'; +import { Series } from '../../core/dataframe/Series.js'; + +// Register methods +registerDataFrameTimeSeries(DataFrame); +registerSeriesTimeSeries(Series); + +// Export nothing as methods are attached to DataFrame and Series prototypes +export {}; diff --git a/src/methods/timeseries/series/expanding.js b/src/methods/timeseries/series/expanding.js new file mode 100644 index 0000000..52db1b3 --- /dev/null +++ b/src/methods/timeseries/series/expanding.js @@ -0,0 +1,42 @@ +/** + * Apply an expanding window function to Series values + * + * @param {Series} series - Series to apply expanding window to + * @param {Object} options - Options object + * @param {Function} options.aggregation - Aggregation function to apply + * @param {number} [options.minPeriods=1] - Minimum number of observations required + * @returns {Series} - Series with expanding window calculations + */ +export function expanding(options) { + return function (series) { + const { aggregation, minPeriods = 1 } = options || {}; + + // Validate options + if (!aggregation || typeof aggregation !== 'function') { + throw new Error('aggregation must be a function'); + } + + const values = series.toArray(); + const result = new Array(values.length).fill(null); + + // Apply expanding window + for (let i = 0; i < values.length; i++) { + // Extract window values (all values from start to current position) + const windowValues = values + .slice(0, i + 1) + .filter((v) => v !== null && v !== undefined && !isNaN(v)); + + // Apply aggregation function if we have enough values + if (windowValues.length >= minPeriods) { + result[i] = aggregation(windowValues); + } + } + + // Create a new Series with the result + return new series.constructor(result, { + name: `${series.name}_expanding`, + }); + }; +} + +export default expanding; diff --git a/src/methods/timeseries/series/register.js b/src/methods/timeseries/series/register.js new file mode 100644 index 0000000..6979ec9 --- /dev/null +++ b/src/methods/timeseries/series/register.js @@ -0,0 +1,61 @@ +/** + * Registrar for Series time series methods + */ + +/** + * Registers all time series methods for Series + * @param {Class} Series - Series class to extend + */ +export function registerSeriesTimeSeries(Series) { + /** + * Apply a rolling window function to Series values + * @param {Object} options - Options object + * @param {number} options.window - Size of the rolling window + * @param {Function} options.aggregation - Aggregation function to apply + * @param {number} [options.minPeriods=null] - Minimum number of observations required + * @returns {Series} - Series with rolling window calculations + */ + Series.prototype.rolling = function (options) { + // Import the implementation dynamically to avoid circular dependencies + const rollingModule = require('./rolling.js'); + return rollingModule.default(options)(this); + }; + + /** + * Apply an expanding window function to Series values + * @param {Object} options - Options object + * @param {Function} options.aggregation - Aggregation function to apply + * @param {number} [options.minPeriods=1] - Minimum number of observations required + * @returns {Series} - Series with expanding window calculations + */ + Series.prototype.expanding = function (options) { + // Import the implementation dynamically to avoid circular dependencies + const expandingModule = require('./expanding.js'); + return expandingModule.default(options)(this); + }; + + /** + * Shift values in a Series by a specified number of periods + * @param {number} [periods=1] - Number of periods to shift (positive for forward, negative for backward) + * @param {*} [fillValue=null] - Value to use for new periods + * @returns {Series} - Shifted Series + */ + Series.prototype.shift = function (periods = 1, fillValue = null) { + // Import the implementation dynamically to avoid circular dependencies + const shiftModule = require('./shift.js'); + return shiftModule.shift(this, periods, fillValue); + }; + + /** + * Calculate percentage change between current and prior element + * @param {number} [periods=1] - Periods to shift for calculating percentage change + * @returns {Series} - Series with percentage changes + */ + Series.prototype.pctChange = function (periods = 1) { + // Import the implementation dynamically to avoid circular dependencies + const pctChangeModule = require('./shift.js'); + return pctChangeModule.pctChange(this, periods); + }; +} + +export default registerSeriesTimeSeries; diff --git a/src/methods/timeseries/series/rolling.js b/src/methods/timeseries/series/rolling.js new file mode 100644 index 0000000..2351126 --- /dev/null +++ b/src/methods/timeseries/series/rolling.js @@ -0,0 +1,49 @@ +/** + * Apply a rolling window function to Series values + * + * @param {Series} series - Series to apply rolling window to + * @param {Object} options - Options object + * @param {number} options.window - Size of the rolling window + * @param {Function} options.aggregation - Aggregation function to apply + * @param {number} [options.minPeriods=null] - Minimum number of observations required + * @returns {Series} - Series with rolling window calculations + */ +export function rolling(options) { + return function (series) { + const { window, aggregation, minPeriods = null } = options || {}; + + // Validate options + if (!window || typeof window !== 'number' || window <= 0) { + throw new Error('window must be a positive number'); + } + + if (!aggregation || typeof aggregation !== 'function') { + throw new Error('aggregation must be a function'); + } + + const effectiveMinPeriods = minPeriods === null ? window : minPeriods; + const values = series.toArray(); + const result = new Array(values.length).fill(null); + + // Apply rolling window + for (let i = 0; i < values.length; i++) { + // Extract window values (window size elements ending at current position) + const start = Math.max(0, i - window + 1); + const windowValues = values + .slice(start, i + 1) + .filter((v) => v !== null && v !== undefined && !isNaN(v)); + + // Apply aggregation function if we have enough values + if (windowValues.length >= effectiveMinPeriods) { + result[i] = aggregation(windowValues); + } + } + + // Create a new Series with the result + return new series.constructor(result, { + name: `${series.name}_rolling`, + }); + }; +} + +export default rolling; diff --git a/src/methods/timeseries/series/shift.js b/src/methods/timeseries/series/shift.js new file mode 100644 index 0000000..bdeacf3 --- /dev/null +++ b/src/methods/timeseries/series/shift.js @@ -0,0 +1,76 @@ +/** + * Shift values in a Series by a specified number of periods + * + * @param {Series} series - Series to shift + * @param {number} periods - Number of periods to shift (positive for forward, negative for backward) + * @param {*} fillValue - Value to use for new periods + * @returns {Series} - Shifted Series + */ +export function shift(series, periods = 1, fillValue = null) { + const values = series.toArray(); + const result = new Array(values.length).fill(fillValue); + + if (periods > 0) { + // Forward shift (positive periods) + for (let i = 0; i < values.length - periods; i++) { + result[i + periods] = values[i]; + } + } else if (periods < 0) { + // Backward shift (negative periods) + const absShift = Math.abs(periods); + for (let i = absShift; i < values.length; i++) { + result[i - absShift] = values[i]; + } + } else { + // No shift (periods = 0) + for (let i = 0; i < values.length; i++) { + result[i] = values[i]; + } + } + + // Create a new Series with the shifted values + return new series.constructor(result, { + name: series.name, + }); +} + +/** + * Calculate percentage change between current and prior element + * + * @param {Series} series - Series to calculate percentage change + * @param {number} periods - Periods to shift for calculating percentage change + * @returns {Series} - Series with percentage changes + */ +export function pctChange(series, periods = 1) { + const values = series.toArray(); + const result = new Array(values.length).fill(null); + + for (let i = periods; i < values.length; i++) { + const current = values[i]; + const previous = values[i - periods]; + + // Skip if either value is not a number or if previous is zero + if ( + typeof current !== 'number' || + typeof previous !== 'number' || + Number.isNaN(current) || + Number.isNaN(previous) || + previous === 0 || + current === 0 // Also treat zero current values as null + ) { + continue; + } + + result[i] = (current - previous) / previous; + } + + // Create a new Series with the percentage changes + return new series.constructor(result, { + name: `${series.name}_pct_change`, + }); +} + +export default { + shift, + pctChange, +}; diff --git a/test/methods/timeseries/alltypes/calendar.test.js b/test/methods/timeseries/alltypes/calendar.test.js new file mode 100644 index 0000000..b9a9270 --- /dev/null +++ b/test/methods/timeseries/alltypes/calendar.test.js @@ -0,0 +1,476 @@ +import { describe, expect, test } from 'vitest'; +import { + isBusinessDay, + nextBusinessDay, + previousBusinessDay, + endOfMonth, + startOfMonth, + endOfQuarter, + startOfQuarter, + endOfYear, + startOfYear, + addBusinessDays, + dateRange, +} from '../../../../src/methods/timeseries/alltypes/calendar.js'; + +describe('isBusinessDay', () => { + test('should identify business days', () => { + // Monday to Friday are business days + expect(isBusinessDay(new Date('2023-01-02'))).toBe(true); // Monday + expect(isBusinessDay(new Date('2023-01-03'))).toBe(true); // Tuesday + expect(isBusinessDay(new Date('2023-01-04'))).toBe(true); // Wednesday + expect(isBusinessDay(new Date('2023-01-05'))).toBe(true); // Thursday + expect(isBusinessDay(new Date('2023-01-06'))).toBe(true); // Friday + }); + + test('should identify non-business days', () => { + // Saturday and Sunday are not business days + expect(isBusinessDay(new Date('2023-01-07'))).toBe(false); // Saturday + expect(isBusinessDay(new Date('2023-01-08'))).toBe(false); // Sunday + }); + + test('should handle invalid dates', () => { + expect(isBusinessDay(new Date('invalid date'))).toBe(false); + expect(isBusinessDay(null)).toBe(false); + expect(isBusinessDay(undefined)).toBe(false); + }); +}); + +describe('nextBusinessDay', () => { + test('should get next business day from weekday', () => { + // Monday to Thursday -> next day + expect(nextBusinessDay(new Date('2023-01-02'))).toEqual( + new Date('2023-01-03'), + ); // Monday -> Tuesday + expect(nextBusinessDay(new Date('2023-01-03'))).toEqual( + new Date('2023-01-04'), + ); // Tuesday -> Wednesday + expect(nextBusinessDay(new Date('2023-01-04'))).toEqual( + new Date('2023-01-05'), + ); // Wednesday -> Thursday + expect(nextBusinessDay(new Date('2023-01-05'))).toEqual( + new Date('2023-01-06'), + ); // Thursday -> Friday + }); + + test('should skip weekend for Friday', () => { + // Friday -> Monday + expect(nextBusinessDay(new Date('2023-01-06'))).toEqual( + new Date('2023-01-09'), + ); // Friday -> Monday + }); + + test('should skip weekend for weekend days', () => { + // Saturday and Sunday -> Monday + expect(nextBusinessDay(new Date('2023-01-07'))).toEqual( + new Date('2023-01-09'), + ); // Saturday -> Monday + expect(nextBusinessDay(new Date('2023-01-08'))).toEqual( + new Date('2023-01-09'), + ); // Sunday -> Monday + }); + + test('should handle invalid dates', () => { + expect(nextBusinessDay(new Date('invalid date'))).toBeNull(); + expect(nextBusinessDay(null)).toBeNull(); + expect(nextBusinessDay(undefined)).toBeNull(); + }); +}); + +describe('previousBusinessDay', () => { + test('should get previous business day from weekday', () => { + // Tuesday to Friday -> previous day + expect(previousBusinessDay(new Date('2023-01-03'))).toEqual( + new Date('2023-01-02'), + ); // Tuesday -> Monday + expect(previousBusinessDay(new Date('2023-01-04'))).toEqual( + new Date('2023-01-03'), + ); // Wednesday -> Tuesday + expect(previousBusinessDay(new Date('2023-01-05'))).toEqual( + new Date('2023-01-04'), + ); // Thursday -> Wednesday + expect(previousBusinessDay(new Date('2023-01-06'))).toEqual( + new Date('2023-01-05'), + ); // Friday -> Thursday + }); + + test('should skip weekend for Monday', () => { + // Monday -> Friday + expect(previousBusinessDay(new Date('2023-01-09'))).toEqual( + new Date('2023-01-06'), + ); // Monday -> Friday + }); + + test('should skip weekend for weekend days', () => { + // Saturday and Sunday -> Friday + expect(previousBusinessDay(new Date('2023-01-07'))).toEqual( + new Date('2023-01-06'), + ); // Saturday -> Friday + expect(previousBusinessDay(new Date('2023-01-08'))).toEqual( + new Date('2023-01-06'), + ); // Sunday -> Friday + }); + + test('should handle invalid dates', () => { + expect(previousBusinessDay(new Date('invalid date'))).toBeNull(); + expect(previousBusinessDay(null)).toBeNull(); + expect(previousBusinessDay(undefined)).toBeNull(); + }); +}); + +describe('endOfMonth', () => { + test('should get end of month for various months', () => { + expect(endOfMonth(new Date('2023-01-15'))).toEqual(new Date('2023-01-31')); // January + expect(endOfMonth(new Date('2023-02-10'))).toEqual(new Date('2023-02-28')); // February (non-leap year) + expect(endOfMonth(new Date('2024-02-10'))).toEqual(new Date('2024-02-29')); // February (leap year) + expect(endOfMonth(new Date('2023-04-05'))).toEqual(new Date('2023-04-30')); // April + expect(endOfMonth(new Date('2023-12-25'))).toEqual(new Date('2023-12-31')); // December + }); + + test('should handle invalid dates', () => { + expect(endOfMonth(new Date('invalid date'))).toBeNull(); + expect(endOfMonth(null)).toBeNull(); + expect(endOfMonth(undefined)).toBeNull(); + }); +}); + +describe('startOfMonth', () => { + test('should get start of month for various months', () => { + expect(startOfMonth(new Date('2023-01-15'))).toEqual( + new Date('2023-01-01'), + ); // January + expect(startOfMonth(new Date('2023-02-10'))).toEqual( + new Date('2023-02-01'), + ); // February + expect(startOfMonth(new Date('2023-04-05'))).toEqual( + new Date('2023-04-01'), + ); // April + expect(startOfMonth(new Date('2023-12-25'))).toEqual( + new Date('2023-12-01'), + ); // December + }); + + test('should handle invalid dates', () => { + expect(startOfMonth(new Date('invalid date'))).toBeNull(); + expect(startOfMonth(null)).toBeNull(); + expect(startOfMonth(undefined)).toBeNull(); + }); +}); + +describe('endOfQuarter', () => { + test('should get end of quarter for various dates', () => { + expect(endOfQuarter(new Date('2023-01-15')).toDateString()).toEqual( + new Date('2023-03-31').toDateString(), + ); // Q1 + expect(endOfQuarter(new Date('2023-05-10')).toDateString()).toEqual( + new Date('2023-06-30').toDateString(), + ); // Q2 + expect(endOfQuarter(new Date('2023-08-05')).toDateString()).toEqual( + new Date('2023-09-30').toDateString(), + ); // Q3 + expect(endOfQuarter(new Date('2023-11-25')).toDateString()).toEqual( + new Date('2023-12-31').toDateString(), + ); // Q4 + }); + + test('should handle invalid dates', () => { + expect(endOfQuarter(new Date('invalid date'))).toBeNull(); + expect(endOfQuarter(null)).toBeNull(); + expect(endOfQuarter(undefined)).toBeNull(); + }); +}); + +describe('startOfQuarter', () => { + test('should get start of quarter for various dates', () => { + expect(startOfQuarter(new Date('2023-01-15')).toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); // Q1 + expect(startOfQuarter(new Date('2023-05-10')).toDateString()).toEqual( + new Date('2023-04-01').toDateString(), + ); // Q2 + expect(startOfQuarter(new Date('2023-08-05')).toDateString()).toEqual( + new Date('2023-07-01').toDateString(), + ); // Q3 + expect(startOfQuarter(new Date('2023-11-25')).toDateString()).toEqual( + new Date('2023-10-01').toDateString(), + ); // Q4 + }); + + test('should handle invalid dates', () => { + expect(startOfQuarter(new Date('invalid date'))).toBeNull(); + expect(startOfQuarter(null)).toBeNull(); + expect(startOfQuarter(undefined)).toBeNull(); + }); +}); + +describe('endOfYear', () => { + test('should get end of year for various dates', () => { + expect(endOfYear(new Date('2023-01-15')).toDateString()).toEqual( + new Date('2023-12-31').toDateString(), + ); + expect(endOfYear(new Date('2023-06-10')).toDateString()).toEqual( + new Date('2023-12-31').toDateString(), + ); + expect(endOfYear(new Date('2023-12-25')).toDateString()).toEqual( + new Date('2023-12-31').toDateString(), + ); + expect(endOfYear(new Date('2024-02-29')).toDateString()).toEqual( + new Date('2024-12-31').toDateString(), + ); // Leap year + }); + + test('should handle invalid dates', () => { + expect(endOfYear(new Date('invalid date'))).toBeNull(); + expect(endOfYear(null)).toBeNull(); + expect(endOfYear(undefined)).toBeNull(); + }); +}); + +describe('startOfYear', () => { + test('should get start of year for various dates', () => { + expect(startOfYear(new Date('2023-01-15')).toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); + expect(startOfYear(new Date('2023-06-10')).toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); + expect(startOfYear(new Date('2023-12-25')).toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); + expect(startOfYear(new Date('2024-02-29')).toDateString()).toEqual( + new Date('2024-01-01').toDateString(), + ); // Leap year + }); + + test('should handle invalid dates', () => { + expect(startOfYear(new Date('invalid date'))).toBeNull(); + expect(startOfYear(null)).toBeNull(); + expect(startOfYear(undefined)).toBeNull(); + }); +}); + +describe('addBusinessDays', () => { + test('should add business days correctly', () => { + // Add 1 business day + expect(addBusinessDays(new Date('2023-01-02'), 1)).toEqual( + new Date('2023-01-03'), + ); // Monday -> Tuesday + + // Add 3 business days + expect(addBusinessDays(new Date('2023-01-02'), 3)).toEqual( + new Date('2023-01-05'), + ); // Monday -> Thursday + + // Add 5 business days (crossing weekend) + expect(addBusinessDays(new Date('2023-01-05'), 5)).toEqual( + new Date('2023-01-12'), + ); // Thursday -> next Thursday + + // Add 0 business days + expect(addBusinessDays(new Date('2023-01-02'), 0)).toEqual( + new Date('2023-01-02'), + ); // No change + }); + + test('should handle weekend start dates', () => { + // Add business days from weekend + expect(addBusinessDays(new Date('2023-01-07'), 1)).toEqual( + new Date('2023-01-09'), + ); // Saturday -> Monday + expect(addBusinessDays(new Date('2023-01-08'), 1)).toEqual( + new Date('2023-01-09'), + ); // Sunday -> Monday + }); + + test('should handle invalid dates and inputs', () => { + expect(addBusinessDays(new Date('invalid date'), 1)).toBeNull(); + expect(addBusinessDays(null, 1)).toBeNull(); + expect(addBusinessDays(undefined, 1)).toBeNull(); + expect(addBusinessDays(new Date('2023-01-02'), 'not a number')).toBeNull(); + }); +}); + +describe('dateRange', () => { + test('should generate daily date range', () => { + const start = new Date('2023-01-01'); + const end = new Date('2023-01-05'); + + const range = dateRange(start, end, 'D'); + + expect(range.length).toBe(5); + expect(range[0].toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); + expect(range[1].toDateString()).toEqual( + new Date('2023-01-02').toDateString(), + ); + expect(range[2].toDateString()).toEqual( + new Date('2023-01-03').toDateString(), + ); + expect(range[3].toDateString()).toEqual( + new Date('2023-01-04').toDateString(), + ); + expect(range[4].toDateString()).toEqual( + new Date('2023-01-05').toDateString(), + ); + }); + + test('should generate business day range', () => { + const start = new Date('2023-01-01'); // Sunday + const end = new Date('2023-01-08'); // Sunday + + const range = dateRange(start, end, 'B'); + + expect(range.length).toBe(6); // Sunday, 5 business days, Sunday + expect(range[0].toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); // Sunday (start date) + expect(range[1].toDateString()).toEqual( + new Date('2023-01-02').toDateString(), + ); // Monday + expect(range[2].toDateString()).toEqual( + new Date('2023-01-03').toDateString(), + ); // Tuesday + expect(range[3].toDateString()).toEqual( + new Date('2023-01-04').toDateString(), + ); // Wednesday + expect(range[4].toDateString()).toEqual( + new Date('2023-01-05').toDateString(), + ); // Thursday + expect(range[5].toDateString()).toEqual( + new Date('2023-01-06').toDateString(), + ); // Friday + }); + + test('should generate weekly date range', () => { + const start = new Date('2023-01-01'); + const end = new Date('2023-01-29'); + + const range = dateRange(start, end, 'W'); + + expect(range.length).toBe(5); + expect(range[0].toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); + expect(range[1].toDateString()).toEqual( + new Date('2023-01-08').toDateString(), + ); + expect(range[2].toDateString()).toEqual( + new Date('2023-01-15').toDateString(), + ); + expect(range[3].toDateString()).toEqual( + new Date('2023-01-22').toDateString(), + ); + expect(range[4].toDateString()).toEqual( + new Date('2023-01-29').toDateString(), + ); + }); + + test('should generate monthly date range', () => { + const start = new Date('2023-01-01'); + const end = new Date('2023-06-01'); + + const range = dateRange(start, end, 'M'); + + expect(range.length).toBe(6); + expect(range[0].toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); + expect(range[1].toDateString()).toEqual( + new Date('2023-02-01').toDateString(), + ); + expect(range[2].toDateString()).toEqual( + new Date('2023-03-01').toDateString(), + ); + expect(range[3].toDateString()).toEqual( + new Date('2023-04-01').toDateString(), + ); + expect(range[4].toDateString()).toEqual( + new Date('2023-05-01').toDateString(), + ); + expect(range[5].toDateString()).toEqual( + new Date('2023-06-01').toDateString(), + ); + }); + + test('should generate quarterly date range', () => { + const start = new Date('2023-01-01'); + const end = new Date('2024-01-01'); + + const range = dateRange(start, end, 'Q'); + + expect(range.length).toBe(5); + expect(range[0].toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); + expect(range[1].toDateString()).toEqual( + new Date('2023-04-01').toDateString(), + ); + expect(range[2].toDateString()).toEqual( + new Date('2023-07-01').toDateString(), + ); + expect(range[3].toDateString()).toEqual( + new Date('2023-10-01').toDateString(), + ); + expect(range[4].toDateString()).toEqual( + new Date('2024-01-01').toDateString(), + ); + }); + + test('should generate yearly date range', () => { + const start = new Date('2020-01-01'); + const end = new Date('2025-01-01'); + + const range = dateRange(start, end, 'Y'); + + expect(range.length).toBe(6); + expect(range[0].toDateString()).toEqual( + new Date('2020-01-01').toDateString(), + ); + expect(range[1].toDateString()).toEqual( + new Date('2021-01-01').toDateString(), + ); + expect(range[2].toDateString()).toEqual( + new Date('2022-01-01').toDateString(), + ); + expect(range[3].toDateString()).toEqual( + new Date('2023-01-01').toDateString(), + ); + expect(range[4].toDateString()).toEqual( + new Date('2024-01-01').toDateString(), + ); + expect(range[5].toDateString()).toEqual( + new Date('2025-01-01').toDateString(), + ); + }); + + test('should handle invalid inputs', () => { + expect(dateRange(new Date('invalid date'), new Date('2023-01-05'))).toEqual( + [], + ); + expect(dateRange(new Date('2023-01-01'), new Date('invalid date'))).toEqual( + [], + ); + expect(dateRange(null, new Date('2023-01-05'))).toEqual([]); + expect(dateRange(new Date('2023-01-01'), null)).toEqual([]); + expect(dateRange(undefined, new Date('2023-01-05'))).toEqual([]); + expect(dateRange(new Date('2023-01-01'), undefined)).toEqual([]); + + // End date before start date + expect(dateRange(new Date('2023-01-05'), new Date('2023-01-01'))).toEqual( + [], + ); + }); + + test('should default to daily frequency if invalid frequency provided', () => { + const start = new Date('2023-01-01'); + const end = new Date('2023-01-03'); + + const range = dateRange(start, end, 'invalid'); + + expect(range.length).toBe(3); + expect(range[0]).toEqual(new Date('2023-01-01')); + expect(range[1]).toEqual(new Date('2023-01-02')); + expect(range[2]).toEqual(new Date('2023-01-03')); + }); +}); diff --git a/test/methods/timeseries/alltypes/inferFrequency.test.js b/test/methods/timeseries/alltypes/inferFrequency.test.js new file mode 100644 index 0000000..3836bb9 --- /dev/null +++ b/test/methods/timeseries/alltypes/inferFrequency.test.js @@ -0,0 +1,206 @@ +import { describe, expect, test } from 'vitest'; +import { + inferFrequency, + inferFrequencyFromData, +} from '../../../../src/methods/timeseries/alltypes/inferFrequency.js'; +import { Series } from '../../../../src/core/dataframe/Series.js'; +import { DataFrame } from '../../../../src/core/dataframe/DataFrame.js'; + +describe('inferFrequency', () => { + test('should infer daily frequency', () => { + const dates = [ + new Date('2023-01-01'), + new Date('2023-01-02'), + new Date('2023-01-03'), + new Date('2023-01-04'), + new Date('2023-01-05'), + ]; + + expect(inferFrequency(dates)).toBe('D'); + }); + + test('should infer weekly frequency', () => { + const dates = [ + new Date('2023-01-01'), + new Date('2023-01-08'), + new Date('2023-01-15'), + new Date('2023-01-22'), + new Date('2023-01-29'), + ]; + + expect(inferFrequency(dates)).toBe('W'); + }); + + test('should infer monthly frequency', () => { + const dates = [ + new Date('2023-01-01'), + new Date('2023-02-01'), + new Date('2023-03-01'), + new Date('2023-04-01'), + new Date('2023-05-01'), + ]; + + expect(inferFrequency(dates)).toBe('M'); + }); + + test('should infer quarterly frequency', () => { + const dates = [ + new Date('2023-01-01'), + new Date('2023-04-01'), + new Date('2023-07-01'), + new Date('2023-10-01'), + new Date('2024-01-01'), + ]; + + expect(inferFrequency(dates)).toBe('Q'); + }); + + test('should infer yearly frequency', () => { + const dates = [ + new Date('2020-01-01'), + new Date('2021-01-01'), + new Date('2022-01-01'), + new Date('2023-01-01'), + new Date('2024-01-01'), + ]; + + expect(inferFrequency(dates)).toBe('Y'); + }); + + test('should handle irregular dates', () => { + const dates = [ + new Date('2023-01-01'), + new Date('2023-01-03'), // Skip a day + new Date('2023-01-04'), + new Date('2023-01-05'), + new Date('2023-01-08'), // Skip two days + ]; + + expect(inferFrequency(dates)).toBe('D'); // Still infer as daily with gaps + }); + + test('should handle invalid dates', () => { + const dates = [ + new Date('2023-01-01'), + new Date('invalid date'), // Invalid date + new Date('2023-01-03'), + null, // null + new Date('2023-01-05'), + ]; + + expect(inferFrequency(dates)).toBe('D'); // Should filter out invalid dates + }); + + test('should return null for insufficient data', () => { + // Only one date + expect(inferFrequency([new Date('2023-01-01')])).toBeNull(); + + // Empty array + expect(inferFrequency([])).toBeNull(); + + // null + expect(inferFrequency(null)).toBeNull(); + }); +}); + +describe('inferFrequencyFromData', () => { + test('should infer frequency from Series', () => { + const dates = [ + new Date('2023-01-01'), + new Date('2023-01-02'), + new Date('2023-01-03'), + new Date('2023-01-04'), + new Date('2023-01-05'), + ]; + + const series = Series.create(dates, { name: 'dates' }); + + expect(inferFrequencyFromData(series)).toBe('D'); + }); + + test('should infer frequency from DataFrame with date column', () => { + // Create dates with a precise interval of 30 days (month) + const dates = [ + new Date('2023-01-01T00:00:00Z'), + new Date('2023-02-01T00:00:00Z'), + new Date('2023-03-01T00:00:00Z'), + new Date('2023-04-01T00:00:00Z'), + new Date('2023-05-01T00:00:00Z'), + ]; + + const values = [10, 20, 30, 40, 50]; + + const df = DataFrame.create({ + date: dates, + value: values, + }); + + // Verify that dates are correctly stored in the DataFrame + const dateColumn = df.col('date'); + expect(dateColumn).toBeDefined(); + expect(dateColumn.length).toBe(5); + expect(dateColumn.get(0) instanceof Date).toBe(true); + + // Manual verification of the inferFrequency function with dates from the column + const dateValues = dateColumn.toArray(); + console.log('Date values from column:', dateValues); + const frequency = inferFrequency(dateValues); + console.log('Direct frequency inference:', frequency); + + // Testing the inferFrequencyFromData function + const result = inferFrequencyFromData(df, 'date'); + console.log('inferFrequencyFromData result:', result); + + expect(result).toBe('M'); + }); + + test('should auto-detect date column in DataFrame', () => { + // Create dates with a precise interval of 7 days (week) + const dates = [ + new Date('2023-01-01T00:00:00Z'), + new Date('2023-01-08T00:00:00Z'), + new Date('2023-01-15T00:00:00Z'), + new Date('2023-01-22T00:00:00Z'), + new Date('2023-01-29T00:00:00Z'), + ]; + + const values = [10, 20, 30, 40, 50]; + + const df = DataFrame.create({ + timestamp: dates, + value: values, + }); + + // Verify that dates are correctly stored in the DataFrame + const timestampColumn = df.col('timestamp'); + expect(timestampColumn).toBeDefined(); + expect(timestampColumn.length).toBe(5); + expect(timestampColumn.get(0) instanceof Date).toBe(true); + + // Verify that the difference between dates is 7 days (604800000 ms) + const diff = timestampColumn.get(1) - timestampColumn.get(0); + expect(diff).toBe(7 * 24 * 60 * 60 * 1000); + + // Debug automatic detection of the date column + console.log('DataFrame columns:', df.columns); + console.log('First row of timestamp column:', timestampColumn.get(0)); + + // Testing the inferFrequencyFromData function without specifying a column + const result = inferFrequencyFromData(df); + console.log('inferFrequencyFromData auto-detect result:', result); + + expect(result).toBe('W'); + }); + + test('should return null for DataFrame with no date column', () => { + const values1 = [10, 20, 30, 40, 50]; + const values2 = [1, 2, 3, 4, 5]; + + const df = DataFrame.create({ + value1: values1, + value2: values2, + }); + + expect(inferFrequencyFromData(df)).toBeNull(); + }); +}); diff --git a/test/methods/timeseries/alltypes/rollingCore.test.js b/test/methods/timeseries/alltypes/rollingCore.test.js new file mode 100644 index 0000000..397804f --- /dev/null +++ b/test/methods/timeseries/alltypes/rollingCore.test.js @@ -0,0 +1,314 @@ +import { describe, expect, test } from 'vitest'; +import { + validateRollingOptions, + applyRollingWindow, + validateExpandingOptions, + applyExpandingWindow, +} from '../../../../src/methods/timeseries/alltypes/rollingCore.js'; + +describe('validateRollingOptions', () => { + test('should validate valid options', () => { + const options = { + window: 3, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }; + + const validated = validateRollingOptions(options); + + expect(validated.window).toBe(3); + expect(typeof validated.aggregation).toBe('function'); + expect(validated.minPeriods).toBe(3); // Default to window size + }); + + test('should validate options with custom minPeriods', () => { + const options = { + window: 5, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + minPeriods: 2, + }; + + const validated = validateRollingOptions(options); + + expect(validated.window).toBe(5); + expect(typeof validated.aggregation).toBe('function'); + expect(validated.minPeriods).toBe(2); + }); + + test('should throw error for missing options', () => { + expect(() => validateRollingOptions()).toThrow('options must be provided'); + }); + + test('should throw error for invalid window', () => { + const options = { + window: -1, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }; + + expect(() => validateRollingOptions(options)).toThrow( + 'window must be a positive number', + ); + }); + + test('should throw error for missing window', () => { + const options = { + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }; + + expect(() => validateRollingOptions(options)).toThrow( + 'window must be a positive number', + ); + }); + + test('should throw error for invalid aggregation', () => { + const options = { + window: 3, + aggregation: 'not a function', + }; + + expect(() => validateRollingOptions(options)).toThrow( + 'aggregation must be a function', + ); + }); + + test('should throw error for missing aggregation', () => { + const options = { + window: 3, + }; + + expect(() => validateRollingOptions(options)).toThrow( + 'aggregation must be a function', + ); + }); + + test('should throw error for invalid minPeriods', () => { + const options = { + window: 3, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + minPeriods: -1, + }; + + expect(() => validateRollingOptions(options)).toThrow( + 'minPeriods must be a positive number', + ); + }); +}); + +describe('applyRollingWindow', () => { + test('should apply rolling window with mean aggregation', () => { + const values = [1, 2, 3, 4, 5]; + + const options = { + window: 3, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }; + + const result = applyRollingWindow(values, options); + + expect(result.length).toBe(values.length); + expect(result[0]).toBeNull(); // Not enough values for window + expect(result[1]).toBeNull(); // Not enough values for window + expect(result[2]).toBeCloseTo((1 + 2 + 3) / 3); // First complete window + expect(result[3]).toBeCloseTo((2 + 3 + 4) / 3); + expect(result[4]).toBeCloseTo((3 + 4 + 5) / 3); + }); + + test('should apply rolling window with custom minPeriods', () => { + const values = [1, 2, 3, 4, 5]; + + const options = { + window: 3, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + minPeriods: 2, + }; + + const result = applyRollingWindow(values, options); + + expect(result.length).toBe(values.length); + expect(result[0]).toBeNull(); // Not enough values for minPeriods + expect(result[1]).toBeCloseTo((1 + 2) / 2); // Enough for minPeriods + expect(result[2]).toBeCloseTo((1 + 2 + 3) / 3); + expect(result[3]).toBeCloseTo((2 + 3 + 4) / 3); + expect(result[4]).toBeCloseTo((3 + 4 + 5) / 3); + }); + + test('should handle null and NaN values', () => { + const values = [1, null, NaN, 4, 5]; + + const options = { + window: 3, + minPeriods: 1, + aggregation: (arr) => arr.reduce((a, b) => a + b, 0) / arr.length, + }; + + const result = applyRollingWindow(values, options); + + expect(result.length).toBe(values.length); + expect(result[0]).toBeCloseTo(1); // Only one valid value + expect(result[1]).toBeCloseTo(1); // Only one valid value + expect(result[2]).toBeCloseTo(1); // Only one valid value + expect(result[3]).toBeCloseTo(4); // Only one valid value in window (4) + expect(result[4]).toBeCloseTo((4 + 5) / 2); // Two valid values (4, 5) + }); + + test('should handle aggregation errors', () => { + const values = [1, 2, 3, 4, 5]; + + const options = { + window: 3, + aggregation: () => { + throw new Error('Aggregation error'); + }, + minPeriods: 1, + }; + + const result = applyRollingWindow(values, options); + + // All results should be null due to aggregation errors + expect(result.every((v) => v === null)).toBe(true); + }); +}); + +describe('validateExpandingOptions', () => { + test('should validate valid options', () => { + const options = { + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }; + + const validated = validateExpandingOptions(options); + + expect(typeof validated.aggregation).toBe('function'); + expect(validated.minPeriods).toBe(1); // Default + }); + + test('should validate options with custom minPeriods', () => { + const options = { + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + minPeriods: 3, + }; + + const validated = validateExpandingOptions(options); + + expect(typeof validated.aggregation).toBe('function'); + expect(validated.minPeriods).toBe(3); + }); + + test('should throw error for missing options', () => { + expect(() => validateExpandingOptions()).toThrow( + 'options must be provided', + ); + }); + + test('should throw error for invalid aggregation', () => { + const options = { + aggregation: 'not a function', + }; + + expect(() => validateExpandingOptions(options)).toThrow( + 'aggregation must be a function', + ); + }); + + test('should throw error for missing aggregation', () => { + const options = { + minPeriods: 3, + }; + + expect(() => validateExpandingOptions(options)).toThrow( + 'aggregation must be a function', + ); + }); + + test('should throw error for invalid minPeriods', () => { + const options = { + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + minPeriods: -1, + }; + + expect(() => validateExpandingOptions(options)).toThrow( + 'minPeriods must be a positive number', + ); + }); +}); + +describe('applyExpandingWindow', () => { + test('should apply expanding window with mean aggregation', () => { + const values = [1, 2, 3, 4, 5]; + + const options = { + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }; + + const result = applyExpandingWindow(values, options); + + expect(result.length).toBe(values.length); + expect(result[0]).toBeCloseTo(1); // Just the first value + expect(result[1]).toBeCloseTo((1 + 2) / 2); // First two values + expect(result[2]).toBeCloseTo((1 + 2 + 3) / 3); // First three values + expect(result[3]).toBeCloseTo((1 + 2 + 3 + 4) / 4); // First four values + expect(result[4]).toBeCloseTo((1 + 2 + 3 + 4 + 5) / 5); // All values + }); + + test('should apply expanding window with custom minPeriods', () => { + const values = [1, 2, 3, 4, 5]; + + const options = { + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + minPeriods: 3, + }; + + const result = applyExpandingWindow(values, options); + + expect(result.length).toBe(values.length); + expect(result[0]).toBeNull(); // Not enough values for minPeriods + expect(result[1]).toBeNull(); // Not enough values for minPeriods + expect(result[2]).toBeCloseTo((1 + 2 + 3) / 3); // First three values (enough for minPeriods) + expect(result[3]).toBeCloseTo((1 + 2 + 3 + 4) / 4); // First four values + expect(result[4]).toBeCloseTo((1 + 2 + 3 + 4 + 5) / 5); // All values + }); + + test('should handle null and NaN values', () => { + const values = [1, null, NaN, 4, 5]; + + const options = { + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + minPeriods: 1, + }; + + const result = applyExpandingWindow(values, options); + + expect(result.length).toBe(values.length); + expect(result[0]).toBeCloseTo(1); // Only one valid value + expect(result[1]).toBeCloseTo(1); // Only one valid value + expect(result[2]).toBeCloseTo(1); // Only one valid value + expect(result[3]).toBeCloseTo((1 + 4) / 2); // Two valid values + expect(result[4]).toBeCloseTo((1 + 4 + 5) / 3); // Three valid values + }); + + test('should handle aggregation errors', () => { + const values = [1, 2, 3, 4, 5]; + + const options = { + aggregation: () => { + throw new Error('Aggregation error'); + }, + }; + + const result = applyExpandingWindow(values, options); + + // All results should be null due to aggregation errors + expect(result.every((v) => v === null)).toBe(true); + }); +}); diff --git a/test/methods/timeseries/dataframe/expanding.test.js b/test/methods/timeseries/dataframe/expanding.test.js new file mode 100644 index 0000000..891738d --- /dev/null +++ b/test/methods/timeseries/dataframe/expanding.test.js @@ -0,0 +1,148 @@ +import { describe, expect, test, beforeAll } from 'vitest'; +import { DataFrame } from '../../../../src/core/dataframe/DataFrame'; +import registerDataFrameTimeSeries from '../../../../src/methods/timeseries/dataframe/register'; + +// Register timeseries methods before tests +beforeAll(() => { + registerDataFrameTimeSeries(DataFrame); + console.log('Apache Arrow integration initialized successfully'); +}); + +describe('expanding', () => { + test('should calculate expanding window with default options', () => { + const df = DataFrame.create({ + value: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }); + + const result = df.expanding({ + aggregations: { + value: (values) => + values.reduce((sum, val) => sum + val, 0) / values.length, + }, + }); + + const expandingValues = result.col('value_expanding').toArray(); + + // Check that all columns are preserved + expect(result.columns).toContain('value'); + expect(result.columns).toContain('value_expanding'); + + // Check aggregation results + expect(expandingValues[0]).toBe(1); // [1] + expect(expandingValues[1]).toBeCloseTo((1 + 2) / 2); // [1, 2] + expect(expandingValues[2]).toBeCloseTo((1 + 2 + 3) / 3); // [1, 2, 3] + expect(expandingValues[3]).toBeCloseTo((1 + 2 + 3 + 4) / 4); // [1, 2, 3, 4] + expect(expandingValues[9]).toBeCloseTo( + (1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10) / 10, + ); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + }); + + test('should handle minPeriods option', () => { + const df = DataFrame.create({ + value: [1, 2, 3, 4, 5], + }); + + const result = df.expanding({ + minPeriods: 3, + aggregations: { + value: (values) => + values.reduce((sum, val) => sum + val, 0) / values.length, + }, + }); + + const expandingValues = result.col('value_expanding').toArray(); + + // First two values should be null since minPeriods = 3 + expect(expandingValues[0]).toBeNull(); + expect(expandingValues[1]).toBeNull(); + + // Starting from the third value, we should have results + expect(expandingValues[2]).toBeCloseTo((1 + 2 + 3) / 3); + expect(expandingValues[3]).toBeCloseTo((1 + 2 + 3 + 4) / 4); + expect(expandingValues[4]).toBeCloseTo((1 + 2 + 3 + 4 + 5) / 5); + }); + + test('should handle multiple column aggregations', () => { + const df = DataFrame.create({ + A: [1, 2, 3, 4, 5], + B: [10, 20, 30, 40, 50], + }); + + const result = df.expanding({ + aggregations: { + A: (values) => values.reduce((sum, val) => sum + val, 0), + B: (values) => + values.reduce((sum, val) => sum + val, 0) / values.length, + }, + }); + + const expandingA = result.col('A_expanding').toArray(); + const expandingB = result.col('B_expanding').toArray(); + + // Check that all columns are preserved + expect(result.columns).toContain('A'); + expect(result.columns).toContain('B'); + expect(result.columns).toContain('A_expanding'); + expect(result.columns).toContain('B_expanding'); + + // Check aggregation results for column A (sum) + expect(expandingA[0]).toBe(1); + expect(expandingA[1]).toBe(1 + 2); + expect(expandingA[2]).toBe(1 + 2 + 3); + expect(expandingA[3]).toBe(1 + 2 + 3 + 4); + expect(expandingA[4]).toBe(1 + 2 + 3 + 4 + 5); + + // Check aggregation results for column B (average) + expect(expandingB[0]).toBe(10); + expect(expandingB[1]).toBeCloseTo((10 + 20) / 2); + expect(expandingB[2]).toBeCloseTo((10 + 20 + 30) / 3); + expect(expandingB[3]).toBeCloseTo((10 + 20 + 30 + 40) / 4); + expect(expandingB[4]).toBeCloseTo((10 + 20 + 30 + 40 + 50) / 5); + }); + + test('should handle NaN values correctly', () => { + const df = DataFrame.create({ + value: [1, NaN, 3, 4, NaN, 6], + }); + + const result = df.expanding({ + aggregations: { + value: (values) => { + // Proper handling of NaN values in the aggregation function + if (values.length === 0) return null; + return values.reduce((sum, val) => sum + val, 0) / values.length; + }, + }, + }); + + const expandingValues = result.col('value_expanding').toArray(); + + // Check aggregation results with NaN values filtering + expect(expandingValues[0]).toBe(1); // [1] + expect(expandingValues[1]).toBe(1); // [1] (NaN is filtered out) + expect(expandingValues[2]).toBeCloseTo((1 + 3) / 2); // [1, 3] + expect(expandingValues[3]).toBeCloseTo((1 + 3 + 4) / 3); // [1, 3, 4] + expect(expandingValues[4]).toBeCloseTo((1 + 3 + 4) / 3); // [1, 3, 4] (NaN is filtered out) + expect(expandingValues[5]).toBeCloseTo((1 + 3 + 4 + 6) / 4); // [1, 3, 4, 6] + }); + + test('should throw error for invalid options', () => { + const df = DataFrame.create({ + value: [1, 2, 3, 4, 5], + }); + + // Check that an error is thrown if no aggregations are specified + expect(() => df.expanding({})).toThrow( + 'At least one aggregation must be specified', + ); + + // Check that an error is thrown if a non-existent column is specified + expect(() => + df.expanding({ + aggregations: { + nonexistent: (values) => values.length, + }, + }), + ).toThrow("Column 'nonexistent' not found in DataFrame"); + }); +}); diff --git a/test/methods/timeseries/dataframe/resample.test.js b/test/methods/timeseries/dataframe/resample.test.js new file mode 100644 index 0000000..a4ae59e --- /dev/null +++ b/test/methods/timeseries/dataframe/resample.test.js @@ -0,0 +1,216 @@ +// test/methods/timeseries/dataframe/resample.test.js +import { describe, test, expect, beforeAll } from 'vitest'; +import { DataFrame } from '../../../../src/core/dataframe/DataFrame.js'; +import resample from '../../../../src/methods/timeseries/dataframe/resample.js'; +import registerDataFrameTimeSeries from '../../../../src/methods/timeseries/dataframe/register.js'; + +describe('resample', () => { + beforeAll(() => { + // Регистрируем методы временных рядов для DataFrame + registerDataFrameTimeSeries(DataFrame); + }); + test('should resample daily data to monthly data', async () => { + // Create test data with daily timestamps + const dates = []; + const values = []; + const startDate = new Date('2023-01-01'); + + // Generate 90 days of test data (Jan-Mar 2023) + for (let i = 0; i < 90; i++) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + dates.push(date); + values.push(i + 1); // Simple increasing values + } + + const df = DataFrame.create({ + date: dates, + value: values, + }); + + // Resample to monthly frequency with sum aggregation + const resampled = df.resample({ + dateColumn: 'date', + freq: 'M', + aggregations: { + value: (arr) => arr.reduce((sum, val) => sum + val, 0), + }, + }); + + // Should have 3 months (Jan, Feb, Mar) + expect(resampled.rowCount).toBe(3); + + // Check that dates are first day of each month + const resultDates = resampled.col('date').toArray(); + expect(resultDates[0].getMonth()).toBe(0); // January + expect(resultDates[1].getMonth()).toBe(1); // February + expect(resultDates[2].getMonth()).toBe(2); // March + + // Check aggregated values + const resultValues = resampled.col('value').toArray(); + + // January should be sum of days 1-31 + const janSum = Array.from({ length: 31 }, (_, i) => i + 1).reduce( + (a, b) => a + b, + 0, + ); + expect(resultValues[0]).toBeCloseTo(janSum); + + // February should be sum of days 32-59 (28 days in Feb 2023) + const febSum = Array.from({ length: 28 }, (_, i) => i + 32).reduce( + (a, b) => a + b, + 0, + ); + expect(resultValues[1]).toBeCloseTo(febSum); + + // March should be sum of days 60-90 (31 days) + const marSum = Array.from({ length: 31 }, (_, i) => i + 60).reduce( + (a, b) => a + b, + 0, + ); + expect(resultValues[2]).toBeCloseTo(marSum); + }); + + test('should resample with multiple aggregations', async () => { + const dates = [ + new Date('2023-01-01'), + new Date('2023-01-02'), + new Date('2023-01-03'), + new Date('2023-02-01'), + new Date('2023-02-02'), + ]; + + const df = DataFrame.create({ + date: dates, + value: [10, 20, 30, 40, 50], + count: [1, 2, 3, 4, 5], + }); + + const resampled = df.resample({ + dateColumn: 'date', + freq: 'M', + aggregations: { + value: (arr) => arr.reduce((sum, val) => sum + val, 0), + count: (arr) => arr.length, + }, + }); + + expect(resampled.rowCount).toBe(2); + + const valueResults = resampled.col('value').toArray(); + expect(valueResults[0]).toBe(60); // January: 10+20+30 + expect(valueResults[1]).toBe(90); // February: 40+50 + + const countResults = resampled.col('count').toArray(); + expect(countResults[0]).toBe(3); // 3 entries in January + expect(countResults[1]).toBe(2); // 2 entries in February + }); + + test('should throw error for invalid options', () => { + const df = DataFrame.create({ + date: [new Date()], + value: [1], + }); + + // Missing dateColumn + expect(() => + df.resample({ + freq: 'D', + aggregations: { value: (arr) => arr[0] }, + }), + ).toThrow(); + + // Missing freq + expect(() => + df.resample({ + dateColumn: 'date', + aggregations: { value: (arr) => arr[0] }, + }), + ).toThrow(); + + // Missing aggregations + expect(() => + df.resample({ + dateColumn: 'date', + freq: 'D', + }), + ).toThrow(); + + // Invalid column name + expect(() => + df.resample({ + dateColumn: 'date', + freq: 'D', + aggregations: { nonexistent: (arr) => arr[0] }, + }), + ).toThrow(); + }); + + test('should handle different frequencies', () => { + const dates = []; + const values = []; + const startDate = new Date('2023-01-01'); + + // Generate a year of test data + for (let i = 0; i < 365; i++) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + dates.push(date); + values.push(i % 7); // Values 0-6 repeating + } + + const df = DataFrame.create({ + date: dates, + value: values, + }); + + // Test daily aggregation + const dailyDf = df.resample({ + dateColumn: 'date', + freq: 'D', + aggregations: { + value: (arr) => arr.reduce((sum, val) => sum + val, 0), + }, + }); + + // Should have 365 days + expect(dailyDf.rowCount).toBe(365); + + // Test weekly aggregation + const weeklyDf = df.resample({ + dateColumn: 'date', + freq: 'W', + aggregations: { + value: (arr) => arr.reduce((sum, val) => sum + val, 0), + }, + }); + + // Should have ~52 weeks (might be 53 depending on exact dates) + expect(weeklyDf.rowCount).toBeGreaterThanOrEqual(52); + expect(weeklyDf.rowCount).toBeLessThanOrEqual(53); + + // Test quarterly aggregation + const quarterlyDf = df.resample({ + dateColumn: 'date', + freq: 'Q', + aggregations: { + value: (arr) => arr.reduce((sum, val) => sum + val, 0), + }, + }); + + // Should have 4 quarters + expect(quarterlyDf.rowCount).toBe(4); + + // Test yearly aggregation + const yearlyDf = df.resample({ + dateColumn: 'date', + freq: 'Y', + aggregations: { + value: (arr) => arr.reduce((sum, val) => sum + val, 0), + }, + }); + + // Should have 1 year + expect(yearlyDf.rowCount).toBe(1); + }); +}); diff --git a/test/methods/timeseries/dataframe/rolling.test.js b/test/methods/timeseries/dataframe/rolling.test.js new file mode 100644 index 0000000..e7ed3e4 --- /dev/null +++ b/test/methods/timeseries/dataframe/rolling.test.js @@ -0,0 +1,200 @@ +// test/methods/timeseries/dataframe/rolling.test.js +import { describe, test, expect, beforeAll } from 'vitest'; +import { DataFrame } from '../../../../src/core/dataframe/DataFrame.js'; +import rolling from '../../../../src/methods/timeseries/dataframe/rolling.js'; +import registerDataFrameTimeSeries from '../../../../src/methods/timeseries/dataframe/register.js'; + +describe('rolling', () => { + beforeAll(() => { + // Регистрируем методы временных рядов для DataFrame + registerDataFrameTimeSeries(DataFrame); + }); + test('should calculate rolling window with default options', () => { + const df = DataFrame.create({ + value: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }); + + const result = df.rolling({ + window: 3, + aggregations: { + value: (values) => + values.reduce((sum, val) => sum + val, 0) / values.length, + }, + }); + + // Check that original column is preserved + expect(result.columns).toContain('value'); + + // Check that rolling column was added + expect(result.columns).toContain('value_rolling'); + + // Check values (first two should be null because window size is 3) + const rollingValues = result.col('value_rolling').toArray(); + expect(rollingValues[0]).toBeNull(); + expect(rollingValues[1]).toBeNull(); + expect(rollingValues[2]).toBeCloseTo((1 + 2 + 3) / 3); + expect(rollingValues[3]).toBeCloseTo((2 + 3 + 4) / 3); + expect(rollingValues[4]).toBeCloseTo((3 + 4 + 5) / 3); + expect(rollingValues[9]).toBeCloseTo((8 + 9 + 10) / 3); + }); + + test('should calculate rolling window with centered option', () => { + const df = DataFrame.create({ + value: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }); + + const result = df.rolling({ + window: 3, + center: true, + aggregations: { + value: (values) => + values.reduce((sum, val) => sum + val, 0) / values.length, + }, + }); + + // Check values (first and last should be null because of centering) + const rollingValues = result.col('value_rolling').toArray(); + expect(rollingValues[0]).toBeNull(); + expect(rollingValues[1]).toBeCloseTo((1 + 2 + 3) / 3); + expect(rollingValues[2]).toBeCloseTo((2 + 3 + 4) / 3); + expect(rollingValues[8]).toBeCloseTo((8 + 9 + 10) / 3); + expect(rollingValues[9]).toBeNull(); + }); + + test('should handle minPeriods option', () => { + const df = DataFrame.create({ + value: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }); + + const result = df.rolling({ + window: 3, + minPeriods: 2, // Only require 2 observations instead of full window + aggregations: { + value: (values) => + values.reduce((sum, val) => sum + val, 0) / values.length, + }, + }); + + // Check values (first should be null, second should have value) + const rollingValues = result.col('value_rolling').toArray(); + expect(rollingValues[0]).toBeNull(); + expect(rollingValues[1]).toBeCloseTo((1 + 2) / 2); + expect(rollingValues[2]).toBeCloseTo((1 + 2 + 3) / 3); + }); + + test('should handle multiple column aggregations', () => { + const df = DataFrame.create({ + a: [1, 2, 3, 4, 5], + b: [10, 20, 30, 40, 50], + }); + + const result = df.rolling({ + window: 2, + aggregations: { + a: (values) => values.reduce((sum, val) => sum + val, 0), + b: (values) => Math.max(...values), + }, + }); + + // Check that both original columns are preserved + expect(result.columns).toContain('a'); + expect(result.columns).toContain('b'); + + // Check that both rolling columns were added + expect(result.columns).toContain('a_rolling'); + expect(result.columns).toContain('b_rolling'); + + // Check values for column a (sum) + const rollingA = result.col('a_rolling').toArray(); + expect(rollingA[0]).toBeNull(); + expect(rollingA[1]).toBe(1 + 2); + expect(rollingA[2]).toBe(2 + 3); + expect(rollingA[4]).toBe(4 + 5); + + // Check values for column b (max) + const rollingB = result.col('b_rolling').toArray(); + expect(rollingB[0]).toBeNull(); + expect(rollingB[1]).toBe(20); + expect(rollingB[2]).toBe(30); + expect(rollingB[4]).toBe(50); + }); + + test('should handle NaN values correctly', () => { + const df = DataFrame.create({ + value: [1, NaN, 3, 4, NaN, 6], + }); + + const result = df.rolling({ + window: 3, + minPeriods: 1, // Требуем минимум 1 значение в окне вместо 3 (по умолчанию) + aggregations: { + value: (values) => { + // Правильная обработка NaN значений в агрегационной функции + if (values.length === 0) return null; + return values.reduce((sum, val) => sum + val, 0) / values.length; + }, + }, + }); + + const rollingValues = result.col('value_rolling').toArray(); + + // С minPeriods=1 первые значения будут содержать среднее из доступных значений + expect(rollingValues[0]).toBe(1); // Только одно значение [1] + expect(rollingValues[1]).toBe(1); // Только одно значение [1] (NaN отфильтровывается) + + // Window [1, NaN, 3] должно фильтровать NaN и вычислять среднее из [1, 3] + expect(rollingValues[2]).toBeCloseTo((1 + 3) / 2); + + // Window [NaN, 3, 4] должно фильтровать NaN и вычислять среднее из [3, 4] + expect(rollingValues[3]).toBeCloseTo((3 + 4) / 2); + + // Window [3, 4, NaN] должно фильтровать NaN и вычислять среднее из [3, 4] + expect(rollingValues[4]).toBeCloseTo((3 + 4) / 2); + + // Window [4, NaN, 6] должно фильтровать NaN и вычислять среднее из [4, 6] + expect(rollingValues[5]).toBeCloseTo((4 + 6) / 2); + }); + + test('should throw error for invalid options', () => { + const df = DataFrame.create({ + value: [1, 2, 3, 4, 5], + }); + + // Invalid window size + expect(() => + df.rolling({ + window: 0, + aggregations: { value: (arr) => arr[0] }, + }), + ).toThrow(); + + expect(() => + df.rolling({ + window: -1, + aggregations: { value: (arr) => arr[0] }, + }), + ).toThrow(); + + expect(() => + df.rolling({ + window: 'invalid', + aggregations: { value: (arr) => arr[0] }, + }), + ).toThrow(); + + // Missing aggregations + expect(() => + df.rolling({ + window: 3, + }), + ).toThrow(); + + // Invalid column name + expect(() => + df.rolling({ + window: 3, + aggregations: { nonexistent: (arr) => arr[0] }, + }), + ).toThrow(); + }); +}); diff --git a/test/methods/timeseries/dataframe/shift.test.js b/test/methods/timeseries/dataframe/shift.test.js new file mode 100644 index 0000000..7e8141d --- /dev/null +++ b/test/methods/timeseries/dataframe/shift.test.js @@ -0,0 +1,137 @@ +import { describe, expect, test, beforeAll } from 'vitest'; +import { DataFrame } from '../../../../src/core/dataframe/DataFrame'; +import registerDataFrameTimeSeries from '../../../../src/methods/timeseries/dataframe/register'; + +// Register timeseries methods before tests +beforeAll(() => { + registerDataFrameTimeSeries(DataFrame); + console.log('Apache Arrow integration initialized successfully'); +}); + +describe('shift', () => { + test('should shift values forward by default', () => { + const df = DataFrame.create({ + A: [1, 2, 3, 4, 5], + B: [10, 20, 30, 40, 50], + }); + + const result = df.shift(); + + // Check that all columns are preserved + expect(result.columns).toEqual(df.columns); + + // Check that values are shifted forward by 1 position (default) + expect(result.col('A').toArray()).toEqual([null, 1, 2, 3, 4]); + expect(result.col('B').toArray()).toEqual([null, 10, 20, 30, 40]); + }); + + test('should shift values forward with custom periods', () => { + const df = DataFrame.create({ + A: [1, 2, 3, 4, 5], + B: [10, 20, 30, 40, 50], + }); + + const result = df.shift(2); + + // Check that values are shifted forward by 2 positions + expect(result.col('A').toArray()).toEqual([null, null, 1, 2, 3]); + expect(result.col('B').toArray()).toEqual([null, null, 10, 20, 30]); + }); + + test('should shift values backward with negative periods', () => { + const df = DataFrame.create({ + A: [1, 2, 3, 4, 5], + B: [10, 20, 30, 40, 50], + }); + + const result = df.shift(-2); + + // Check that values are shifted backward by 2 positions + expect(result.col('A').toArray()).toEqual([3, 4, 5, null, null]); + expect(result.col('B').toArray()).toEqual([30, 40, 50, null, null]); + }); + + test('should use custom fill value', () => { + const df = DataFrame.create({ + A: [1, 2, 3, 4, 5], + B: [10, 20, 30, 40, 50], + }); + + const result = df.shift(1, 0); + + // Check that new positions are filled with the specified value (0) + expect(result.col('A').toArray()).toEqual([0, 1, 2, 3, 4]); + expect(result.col('B').toArray()).toEqual([0, 10, 20, 30, 40]); + }); +}); + +describe('pctChange', () => { + test('should calculate percentage change with default periods', () => { + const df = DataFrame.create({ + A: [1, 2, 4, 8, 16], + }); + + const result = df.pctChange(); + + // Check that all columns are preserved + expect(result.columns).toEqual(df.columns); + + // Check that percentage changes are calculated correctly + // Formula: (current - previous) / previous + const pctChanges = result.col('A').toArray(); + expect(pctChanges[0]).toBeNull(); // First value is always null + expect(pctChanges[1]).toBeCloseTo((2 - 1) / 1); // 100% + expect(pctChanges[2]).toBeCloseTo((4 - 2) / 2); // 100% + expect(pctChanges[3]).toBeCloseTo((8 - 4) / 4); // 100% + expect(pctChanges[4]).toBeCloseTo((16 - 8) / 8); // 100% + }); + + test('should calculate percentage change with custom periods', () => { + const df = DataFrame.create({ + A: [1, 2, 4, 8, 16, 32], + }); + + const result = df.pctChange(2); + + // Check that percentage changes are calculated correctly with period 2 + const pctChanges = result.col('A').toArray(); + expect(pctChanges[0]).toBeNull(); // First two values are null + expect(pctChanges[1]).toBeNull(); + expect(pctChanges[2]).toBeCloseTo((4 - 1) / 1); // 300% + expect(pctChanges[3]).toBeCloseTo((8 - 2) / 2); // 300% + expect(pctChanges[4]).toBeCloseTo((16 - 4) / 4); // 300% + expect(pctChanges[5]).toBeCloseTo((32 - 8) / 8); // 300% + }); + + test('should handle zero values correctly', () => { + const df = DataFrame.create({ + A: [0, 1, 0, 2, 0], + }); + + const result = df.pctChange(); + + // Check that division by zero is handled correctly (should be null) + const pctChanges = result.col('A').toArray(); + expect(pctChanges[0]).toBeNull(); + expect(pctChanges[1]).toBeNull(); // Division by zero should return null + expect(pctChanges[2]).toBeCloseTo((0 - 1) / 1); // -100% + expect(pctChanges[3]).toBeNull(); // Division by zero should return null + expect(pctChanges[4]).toBeCloseTo((0 - 2) / 2); // -100% + }); + + test('should handle NaN values correctly', () => { + const df = DataFrame.create({ + A: [1, NaN, 3, 4, NaN], + }); + + const result = df.pctChange(); + + // Check that NaN values are handled correctly + const pctChanges = result.col('A').toArray(); + expect(pctChanges[0]).toBeNull(); + expect(pctChanges[1]).toBeNull(); // NaN - 1 / 1 = NaN + expect(pctChanges[2]).toBeNull(); // 3 - NaN / NaN = NaN + expect(pctChanges[3]).toBeCloseTo((4 - 3) / 3); // 33.33% + expect(pctChanges[4]).toBeNull(); // NaN - 4 / 4 = NaN + }); +}); diff --git a/test/methods/timeseries/series/expanding.test.js b/test/methods/timeseries/series/expanding.test.js new file mode 100644 index 0000000..8dd6b7a --- /dev/null +++ b/test/methods/timeseries/series/expanding.test.js @@ -0,0 +1,97 @@ +import { describe, expect, test, beforeAll } from 'vitest'; +import { Series } from '../../../../src/core/dataframe/Series'; +import registerSeriesTimeSeries from '../../../../src/methods/timeseries/series/register'; + +// Register timeseries methods before tests +beforeAll(() => { + registerSeriesTimeSeries(Series); + console.log('Series timeseries methods registered successfully'); +}); + +describe('Series.expanding', () => { + test('should apply expanding window with mean aggregation', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.expanding({ + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }); + + // Check that result is a Series + expect(result).toBeInstanceOf(Series); + + // Check name + expect(result.name).toBe('values_expanding'); + + // Check values + const values = result.toArray(); + expect(values[0]).toBeCloseTo(1); // Just the first value + expect(values[1]).toBeCloseTo((1 + 2) / 2); // First two values + expect(values[2]).toBeCloseTo((1 + 2 + 3) / 3); // First three values + expect(values[3]).toBeCloseTo((1 + 2 + 3 + 4) / 4); // First four values + expect(values[4]).toBeCloseTo((1 + 2 + 3 + 4 + 5) / 5); // All values + }); + + test('should apply expanding window with custom minPeriods', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.expanding({ + minPeriods: 3, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }); + + // Check values + const values = result.toArray(); + expect(values[0]).toBeNull(); // Not enough values for minPeriods + expect(values[1]).toBeNull(); // Not enough values for minPeriods + expect(values[2]).toBeCloseTo((1 + 2 + 3) / 3); // First three values (enough for minPeriods) + expect(values[3]).toBeCloseTo((1 + 2 + 3 + 4) / 4); // First four values + expect(values[4]).toBeCloseTo((1 + 2 + 3 + 4 + 5) / 5); // All values + }); + + test('should handle empty series', () => { + const series = Series.create([], { name: 'empty' }); + + const result = series.expanding({ + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }); + + expect(result).toBeInstanceOf(Series); + expect(result.length).toBe(0); + }); + + test('should throw error for invalid options', () => { + const series = Series.create([1, 2, 3, 4, 5]); + + // Missing aggregation + expect(() => series.expanding({})).toThrow( + 'aggregation must be a function', + ); + + // Invalid aggregation + expect(() => + series.expanding({ + aggregation: 'not a function', + }), + ).toThrow('aggregation must be a function'); + }); + + test('should handle NaN and null values', () => { + const series = Series.create([1, NaN, null, 4, 5], { name: 'values' }); + + const result = series.expanding({ + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }); + + // Check values - NaN and null should be filtered out + const values = result.toArray(); + expect(values[0]).toBeCloseTo(1); // Just the first value + expect(values[1]).toBeCloseTo(1); // Only the first value (NaN filtered out) + expect(values[2]).toBeCloseTo(1); // Only the first value (null filtered out) + expect(values[3]).toBeCloseTo((1 + 4) / 2); // First and fourth values + expect(values[4]).toBeCloseTo((1 + 4 + 5) / 3); // First, fourth, and fifth values + }); +}); diff --git a/test/methods/timeseries/series/rolling.test.js b/test/methods/timeseries/series/rolling.test.js new file mode 100644 index 0000000..3212334 --- /dev/null +++ b/test/methods/timeseries/series/rolling.test.js @@ -0,0 +1,103 @@ +import { describe, expect, test, beforeAll } from 'vitest'; +import { Series } from '../../../../src/core/dataframe/Series'; +import registerSeriesTimeSeries from '../../../../src/methods/timeseries/series/register'; + +// Register timeseries methods before tests +beforeAll(() => { + registerSeriesTimeSeries(Series); + console.log('Series timeseries methods registered successfully'); +}); + +describe('Series.rolling', () => { + test('should apply rolling window with mean aggregation', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.rolling({ + window: 3, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }); + + // Check that result is a Series + expect(result).toBeInstanceOf(Series); + + // Check name + expect(result.name).toBe('values_rolling'); + + // Check values + const values = result.toArray(); + expect(values[0]).toBeNull(); // Not enough values for window + expect(values[1]).toBeNull(); // Not enough values for window + expect(values[2]).toBeCloseTo((1 + 2 + 3) / 3); // First complete window + expect(values[3]).toBeCloseTo((2 + 3 + 4) / 3); + expect(values[4]).toBeCloseTo((3 + 4 + 5) / 3); + }); + + test('should apply rolling window with custom minPeriods', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.rolling({ + window: 3, + minPeriods: 2, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }); + + // Check values + const values = result.toArray(); + expect(values[0]).toBeNull(); // Not enough values for minPeriods + expect(values[1]).toBeCloseTo((1 + 2) / 2); // Enough for minPeriods + expect(values[2]).toBeCloseTo((1 + 2 + 3) / 3); + expect(values[3]).toBeCloseTo((2 + 3 + 4) / 3); + expect(values[4]).toBeCloseTo((3 + 4 + 5) / 3); + }); + + test('should handle empty series', () => { + const series = Series.create([], { name: 'empty' }); + + const result = series.rolling({ + window: 3, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }); + + expect(result).toBeInstanceOf(Series); + expect(result.length).toBe(0); + }); + + test('should throw error for invalid options', () => { + const series = Series.create([1, 2, 3, 4, 5]); + + // Missing window + expect(() => + series.rolling({ + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }), + ).toThrow('window must be a positive number'); + + // Invalid window + expect(() => + series.rolling({ + window: -1, + aggregation: (values) => + values.reduce((sum, v) => sum + v, 0) / values.length, + }), + ).toThrow('window must be a positive number'); + + // Missing aggregation + expect(() => + series.rolling({ + window: 3, + }), + ).toThrow('aggregation must be a function'); + + // Invalid aggregation + expect(() => + series.rolling({ + window: 3, + aggregation: 'not a function', + }), + ).toThrow('aggregation must be a function'); + }); +}); diff --git a/test/methods/timeseries/series/shift.test.js b/test/methods/timeseries/series/shift.test.js new file mode 100644 index 0000000..c8c7993 --- /dev/null +++ b/test/methods/timeseries/series/shift.test.js @@ -0,0 +1,150 @@ +import { describe, expect, test, beforeAll } from 'vitest'; +import { Series } from '../../../../src/core/dataframe/Series'; +import registerSeriesTimeSeries from '../../../../src/methods/timeseries/series/register'; + +// Register timeseries methods before tests +beforeAll(() => { + registerSeriesTimeSeries(Series); + console.log('Series timeseries methods registered successfully'); +}); + +describe('Series.shift', () => { + test('should shift values forward by default', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.shift(); + + // Check that result is a Series + expect(result).toBeInstanceOf(Series); + + // Check name + expect(result.name).toBe('values'); + + // Check values + const values = result.toArray(); + expect(values).toEqual([null, 1, 2, 3, 4]); + }); + + test('should shift values forward with custom periods', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.shift(2); + + // Check values + const values = result.toArray(); + expect(values).toEqual([null, null, 1, 2, 3]); + }); + + test('should shift values backward with negative periods', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.shift(-2); + + // Check values + const values = result.toArray(); + expect(values).toEqual([3, 4, 5, null, null]); + }); + + test('should use custom fill value', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.shift(1, 0); + + // Check values + const values = result.toArray(); + expect(values).toEqual([0, 1, 2, 3, 4]); + }); + + test('should return copy of series when shift is 0', () => { + const series = Series.create([1, 2, 3, 4, 5], { name: 'values' }); + + const result = series.shift(0); + + // Check values + const values = result.toArray(); + expect(values).toEqual([1, 2, 3, 4, 5]); + }); + + test('should handle empty series', () => { + const series = Series.create([], { name: 'empty' }); + + const result = series.shift(); + + expect(result).toBeInstanceOf(Series); + expect(result.length).toBe(0); + }); +}); + +describe('Series.pctChange', () => { + test('should calculate percentage change with default periods', () => { + const series = Series.create([1, 2, 4, 8, 16], { name: 'values' }); + + const result = series.pctChange(); + + // Check that result is a Series + expect(result).toBeInstanceOf(Series); + + // Check name + expect(result.name).toBe('values_pct_change'); + + // Check values + const values = result.toArray(); + expect(values[0]).toBeNull(); // First value is always null + expect(values[1]).toBeCloseTo((2 - 1) / 1); // 100% + expect(values[2]).toBeCloseTo((4 - 2) / 2); // 100% + expect(values[3]).toBeCloseTo((8 - 4) / 4); // 100% + expect(values[4]).toBeCloseTo((16 - 8) / 8); // 100% + }); + + test('should calculate percentage change with custom periods', () => { + const series = Series.create([1, 2, 4, 8, 16, 32], { name: 'values' }); + + const result = series.pctChange(2); + + // Check values + const values = result.toArray(); + expect(values[0]).toBeNull(); // First value is null + expect(values[1]).toBeNull(); // Second value is null + expect(values[2]).toBeCloseTo((4 - 1) / 1); // 300% + expect(values[3]).toBeCloseTo((8 - 2) / 2); // 300% + expect(values[4]).toBeCloseTo((16 - 4) / 4); // 300% + expect(values[5]).toBeCloseTo((32 - 8) / 8); // 300% + }); + + test('should handle zero values by returning null', () => { + const series = Series.create([0, 1, 0, 3, 4], { name: 'values' }); + + const result = series.pctChange(); + + // Check values + const values = result.toArray(); + expect(values[0]).toBeNull(); // First value is always null + expect(values[1]).toBeNull(); // Previous value is 0, should be null + expect(values[2]).toBeNull(); // Previous value is 1, current is 0 + expect(values[3]).toBeNull(); // Previous value is 0, should be null + expect(values[4]).toBeCloseTo((4 - 3) / 3); // ~33.3% + }); + + test('should handle NaN values', () => { + const series = Series.create([1, NaN, 3, 4, 5], { name: 'values' }); + + const result = series.pctChange(); + + // Check values + const values = result.toArray(); + expect(values[0]).toBeNull(); // First value is always null + expect(values[1]).toBeNull(); // Current value is NaN + expect(values[2]).toBeNull(); // Previous value is NaN + expect(values[3]).toBeCloseTo((4 - 3) / 3); // ~33.3% + expect(values[4]).toBeCloseTo((5 - 4) / 4); // 25% + }); + + test('should handle empty series', () => { + const series = Series.create([], { name: 'empty' }); + + const result = series.pctChange(); + + expect(result).toBeInstanceOf(Series); + expect(result.length).toBe(0); + }); +});