reconstruct
This commit is contained in:
parent
20002030ad
commit
ca8bded949
17 changed files with 1268 additions and 1110 deletions
File diff suppressed because one or more lines are too long
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "analytics-api",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"express": "^4.21.2",
|
||||
"lodash": "^4.17.21",
|
||||
"mathjs": "^14.6.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"concurrently": "^9.2.1",
|
||||
"jest": "^30.1.3",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"ts-jest": "^29.4.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
400
server.ts
400
server.ts
|
|
@ -6,30 +6,25 @@
|
|||
import express from 'express';
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import * as math from 'mathjs';
|
||||
import * as _ from 'lodash';
|
||||
import cors from 'cors'; // <-- 1. IMPORT THE CORS PACKAGE
|
||||
import cors from 'cors';
|
||||
|
||||
// Assuming these files exist in the same directory
|
||||
// import { KMeans, KMeansOptions } from './kmeans';
|
||||
// import { getWeekNumber, getSameWeekDayLastYear } from './time-helper';
|
||||
// import { calculateLinearRegression, generateForecast, calculatePredictionIntervals, ForecastResult } from './prediction';
|
||||
import { SignalProcessor, SmoothingOptions, EdgeDetectionOptions } from './signal_processing_convolution';
|
||||
import { TimeSeriesAnalyzer, ARIMAOptions } from './timeseries';
|
||||
import { AnalysisPipelines } from './analysis_pipelines';
|
||||
import { convolve1D, convolve2D, ConvolutionKernels } from './convolution';
|
||||
|
||||
// Dummy interfaces/classes if the files are not present, to prevent compile errors
|
||||
interface KMeansOptions {}
|
||||
class KMeans { constructor(p: any, n: any, o: any) {}; run = () => ({ clusters: [] }) }
|
||||
const getWeekNumber = (d: string) => 1;
|
||||
const getSameWeekDayLastYear = (d: string) => new Date().toISOString();
|
||||
interface ForecastResult {}
|
||||
const calculateLinearRegression = (v: any) => ({slope: 1, intercept: 0});
|
||||
const generateForecast = (m: any, l: any, p: any) => [];
|
||||
const calculatePredictionIntervals = (v: any, m: any, f: any) => [];
|
||||
|
||||
import { SignalProcessor, SmoothingOptions, EdgeDetectionOptions } from './services/signal_processing_convolution';
|
||||
import { TimeSeriesAnalyzer, ARIMAOptions } from './services/timeseries';
|
||||
import { AnalysisPipelines } from './services/analysis_pipelines';
|
||||
import { convolve1D, convolve2D, ConvolutionKernels } from './services/convolution';
|
||||
import { DataSeries, DataMatrix, Condition, ApiResponse } from './types/index';
|
||||
import { handleError, validateSeries, validateMatrix } from './services/analytics_engine';
|
||||
import { ForecastResult } from './services/prediction';
|
||||
import { analytics } from './services/analytics_engine';
|
||||
import { purchaseRate, liftValue, costRatio, grossMarginRate, averageSpendPerCustomer, purchaseIndex } from './services/retail_metrics';
|
||||
import { RollingWindow } from './services/rolling_window';
|
||||
import { pivotTable, PivotOptions } from './services/pivot_table';
|
||||
|
||||
// Initialize Express app
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cors()); // <-- 2. ENABLE CORS FOR ALL ROUTES
|
||||
|
|
@ -56,301 +51,6 @@ const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|||
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
|
||||
// ========================================
|
||||
// TYPE DEFINITIONS
|
||||
// ========================================
|
||||
|
||||
interface DataSeries {
|
||||
values: number[];
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
interface DataMatrix {
|
||||
data: number[][];
|
||||
columns?: string[];
|
||||
rows?: string[];
|
||||
}
|
||||
interface Condition {
|
||||
field: string;
|
||||
operator: '>' | '<' | '=' | '>=' | '<=' | '!=';
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// HELPER FUNCTIONS
|
||||
// ========================================
|
||||
|
||||
const handleError = (error: unknown): string => {
|
||||
return error instanceof Error ? error.message : 'Unknown error';
|
||||
};
|
||||
const validateSeries = (series: DataSeries): void => {
|
||||
if (!series || !Array.isArray(series.values) || series.values.length === 0) {
|
||||
throw new Error('Series must contain at least one value');
|
||||
}
|
||||
};
|
||||
|
||||
const validateMatrix = (matrix: DataMatrix): void => {
|
||||
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) {
|
||||
throw new Error('Matrix must contain at least one row');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A helper class to provide a fluent API for rolling window calculations.
|
||||
*/
|
||||
class RollingWindow {
|
||||
private windows: number[][];
|
||||
|
||||
constructor(windows: number[][]) {
|
||||
this.windows = windows;
|
||||
}
|
||||
|
||||
mean(): number[] {
|
||||
return this.windows.map(window => Number(math.mean(window)));
|
||||
}
|
||||
|
||||
sum(): number[] {
|
||||
return this.windows.map(window => _.sum(window));
|
||||
}
|
||||
|
||||
min(): number[] {
|
||||
return this.windows.map(window => Math.min(...window));
|
||||
}
|
||||
|
||||
max(): number[] {
|
||||
return this.windows.map(window => Math.max(...window));
|
||||
}
|
||||
|
||||
toArray(): number[][] {
|
||||
return this.windows;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ANALYTICS ENGINE (Simplified)
|
||||
// ========================================
|
||||
|
||||
class AnalyticsEngine {
|
||||
|
||||
private applyConditions(series: DataSeries, conditions: Condition[] = []): number[] {
|
||||
if (conditions.length === 0) return series.values;
|
||||
return series.values; // TODO: Implement filtering
|
||||
}
|
||||
|
||||
// Basic statistical functions
|
||||
unique(series: DataSeries): number[] {
|
||||
validateSeries(series);
|
||||
return _.uniq(series.values);
|
||||
}
|
||||
|
||||
mean(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Number(math.mean(filteredValues));
|
||||
}
|
||||
|
||||
count(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return filteredValues.length;
|
||||
}
|
||||
|
||||
variance(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Number(math.variance(filteredValues));
|
||||
}
|
||||
|
||||
standardDeviation(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Number(math.std(filteredValues));
|
||||
}
|
||||
|
||||
percentile(series: DataSeries, percent: number, ascending: boolean = true, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
|
||||
const sorted = ascending ? _.sortBy(filteredValues) : _.sortBy(filteredValues).reverse();
|
||||
const index = (percent / 100) * (sorted.length - 1);
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
const weight = index % 1;
|
||||
|
||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||
}
|
||||
|
||||
median(series: DataSeries, conditions: Condition[] = []): number {
|
||||
return this.percentile(series, 50, true, conditions);
|
||||
}
|
||||
|
||||
mode(series: DataSeries, conditions: Condition[] = []): number[] {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
const frequency = _.countBy(filteredValues);
|
||||
const maxFreq = Math.max(...Object.values(frequency));
|
||||
|
||||
return Object.keys(frequency)
|
||||
.filter(key => frequency[key] === maxFreq)
|
||||
.map(Number);
|
||||
}
|
||||
|
||||
max(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Math.max(...filteredValues);
|
||||
}
|
||||
|
||||
min(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Math.min(...filteredValues);
|
||||
}
|
||||
|
||||
correlation(series1: DataSeries, series2: DataSeries): number {
|
||||
validateSeries(series1);
|
||||
validateSeries(series2);
|
||||
|
||||
if (series1.values.length !== series2.values.length) {
|
||||
throw new Error('Series must have same length for correlation');
|
||||
}
|
||||
|
||||
const x = series1.values;
|
||||
const y = series2.values;
|
||||
const n = x.length;
|
||||
|
||||
const sumX = _.sum(x);
|
||||
const sumY = _.sum(y);
|
||||
const sumXY = _.sum(x.map((xi, i) => xi * y[i]));
|
||||
const sumX2 = _.sum(x.map(xi => xi * xi));
|
||||
const sumY2 = _.sum(y.map(yi => yi * yi));
|
||||
|
||||
const numerator = n * sumXY - sumX * sumY;
|
||||
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
// Rolling window functions
|
||||
rolling(series: DataSeries, windowSize: number): RollingWindow {
|
||||
validateSeries(series);
|
||||
if (windowSize <= 0) {
|
||||
throw new Error('Window size must be a positive number.');
|
||||
}
|
||||
if (series.values.length < windowSize) {
|
||||
return new RollingWindow([]);
|
||||
}
|
||||
|
||||
const windows: number[][] = [];
|
||||
for (let i = 0; i <= series.values.length - windowSize; i++) {
|
||||
const window = series.values.slice(i, i + windowSize);
|
||||
windows.push(window);
|
||||
}
|
||||
return new RollingWindow(windows);
|
||||
}
|
||||
|
||||
movingAverage(series: DataSeries, windowSize: number): number[] {
|
||||
return this.rolling(series, windowSize).mean();
|
||||
}
|
||||
|
||||
// K-means wrapper (uses imported KMeans class)
|
||||
kmeans(matrix: DataMatrix, nClusters: number, options: KMeansOptions = {}): { clusters: number[][][], centroids: number[][] } {
|
||||
validateMatrix(matrix);
|
||||
const points: number[][] = matrix.data;
|
||||
|
||||
// Use the new MiniBatchKMeans class
|
||||
const kmeans = new KMeans(points, nClusters, options);
|
||||
const result = kmeans.run();
|
||||
|
||||
const centroids = result.clusters.map(c => (c as any).centroid);
|
||||
const clusters = result.clusters.map(c => (c as any).points);
|
||||
|
||||
return { clusters, centroids };
|
||||
}
|
||||
|
||||
// Time helper wrapper functions
|
||||
getWeekNumber(dateString: string): number {
|
||||
return getWeekNumber(dateString);
|
||||
}
|
||||
|
||||
getSameWeekDayLastYear(dateString: string): string {
|
||||
return getSameWeekDayLastYear(dateString);
|
||||
}
|
||||
|
||||
// Retail functions
|
||||
purchaseRate(productPurchases: number, totalTransactions: number): number {
|
||||
if (totalTransactions === 0) throw new Error('Total transactions cannot be zero');
|
||||
return (productPurchases / totalTransactions) * 100;
|
||||
}
|
||||
|
||||
liftValue(jointPurchaseRate: number, productAPurchaseRate: number, productBPurchaseRate: number): number {
|
||||
const expectedJointRate = productAPurchaseRate * productBPurchaseRate;
|
||||
if (expectedJointRate === 0) throw new Error('Expected joint rate cannot be zero');
|
||||
return jointPurchaseRate / expectedJointRate;
|
||||
}
|
||||
|
||||
costRatio(cost: number, salePrice: number): number {
|
||||
if (salePrice === 0) throw new Error('Sale price cannot be zero');
|
||||
return cost / salePrice;
|
||||
}
|
||||
|
||||
grossMarginRate(salePrice: number, cost: number): number {
|
||||
if (salePrice === 0) throw new Error('Sale price cannot be zero');
|
||||
return (salePrice - cost) / salePrice;
|
||||
}
|
||||
|
||||
averageSpendPerCustomer(totalRevenue: number, numberOfCustomers: number): number {
|
||||
if (numberOfCustomers === 0) {
|
||||
throw new Error('Number of customers cannot be zero');
|
||||
}
|
||||
return totalRevenue / numberOfCustomers;
|
||||
}
|
||||
|
||||
purchaseIndex(totalItemsSold: number, numberOfCustomers: number): number {
|
||||
if (numberOfCustomers === 0) {
|
||||
throw new Error('Number of customers cannot be zero');
|
||||
}
|
||||
return (totalItemsSold / numberOfCustomers) * 1000;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Prediction functions
|
||||
// ========================================
|
||||
|
||||
timeSeriesForecast(series: DataSeries, forecastPeriods: number): ForecastResult {
|
||||
validateSeries(series);
|
||||
|
||||
const model = calculateLinearRegression(series.values);
|
||||
const forecast = generateForecast(model, series.values.length, forecastPeriods);
|
||||
const predictionIntervals = calculatePredictionIntervals(series.values, model, forecast);
|
||||
|
||||
return {
|
||||
forecast,
|
||||
predictionIntervals,
|
||||
modelParameters: {
|
||||
slope: model.slope,
|
||||
intercept: model.intercept,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize analytics engine
|
||||
const analytics = new AnalyticsEngine();
|
||||
|
||||
// ========================================
|
||||
// API ROUTES
|
||||
// ========================================
|
||||
|
|
@ -779,6 +479,45 @@ app.post('/api/correlation', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/pivot-table:
|
||||
* post:
|
||||
* summary: Generate a pivot table from records
|
||||
* description: Returns a pivot table based on the provided data and options
|
||||
* tags: [Data Transformation]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* description: Array of records to pivot
|
||||
* options:
|
||||
* $ref: '#/components/schemas/PivotOptions'
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Pivot table generated successfully
|
||||
* '400':
|
||||
* description: Invalid input data
|
||||
*/
|
||||
app.post('/api/pivot-table', (req, res) => {
|
||||
try {
|
||||
const { data, options } = req.body;
|
||||
// You can pass analytics.mean, analytics.count, etc. as options.aggFunc if needed
|
||||
const result = pivotTable(data, options);
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
const errorMessage = handleError(error);
|
||||
res.status(400).json({ success: false, error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/series/moving-average:
|
||||
|
|
@ -1150,7 +889,7 @@ app.post('/api/time/same-day-last-year', (req, res) => {
|
|||
*/
|
||||
app.post('/api/retail/purchase-rate', (req, res) => {
|
||||
try {
|
||||
const result = analytics.purchaseRate(req.body.productPurchases, req.body.totalTransactions);
|
||||
const result = purchaseRate(req.body.productPurchases, req.body.totalTransactions);
|
||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||
} catch (error) {
|
||||
const errorMessage = handleError(error);
|
||||
|
|
@ -1192,7 +931,7 @@ app.post('/api/retail/purchase-rate', (req, res) => {
|
|||
*/
|
||||
app.post('/api/retail/lift-value', (req, res) => {
|
||||
try {
|
||||
const result = analytics.liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate);
|
||||
const result = liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate);
|
||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||
} catch (error) {
|
||||
const errorMessage = handleError(error);
|
||||
|
|
@ -1230,7 +969,7 @@ app.post('/api/retail/lift-value', (req, res) => {
|
|||
*/
|
||||
app.post('/api/retail/cost-ratio', (req, res) => {
|
||||
try {
|
||||
const result = analytics.costRatio(req.body.cost, req.body.salePrice);
|
||||
const result = costRatio(req.body.cost, req.body.salePrice);
|
||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||
} catch (error) {
|
||||
const errorMessage = handleError(error);
|
||||
|
|
@ -1268,7 +1007,7 @@ app.post('/api/retail/cost-ratio', (req, res) => {
|
|||
*/
|
||||
app.post('/api/retail/gross-margin', (req, res) => {
|
||||
try {
|
||||
const result = analytics.grossMarginRate(req.body.salePrice, req.body.cost);
|
||||
const result = grossMarginRate(req.body.salePrice, req.body.cost);
|
||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||
} catch (error) {
|
||||
const errorMessage = handleError(error);
|
||||
|
|
@ -1307,7 +1046,7 @@ app.post('/api/retail/gross-margin', (req, res) => {
|
|||
app.post('/api/retail/average-spend', (req, res) => {
|
||||
try {
|
||||
const { totalRevenue, numberOfCustomers } = req.body;
|
||||
const result = analytics.averageSpendPerCustomer(totalRevenue, numberOfCustomers);
|
||||
const result = averageSpendPerCustomer(totalRevenue, numberOfCustomers);
|
||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||
} catch (error) {
|
||||
const errorMessage = handleError(error);
|
||||
|
|
@ -1346,7 +1085,7 @@ app.post('/api/retail/average-spend', (req, res) => {
|
|||
app.post('/api/retail/purchase-index', (req, res) => {
|
||||
try {
|
||||
const { totalItemsSold, numberOfCustomers } = req.body;
|
||||
const result = analytics.purchaseIndex(totalItemsSold, numberOfCustomers);
|
||||
const result = purchaseIndex(totalItemsSold, numberOfCustomers);
|
||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||
} catch (error) {
|
||||
const errorMessage = handleError(error);
|
||||
|
|
@ -1826,6 +1565,29 @@ app.get('/api/kernels/:name', (req, res) => {
|
|||
* s:
|
||||
* type: integer
|
||||
* description: The seasonal period length (e.g., 7 for weekly).
|
||||
* PivotOptions:
|
||||
* type: object
|
||||
* required:
|
||||
* - index
|
||||
* - columns
|
||||
* - values
|
||||
* properties:
|
||||
* index:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Keys to use as row labels
|
||||
* columns:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Keys to use as column labels
|
||||
* values:
|
||||
* type: string
|
||||
* description: Key to aggregate
|
||||
* aggFunc:
|
||||
* type: string
|
||||
* description: Aggregation function name (e.g., "sum", "mean", "count")
|
||||
* ApiResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export interface AutoArimaResult {
|
|||
P: number;
|
||||
D: number;
|
||||
Q: number;
|
||||
s: number; // Correctly included
|
||||
s: number;
|
||||
aic: number;
|
||||
};
|
||||
searchLog: { p: number; d: number; q: number; P: number; D: number; Q: number; s: number; aic: number }[];
|
||||
208
services/analytics_engine.ts
Normal file
208
services/analytics_engine.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import * as math from 'mathjs';
|
||||
import * as _ from 'lodash';
|
||||
import { DataSeries, DataMatrix, Condition, ApiResponse } from '../types/index';
|
||||
import { RollingWindow } from './rolling_window';
|
||||
import { KMeans, KMeansOptions } from './kmeans';
|
||||
import { getWeekNumber, getSameWeekDayLastYear } from './time-helper';
|
||||
import { calculateLinearRegression, generateForecast, calculatePredictionIntervals, ForecastResult } from './prediction';
|
||||
|
||||
export const handleError = (error: unknown): string => {
|
||||
return error instanceof Error ? error.message : 'Unknown error';
|
||||
};
|
||||
export const validateSeries = (series: DataSeries): void => {
|
||||
if (!series || !Array.isArray(series.values) || series.values.length === 0) {
|
||||
throw new Error('Series must contain at least one value');
|
||||
}
|
||||
};
|
||||
|
||||
export const validateMatrix = (matrix: DataMatrix): void => {
|
||||
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) {
|
||||
throw new Error('Matrix must contain at least one row');
|
||||
}
|
||||
};
|
||||
|
||||
export class AnalyticsEngine {
|
||||
|
||||
private applyConditions(series: DataSeries, conditions: Condition[] = []): number[] {
|
||||
if (conditions.length === 0) return series.values;
|
||||
return series.values; // TODO: Implement filtering
|
||||
}
|
||||
|
||||
// Basic statistical functions
|
||||
unique(series: DataSeries): number[] {
|
||||
validateSeries(series);
|
||||
return _.uniq(series.values);
|
||||
}
|
||||
|
||||
mean(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Number(math.mean(filteredValues));
|
||||
}
|
||||
|
||||
count(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return filteredValues.length;
|
||||
}
|
||||
|
||||
distinctCount(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
const uniqueValues = _.uniq(filteredValues);
|
||||
return uniqueValues.length;
|
||||
}
|
||||
|
||||
variance(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Number(math.variance(filteredValues));
|
||||
}
|
||||
|
||||
standardDeviation(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Number(math.std(filteredValues));
|
||||
}
|
||||
|
||||
percentile(series: DataSeries, percent: number, ascending: boolean = true, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
|
||||
const sorted = ascending ? _.sortBy(filteredValues) : _.sortBy(filteredValues).reverse();
|
||||
const index = (percent / 100) * (sorted.length - 1);
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
const weight = index % 1;
|
||||
|
||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||
}
|
||||
|
||||
median(series: DataSeries, conditions: Condition[] = []): number {
|
||||
return this.percentile(series, 50, true, conditions);
|
||||
}
|
||||
|
||||
mode(series: DataSeries, conditions: Condition[] = []): number[] {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
const frequency = _.countBy(filteredValues);
|
||||
const maxFreq = Math.max(...Object.values(frequency));
|
||||
|
||||
return Object.keys(frequency)
|
||||
.filter(key => frequency[key] === maxFreq)
|
||||
.map(Number);
|
||||
}
|
||||
|
||||
max(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Math.max(...filteredValues);
|
||||
}
|
||||
|
||||
min(series: DataSeries, conditions: Condition[] = []): number {
|
||||
validateSeries(series);
|
||||
const filteredValues = this.applyConditions(series, conditions);
|
||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||
return Math.min(...filteredValues);
|
||||
}
|
||||
|
||||
correlation(series1: DataSeries, series2: DataSeries): number {
|
||||
validateSeries(series1);
|
||||
validateSeries(series2);
|
||||
|
||||
if (series1.values.length !== series2.values.length) {
|
||||
throw new Error('Series must have same length for correlation');
|
||||
}
|
||||
|
||||
const x = series1.values;
|
||||
const y = series2.values;
|
||||
const n = x.length;
|
||||
|
||||
const sumX = _.sum(x);
|
||||
const sumY = _.sum(y);
|
||||
const sumXY = _.sum(x.map((xi, i) => xi * y[i]));
|
||||
const sumX2 = _.sum(x.map(xi => xi * xi));
|
||||
const sumY2 = _.sum(y.map(yi => yi * yi));
|
||||
|
||||
const numerator = n * sumXY - sumX * sumY;
|
||||
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
// Rolling window functions
|
||||
rolling(series: DataSeries, windowSize: number): RollingWindow {
|
||||
validateSeries(series);
|
||||
if (windowSize <= 0) {
|
||||
throw new Error('Window size must be a positive number.');
|
||||
}
|
||||
if (series.values.length < windowSize) {
|
||||
return new RollingWindow([]);
|
||||
}
|
||||
|
||||
const windows: number[][] = [];
|
||||
for (let i = 0; i <= series.values.length - windowSize; i++) {
|
||||
const window = series.values.slice(i, i + windowSize);
|
||||
windows.push(window);
|
||||
}
|
||||
return new RollingWindow(windows);
|
||||
}
|
||||
|
||||
movingAverage(series: DataSeries, windowSize: number): number[] {
|
||||
return this.rolling(series, windowSize).mean();
|
||||
}
|
||||
|
||||
// K-means wrapper (uses imported KMeans class)
|
||||
kmeans(matrix: DataMatrix, nClusters: number, options: KMeansOptions = {}): { clusters: number[][][], centroids: number[][] } {
|
||||
validateMatrix(matrix);
|
||||
const points: number[][] = matrix.data;
|
||||
|
||||
// Use the new MiniBatchKMeans class
|
||||
const kmeans = new KMeans(points, nClusters, options);
|
||||
const result = kmeans.run();
|
||||
|
||||
const centroids = result.clusters.map(c => (c as any).centroid);
|
||||
const clusters = result.clusters.map(c => (c as any).points);
|
||||
|
||||
return { clusters, centroids };
|
||||
}
|
||||
|
||||
// Time helper wrapper functions
|
||||
getWeekNumber(dateString: string): number {
|
||||
return getWeekNumber(dateString);
|
||||
}
|
||||
|
||||
getSameWeekDayLastYear(dateString: string): string {
|
||||
return getSameWeekDayLastYear(dateString);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Prediction functions
|
||||
// ========================================
|
||||
|
||||
timeSeriesForecast(series: DataSeries, forecastPeriods: number): ForecastResult {
|
||||
validateSeries(series);
|
||||
|
||||
const model = calculateLinearRegression(series.values);
|
||||
const forecast = generateForecast(model, series.values.length, forecastPeriods);
|
||||
const predictionIntervals = calculatePredictionIntervals(series.values, model, forecast);
|
||||
|
||||
return {
|
||||
forecast,
|
||||
predictionIntervals,
|
||||
modelParameters: {
|
||||
slope: model.slope,
|
||||
intercept: model.intercept,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const analytics = new AnalyticsEngine();
|
||||
|
||||
36
services/pivot_table.ts
Normal file
36
services/pivot_table.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { analytics } from './analytics_engine'; // Import your analytics engine
|
||||
|
||||
export interface PivotOptions {
|
||||
index: string[];
|
||||
columns: string[];
|
||||
values: string;
|
||||
aggFunc?: (items: number[]) => number; // Aggregation function (e.g., analytics.mean)
|
||||
}
|
||||
|
||||
export function pivotTable(
|
||||
data: Record<string, any>[],
|
||||
options: PivotOptions
|
||||
): Record<string, Record<string, number>> {
|
||||
const { index, columns, values, aggFunc = arr => arr.reduce((a, b) => a + b, 0) } = options;
|
||||
const cellMap: Record<string, Record<string, number[]>> = {};
|
||||
|
||||
data.forEach(row => {
|
||||
const rowKey = index.map(k => row[k]).join('|');
|
||||
const colKey = columns.map(k => row[k]).join('|');
|
||||
|
||||
if (!cellMap[rowKey]) cellMap[rowKey] = {};
|
||||
if (!cellMap[rowKey][colKey]) cellMap[rowKey][colKey] = [];
|
||||
cellMap[rowKey][colKey].push(row[values]);
|
||||
});
|
||||
|
||||
// Apply aggregation function to each cell
|
||||
const result: Record<string, Record<string, number>> = {};
|
||||
Object.entries(cellMap).forEach(([rowKey, cols]) => {
|
||||
result[rowKey] = {};
|
||||
Object.entries(cols).forEach(([colKey, valuesArr]) => {
|
||||
result[rowKey][colKey] = aggFunc(valuesArr);
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ export function calculateLinearRegression(yValues: number[]): LinearRegressionMo
|
|||
// Cast the result of math.sum to a Number
|
||||
const correlationNumerator = Number(math.sum(xValues.map((x, i) => (x - meanX) * (yValues[i] - meanY))));
|
||||
|
||||
const correlation = correlationNumerator / ((xValues.length - 1) * stdDevX * stdDevY);
|
||||
const correlation = correlationNumerator / ((xValues.length) * stdDevX * stdDevY);
|
||||
|
||||
const slope = correlation * (stdDevY / stdDevX);
|
||||
const intercept = meanY - slope * meanX;
|
||||
77
services/retail_metrics.ts
Normal file
77
services/retail_metrics.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
export function purchaseIndex(totalItemsSold: number, numberOfCustomers: number): number {
|
||||
if (numberOfCustomers === 0) {
|
||||
throw new Error('Number of customers cannot be zero');
|
||||
}
|
||||
return (totalItemsSold / numberOfCustomers) * 1000;
|
||||
}
|
||||
|
||||
export function purchaseRate(productPurchases: number, totalTransactions: number): number;
|
||||
export function purchaseRate(productPurchases: number[], totalTransactions: number[]): number[];
|
||||
export function purchaseRate(productPurchases: number | number[], totalTransactions: number | number[]): number | number[] {
|
||||
if (Array.isArray(productPurchases) && Array.isArray(totalTransactions)) {
|
||||
if (productPurchases.length !== totalTransactions.length) throw new Error('Arrays must have the same length');
|
||||
return productPurchases.map((pp, i) => purchaseRate(pp, totalTransactions[i]));
|
||||
}
|
||||
if (typeof productPurchases === 'number' && typeof totalTransactions === 'number') {
|
||||
if (totalTransactions === 0) throw new Error('Total transactions cannot be zero');
|
||||
return (productPurchases / totalTransactions) * 100;
|
||||
}
|
||||
throw new Error('Input types must match');
|
||||
}
|
||||
|
||||
export function liftValue(jointPurchaseRate: number, productAPurchaseRate: number, productBPurchaseRate: number): number;
|
||||
export function liftValue(jointPurchaseRate: number[], productAPurchaseRate: number[], productBPurchaseRate: number[]): number[];
|
||||
export function liftValue(jointPurchaseRate: number | number[], productAPurchaseRate: number | number[], productBPurchaseRate: number | number[]): number | number[] {
|
||||
if (Array.isArray(jointPurchaseRate) && Array.isArray(productAPurchaseRate) && Array.isArray(productBPurchaseRate)) {
|
||||
if (jointPurchaseRate.length !== productAPurchaseRate.length || jointPurchaseRate.length !== productBPurchaseRate.length) throw new Error('Arrays must have the same length');
|
||||
return jointPurchaseRate.map((jpr, i) => liftValue(jpr, productAPurchaseRate[i], productBPurchaseRate[i]));
|
||||
}
|
||||
if (typeof jointPurchaseRate === 'number' && typeof productAPurchaseRate === 'number' && typeof productBPurchaseRate === 'number') {
|
||||
const expectedJointRate = productAPurchaseRate * productBPurchaseRate;
|
||||
if (expectedJointRate === 0) throw new Error('Expected joint rate cannot be zero');
|
||||
return jointPurchaseRate / expectedJointRate;
|
||||
}
|
||||
throw new Error('Input types must match');
|
||||
}
|
||||
|
||||
export function costRatio(cost: number, salePrice: number): number;
|
||||
export function costRatio(cost: number[], salePrice: number[]): number[];
|
||||
export function costRatio(cost: number | number[], salePrice: number | number[]): number | number[] {
|
||||
if (Array.isArray(cost) && Array.isArray(salePrice)) {
|
||||
if (cost.length !== salePrice.length) throw new Error('Arrays must have the same length');
|
||||
return cost.map((c, i) => costRatio(c, salePrice[i]));
|
||||
}
|
||||
if (typeof cost === 'number' && typeof salePrice === 'number') {
|
||||
if (salePrice === 0) throw new Error('Sale price cannot be zero');
|
||||
return cost / salePrice;
|
||||
}
|
||||
throw new Error('Input types must match');
|
||||
}
|
||||
|
||||
export function grossMarginRate(salePrice: number, cost: number): number;
|
||||
export function grossMarginRate(salePrice: number[], cost: number[]): number[];
|
||||
export function grossMarginRate(salePrice: number | number[], cost: number | number[]): number | number[] {
|
||||
if (Array.isArray(salePrice) && Array.isArray(cost)) {
|
||||
if (salePrice.length !== cost.length) throw new Error('Arrays must have the same length');
|
||||
return salePrice.map((sp, i) => grossMarginRate(sp, cost[i]));
|
||||
}
|
||||
if (typeof salePrice === 'number' && typeof cost === 'number') {
|
||||
if (salePrice === 0) throw new Error('Sale price cannot be zero');
|
||||
return (salePrice - cost) / salePrice;
|
||||
}
|
||||
throw new Error('Input types must match');
|
||||
}
|
||||
|
||||
export function averageSpendPerCustomer(totalRevenue: number, numberOfCustomers: number): number;
|
||||
export function averageSpendPerCustomer(totalRevenue: number[], numberOfCustomers: number[]): number[];
|
||||
export function averageSpendPerCustomer(totalRevenue: number | number[], numberOfCustomers: number | number[]): number | number[] {
|
||||
if (Array.isArray(totalRevenue) && Array.isArray(numberOfCustomers)) {
|
||||
if (totalRevenue.length !== numberOfCustomers.length) throw new Error('Arrays must have the same length');
|
||||
return totalRevenue.map((tr, i) => averageSpendPerCustomer(tr, numberOfCustomers[i]));
|
||||
}
|
||||
if (typeof totalRevenue === 'number' && typeof numberOfCustomers === 'number') {
|
||||
if (numberOfCustomers === 0) throw new Error('Number of customers cannot be zero');
|
||||
return totalRevenue / numberOfCustomers;
|
||||
}
|
||||
throw new Error('Input types must match');
|
||||
}
|
||||
30
services/rolling_window.ts
Normal file
30
services/rolling_window.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import * as math from 'mathjs';
|
||||
import * as _ from 'lodash';
|
||||
|
||||
export class RollingWindow {
|
||||
private windows: number[][];
|
||||
|
||||
constructor(windows: number[][]) {
|
||||
this.windows = windows;
|
||||
}
|
||||
|
||||
mean(): number[] {
|
||||
return this.windows.map(window => Number(math.mean(window)));
|
||||
}
|
||||
|
||||
sum(): number[] {
|
||||
return this.windows.map(window => _.sum(window));
|
||||
}
|
||||
|
||||
min(): number[] {
|
||||
return this.windows.map(window => Math.min(...window));
|
||||
}
|
||||
|
||||
max(): number[] {
|
||||
return this.windows.map(window => Math.max(...window));
|
||||
}
|
||||
|
||||
toArray(): number[][] {
|
||||
return this.windows;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// time-helpers.ts - Date and time utility functions
|
||||
|
||||
import { getISOWeek, getISODay, subYears, setISOWeek, setISODay, isValid } from 'date-fns';
|
||||
|
||||
export const getWeekNumber = (dateString: string): number => {
|
||||
21
tests/analyticsEngine.test.ts
Normal file
21
tests/analyticsEngine.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { analytics } from '../services/analytics_engine';
|
||||
|
||||
describe('AnalyticsEngine', () => {
|
||||
test('mean returns correct average', () => {
|
||||
const series = { values: [1, 2, 3, 4, 5] };
|
||||
const result = analytics.mean(series);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
test('max returns correct maximum', () => {
|
||||
const series = { values: [1, 2, 3, 4, 5] };
|
||||
const result = analytics.max(series);
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
test('min returns correct minimum', () => {
|
||||
const series = { values: [1, 2, 3, 4, 5] };
|
||||
const result = analytics.min(series);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
});
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./"
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
22
types/index.ts
Normal file
22
types/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export interface DataSeries {
|
||||
values: number[];
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface DataMatrix {
|
||||
data: number[][];
|
||||
columns?: string[];
|
||||
rows?: string[];
|
||||
}
|
||||
|
||||
export interface Condition {
|
||||
field: string;
|
||||
operator: '>' | '<' | '=' | '>=' | '<=' | '!=';
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue