nn-project
Version:
> For Queen's CMPE 452
323 lines (263 loc) • 9.98 kB
JavaScript
// Native
const path = require('path');
// Packages
const mri = require('mri');
const fs = require('fs-extra');
const parse = require('csv-parse/lib/sync');
const stringify = require('csv-stringify/lib/sync');
const chalk = require('chalk');
const untildify = require('untildify');
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const CURRENCY_DECIMAL_PLACES = 2;
const DATE_CELL = 'Date';
const NOT_APPLICABLE = '-';
const VALUE_CELLS = ['Open', 'High', 'Low', 'Close'];
async function main() {
const args = mri(process.argv.slice(2), {
boolean: ['help', 'json', 'csv'],
alias: { help: 'h', json: 'j', csv: 'c' }
});
if (args.help || args.h) {
help();
return process.exit(0);
}
const csvFile = args._[0];
if (!csvFile) {
console.error(error('No data CSV supplied. Please supply a CSV!'));
return process.exit(1);
}
const csvPath = getFilePath(csvFile);
const DEFAULT_PARSER_OPTIONS = {
rowDelimiter: '\n'
};
const csv = parse(((await fs.readFile(csvPath)).toString()), DEFAULT_PARSER_OPTIONS);
const headers = csv[0];
const data = rowsToObjects(csv.slice(1), headers);
await output(
pipe(
sortDataByDate.bind(null, DATE_CELL),
prepareData
)(data)
);
}
function getFilePath(file) {
if (isHomePath(file)) {
return untildify(file)
} else if (isAbsolutePath(file)) {
return path.resolve(file);
}
return path.resolve(process.cwd(), file);
}
function isHomePath(file) {
return !!file.match(/^~/);
}
function isAbsolutePath(file) {
return !!file.match(/(^\.\/|^\.\.\/|\w)/);
}
function rowsToObjects(rows, headers) {
return rows.map(row => (
row.reduce((accum, elem, i) => (
Object.assign(accum, { [headers[i]]: elem })
), {})
));
}
/**
* This function receives an array of object, each of which has a date key. The array is then sorted in ascending
* chronological order.
* @param dateKey - The object key that the date is located at in the array's children
* @param data - The data to sort
* @returns {Array.<Object>} - The sorted array of objects
*/
function sortDataByDate(dateKey, data) {
return data.sort((a, b) => new Date(a[dateKey]) - new Date(b[dateKey]));
}
function prepareData(data) {
return pipe(
percentagesChangeInVolume,
percentagesChangeInPrices,
...movingAverages()
)(data);
}
function numberToCurrencyValue(num) {
return Number(num).toFixed(CURRENCY_DECIMAL_PLACES);
}
const movingAverage = multiplier => data => data.map((row, i) => {
const epoch = 'days';
const avgs = VALUE_CELLS.reduce((avgAccum, cell) => {
const movingAverageCellName = `${cell} Moving Average - ${multiplier} ${epoch}`;
if (i < multiplier - 1) {
avgAccum[movingAverageCellName] = NOT_APPLICABLE;
} else {
const sum = data.slice(i + 1 - multiplier, i + 1).reduce((cellAccum, row) => {
return cellAccum + Number(row[cell]);
}, 0);
const movingAverage = sum / multiplier;
avgAccum[movingAverageCellName] = numberToCurrencyValue(movingAverage);
}
return avgAccum;
}, {});
return Object.assign(row, avgs);
});
const DEFAULT_MOVING_AVERAGES = [5, 50, 100, 200];
function movingAverages() {
const args = mri(process.argv.slice(2), {
string: ['moving-averages']
});
const movingAverageLengths = formatInputtedMovingAverages(args['moving-averages']) || DEFAULT_MOVING_AVERAGES;
return movingAverageLengths.map(len => movingAverage(len));
}
function percentagesChangeInVolume(data) {
const volumeKey = 'Volume';
return data.map((row, i) => {
let change;
if ((i - 1 >= 0) && row[volumeKey] !== NOT_APPLICABLE && data[i - 1][volumeKey] !== NOT_APPLICABLE) {
const oldVolume = Number.parseFloat(data[i - 1][volumeKey].replace(/,/g, ''));
const newVolume = Number.parseFloat(row[volumeKey].replace(/,/g, ''));
change = (newVolume - oldVolume) / oldVolume * 100;
} else {
change = '-';
}
return Object.assign(row, { ['Daily Change in Volume']: change });
})
}
function percentagesChangeInPrices(data) {
const priceKeys = ['Open', 'High', 'Low', 'Close'];
return data.map((row, i) => Object.assign(
row,
priceKeys.reduce((accum, key) => {
let change;
if ((i - 1 > 0) && row[key] !== NOT_APPLICABLE && data[i - 1][key] !== NOT_APPLICABLE) {
const oldValue = Number.parseFloat(data[i - 1][key].replace(/,/g, ''));
const newValue = Number.parseFloat(row[key].replace(/,/g, ''));
change = (newValue - oldValue) / oldValue * 100;
} else {
change = '-';
}
return Object.assign(accum, { [`${key} - Daily Percentage Change`]: change });
}, {})
));
}
function formatInputtedMovingAverages(avgsString) {
if (!avgsString) return null;
return avgsString
.split(',')
.map(a => a.trim())
.map(a => {
if (isNaN(a)) {
console.error(error('Supplied moving averages list contains something other than a number. Pleas supply only comma-separated numbers!'));
return process.exit(1);
}
return Number(a);
});
}
async function output(data) {
const args = mri(process.argv.slice(2), {
boolean: ['json', 'csv'],
alias: { json: 'j', csv: 'c' }
});
if (hasMultipleOutputTypes(args)) {
console.error(error('Multiple output types specified. Please only supply one output type!'));
process.exit(1);
}
const outputFormat = ((args.json || args.j) && 'json') || 'csv';
const filename = getOutputFilename(outputFormat);
switch (outputFormat) {
case 'json':
await saveToFile(filename, convertToJson(data));
break;
default:
await saveToFile(getFilePath(filename), convertToCsv(data));
break;
}
}
function getOutputFilename(extension) {
const args = mri(process.argv.slice(2), {
string: ['output'],
alias: { output: 'o' }
});
const outputFile = args.output || args.o;
if (outputFile) {
if (outputFile.match(new RegExp(`\.${extension}$`))) {
return getFilePath(outputFile);
}
return getFilePath(`${outputFile}.${extension}`);
}
const inputFilePath = getFilePath(args._[0]);
const inputFilename = inputFilePath.split('/').pop().replace(/\.csv$/, '');
return `${inputFilename}.normalized.${extension}`;
}
function convertToJson(data) {
return JSON.stringify(data, null, 4);
}
function convertToCsv(data) {
const headers = Object.keys(data[0]);
const body = data.slice(1).map(row => Object.values(row));
return stringify([headers, ...body]);
}
async function saveToFile(pathToFile, data) {
try {
await fs.writeFile(pathToFile, data);
console.log(`\n${chalk.bold(chalk.green('Success!'))} File saved to ${pathToFile}`)
} catch (e) {
console.log(e);
process.exit(1);
}
}
function hasMultipleOutputTypes(args) {
let count = 0;
if (args.json || args.j) {
count++;
}
if (args.csv || args.c) {
count++;
}
return count > 1;
}
function help() {
console.log(`
${chalk.white('normalize')} [options] <path | file>
path | file A path to a CSV or a CSV filename to load into the normalizer
${chalk.gray('Options:')}
-h, --help Output usage information
-c, --csv Output normalized data as a CSV ${chalk.bold(chalk.white('(default)'))}
-j, --json Output normalized data as a JSON
-o ${chalk.bold(chalk.underline(chalk.white('FILE')))}, -output=${chalk.bold(chalk.underline(chalk.white('FILE')))} The filename for the normalized data. ${chalk.white(chalk.bold('NOTE:'))} The output type file extension will be appended to the supplied filename. ${chalk.white(chalk.bold(`(defaults to ${chalk.underline('<INPUT_FILE>.normalized.<EXTENSION>')})`))}
--moving-averages=${chalk.bold(chalk.underline(chalk.white('AVG_1,...,AVG_N')))} The moving averages to use. ${chalk.white(chalk.bold(`(defaults to ${chalk.underline('5,50,100,200')})`))}
${chalk.gray('Examples:')}
- Normalize a CSV referenced by a relative path
${chalk.cyan('$ normalize my-data.csv')}
- Normalize a CSV referenced by a relative path
${chalk.cyan('$ normalize /Users/Me/Documents/my-data.csv')}
- Specify output to be JSON
${chalk.cyan('$ normalize --json my-data.csv')}
- Specify an output path
${chalk.cyan('$ normalize -o my-normalized-data.csv my-data.csv')}
${chalk.gray('or')}
${chalk.cyan('$ normalize --output="/Users/Me/Documents/my-normalized-data.csv" my-data.csv')}
- Specify custom moving averages
${chalk.cyan('$ normalize --moving-averages="2,3,4,5" my-data.csv')}
`);
}
function error(message) {
return `\n${chalk.red('> Error!')} ${message}`
}
function handleUnexpected(err) {
console.error(error(`An unexpected error occurred!\n ${err.stack}`));
process.exit(1);
}
function handleRejection(err) {
if (err) {
if (err instanceof Error) {
handleUnexpected(err);
} else {
console.error(error(`An unexpected rejection occurred\n ${err}`));
}
} else {
console.error(error('An unexpected empty rejection occurred'));
}
process.exit(1);
}
process.on('unhandledRejection', handleRejection);
process.on('uncaughtException', handleUnexpected);
main().catch(handleRejection);