/* * This file is part of TREB. * * TREB is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any * later version. * * TREB is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along * with TREB. If not, see . * * Copyright 2022-2026 trebco, llc. * info@treb.app * */ import type { FunctionMap } from '../descriptors'; import { type CellValue, type UnionValue, ValueType } from 'treb-base-types'; import { FlattenCellValues } from '../utilities'; import { ArgumentError, ValueError } from '../function-error'; // use a single, static object for base functions /** * some random web resources, more or less helpful: * * http://www.ultimatecalculators.com/future_value_annuity_calculator.html * https://financeformulas.net/Annuity-Due-Payment-from-Present-Value.html * http://www.tvmcalcs.com/tvm/formulas/regular_annuity_formulas */ /** * this function is broken out because we use it in the rate function * (to search). we could reasonably use any of them (we probably should * use the one most likely to be zero -- FV maybe?) * * this is now used in a couple of functions, so it makes sense to leave * it broken out irrespective of what we use for Rate. * */ const payment_function = (rate: number, periods: number, pv = 0, fv = 0, type = 0): number => { if (type) { return -(pv * (rate / (1 - Math.pow(1 + rate, -periods)))) / (1 + rate) - (fv * (1 / ((1 + rate) * ((Math.pow(1 + rate, periods) -1)/rate)))); } return -(pv * rate * Math.pow(1 + rate, periods) + fv * rate) / (Math.pow(1 + rate, periods) - 1); } /** broken out for use in ipmt, ppmt functions */ const fv_function = (rate: number, periods: number, payment: number, pv = 0, type = 0): number => { if (rate === 0) { return -(pv + payment * periods); } if (type) { return (1 + rate) * -payment / rate * (Math.pow(1 + rate, periods) - 1) - pv * Math.pow(1 + rate, periods); } return -payment / rate * (Math.pow(1 + rate, periods) - 1) - pv * Math.pow(1 + rate, periods); }; /** ppmt is calculated as payment less interest payment */ const ipmt_function = (rate: number, period: number, periods: number, pv = 0, fv = 0, type = 0): number => { // invalid if (period < 1) { return NaN; } // if payment is at the start of the period, there's no interest in payment 1 if (period === 1 && type) { return 0; } const total_payment = payment_function(rate, periods, pv, fv, type); const interest = fv_function(rate, period - 1, total_payment, pv, type) * rate; // for payments at start of period, after period 1, we need to discount return type ? interest / (1 + rate) : interest; }; const ppmt_function = (rate: number, period: number, periods: number, pv = 0, fv = 0, type = 0): number => { return payment_function(rate, periods, pv, fv, type) - ipmt_function(rate, period, periods, pv, fv, type); }; export const FinanceFunctionLibrary: FunctionMap = { /* CoupNum: { fn: (settlement: CellValue, maturity: CellValue, frequency: CellValue, basis: CellValue = 0) => { if (typeof settlement !== 'number' || typeof maturity !== 'number' || settlement > maturity ) { return ArgumentError(); } if (frequency !== 1 && frequency !== 2 && frequency !== 4) { return ArgumentError(); } if (basis === 1) { const settlement_date = LotusDate(settlement); const maturity_date = LotusDate(maturity); const comparison = ConstructDate(maturity_date.getUTCFullYear(), settlement_date.getUTCMonth() + 1, settlement_date.getUTCDay()); let years = Math.max(0, maturity_date.getUTCFullYear() - settlement_date.getUTCFullYear()); if (comparison && comparison < maturity) { years += Math.round(maturity - comparison) / DaysInYear(maturity_date.getUTCFullYear()); } return { type: ValueType.number, value: Math.round(years * frequency), }; } return { type: ValueType.number, value: 100, }; } }, */ /* Price: { fn: (settlement: CellValue, maturity: CellValue, rate: CellValue, yld: CellValue, redemption: CellValue, frequency: CellValue, basis: CellValue = 0) => { if (typeof settlement !== 'number' || typeof maturity !== 'number' || typeof rate !== 'number' || typeof yld !== 'number' || typeof redemption !== 'number') { return ArgumentError(); } if (frequency !== 1 && frequency !== 2 && frequency !== 4) { return ArgumentError(); } return { type: ValueType.number, value: 100, }; } }, */ /** * Excel's NPV function is somewhat broken because it assumes the first * (usually negative) cashflow is in year 1, not year 0. so the thing to * do is just use it on the future cashflows and add the initial outlay * as a scalar value. */ NPV: { description: 'Returns the present value of a series of future cashflows', arguments: [ { name: 'Rate' }, { name: 'Cashflow', repeat: true }, ], fn: (rate = 0, ...args: CellValue[]): UnionValue => { let result = 0; const flat = FlattenCellValues(args); for (let i = 0; i < flat.length; i++) { const arg = flat[i]; if (typeof arg === 'number') { result += Math.pow(1 + rate, -(i + 1)) * arg; } } return { type: ValueType.number, value: result, } } }, XNPV: { description: 'returns the NPV of a nonperiodic stream of payments at a given rate', arguments: [ { name: 'Discount rate', }, { name: 'Values', }, { name: 'Dates', }, ], fn: (rate: CellValue, input_values: CellValue[], input_dates: CellValue[]): UnionValue => { if (typeof rate !== 'number') { return ArgumentError(); } input_values = FlattenCellValues(input_values); input_dates = FlattenCellValues(input_dates); // some validation... if (input_values.length !== input_dates.length) { return ArgumentError(); } const values: number[] = []; for (const value of input_values) { if (typeof value !== 'number') { return ArgumentError(); } values.push(value); } const dates: number[] = []; // // "Numbers in dates are truncated to integers." // // https://support.microsoft.com/en-gb/office/xirr-function-de1242ec-6477-445b-b11b-a303ad9adc9d // // what does that mean? rounded? floored? going to assume the latter... for (const date of input_dates) { if (typeof date !== 'number') { return ArgumentError(); } dates.push(Math.floor(date)); } let npv = 0; for (let j = 0; j < values.length; j++) { npv += (values[j] || 0) / Math.pow((1 + rate), (dates[j] - dates[0]) / 365); } return { type: ValueType.number, value: npv, }; } }, XIRR: { description: 'returns the internal rate of return of a nonperiodic stream of payments', arguments: [ { name: 'Values', }, { name: 'Dates', }, { name: 'Guess', default: .1 }, ], fn: (input_values: CellValue[], input_dates: CellValue[], guess = .1): UnionValue => { input_values = FlattenCellValues(input_values); input_dates = FlattenCellValues(input_dates); // some validation... if (input_values.length !== input_dates.length) { return ArgumentError(); } let positive = 0; let negative = 0; const values: number[] = []; for (const value of input_values) { if (typeof value !== 'number') { // console.info('value not number', value); return ArgumentError(); } if (value > 0) { positive++; } if (value < 0) { negative++; } values.push(value); } if (positive <= 0 || negative <= 0) { // console.info('invalid -/+ count', positive, negative); return ArgumentError(); } const dates: number[] = []; // // "Numbers in dates are truncated to integers." // // https://support.microsoft.com/en-gb/office/xirr-function-de1242ec-6477-445b-b11b-a303ad9adc9d // // what does that mean? rounded? floored? going to assume the latter... for (const date of input_dates) { if (typeof date !== 'number') { return ArgumentError(); } dates.push(Math.floor(date)); } const start = dates[0]; for (const date of dates) { if (date < start) { return ArgumentError(); } } // per the above reference link we have max steps = 100 and // resolution threshold = 1e-8 ("0.000001 percent") const step = .1; // initial step const bounds = [ {found: false, value: 0}, {found: false, value: 0}, ]; const count = values.length; for (let i = 0; i < 100; i++) { // calculate npv let npv = 0; for (let j = 0; j < count; j++) { npv += (values[j] || 0) / Math.pow((1 + guess), (dates[j] - dates[0]) / 365); } if (Math.abs(npv) <= 1e-6) { // resolution // console.info(`** found in ${i + 1} steps`) return { type: ValueType.number, value: guess, } } // search space is unbounded, unfortunately. we can expand exponentially // until we have bounds, at which point it's a standard bounded binary search // ...or we can expand linearly, using a reasonable initial step size? if (npv > 0) { bounds[0].value = bounds[0].found ? Math.max(bounds[0].value, guess) : guess; bounds[0].found = true; if (!bounds[1].found) { guess += step; continue; } } else { bounds[1].value = bounds[1].found ? Math.min(bounds[1].value, guess) : guess; bounds[1].found = true; if (!bounds[0].found) { guess -= step; continue; } } guess = bounds[0].value + (bounds[1].value - bounds[0].value) / 2; } return ValueError(); }, }, IRR: { description: 'Calculates the internal rate of return of a series of cashflows', arguments: [ { name: 'Cashflows' }, { name: 'Guess', default: .1 }, ], fn: (args: CellValue[], guess = .1): UnionValue => { const flat = FlattenCellValues(args).map(value => typeof value === 'number' ? value : 0); const step = .1; // initial step const bounds = [ {found: false, value: 0}, {found: false, value: 0}, ]; // FIXME: parameterize max step count, resolution? for (let i = 0; i < 50; i++) { // calculate npv let npv = 0; for (let j = 0; j < flat.length; j++) { npv += Math.pow(1 + guess, -(j + 1)) * flat[j]; } if (Math.abs(npv) <= 0.00125) { // resolution // console.info(`** found in ${i + 1} steps`) return { type: ValueType.number, value: guess, } } // search space is unbounded, unfortunately. we can expand exponentially // until we have bounds, at which point it's a standard bounded binary search // ...or we can expand linearly, using a reasonable initial step size? if (npv > 0) { bounds[0].value = bounds[0].found ? Math.max(bounds[0].value, guess) : guess; bounds[0].found = true; if (!bounds[1].found) { guess += step; continue; } } else { bounds[1].value = bounds[1].found ? Math.min(bounds[1].value, guess) : guess; bounds[1].found = true; if (!bounds[0].found) { guess -= step; continue; } } guess = bounds[0].value + (bounds[1].value - bounds[0].value) / 2; } return { type: ValueType.error, value: 'NUM', } }, }, CUMPRINC: { description: 'Returns cumulative principal paid on a loan between two periods', arguments: [ { name: 'Rate', }, { name: 'Periods', }, { name: 'Present Value' }, { name: 'Start Period' }, { name: 'End Period' }, { name: 'Type', default: 0 }, ], fn: (rate: number, periods: number, pv: number, start: number, end: number, type = 0): UnionValue => { let accum = 0; for (let i = start; i <= end; i++ ) { accum += ppmt_function(rate, i, periods, pv, 0, type); } return { type: ValueType.number, value: accum }; }, }, CUMIPMT: { description: 'Returns cumulative interest paid on a loan between two periods', arguments: [ { name: 'Rate', }, { name: 'Periods', }, { name: 'Present Value' }, { name: 'Start Period' }, { name: 'End Period' }, { name: 'Type', default: 0 }, ], fn: (rate: number, periods: number, pv: number, start: number, end: number, type = 0): UnionValue => { let accum = 0; for (let i = start; i <= end; i++ ) { accum += ipmt_function(rate, i, periods, pv, 0, type); } return { type: ValueType.number, value: accum }; }, }, IPMT: { description: 'Returns the interest portion of a payment', arguments: [ { name: 'Rate', }, { name: 'Period', }, { name: 'Periods', }, { name: 'Present Value', default: 0 }, { name: 'Future Value', default: 0 }, { name: 'Type', default: 0 }, ], fn: (rate: number, period: number, periods: number, pv = 0, fv = 0, type = 0): UnionValue => { return { type: ValueType.number, value: ipmt_function(rate, period, periods, pv, fv, type) }; } }, PPMT: { description: 'Returns the principal portion of a payment', arguments: [ { name: 'Rate', }, { name: 'Period', }, { name: 'Periods', }, { name: 'Present Value', default: 0 }, { name: 'Future Value', default: 0 }, { name: 'Type', default: 0 }, ], fn: (rate: number, period: number, periods: number, pv = 0, fv = 0, type = 0): UnionValue => { return { type: ValueType.number, value: ppmt_function(rate, period, periods, pv, fv, type) }; } }, Rate: { description: 'Returns the interest rate of a loan', arguments: [ { name: 'Periods', }, { name: 'Payment', }, { name: 'Present Value', default: 0 }, { name: 'Future Value', default: 0 }, { name: 'Type', default: 0 }, ], fn: (periods: number, payment: number, pv = 0, fv = 0, type = 0): UnionValue => { let rate = .25; // guess const bounds = [-1, 1]; const steps = 32; // max iterations const epsilon = 1e-6; for (let i = 0; i < steps; i++) { const a = payment_function(rate, periods, pv, fv, type); if (Math.abs(a - payment) <= epsilon) { return { type: ValueType.number, value: rate }; } const b = payment_function(bounds[1], periods, pv, fv, type); if ((payment >= a && payment <= b) || (payment >= b && payment <= a)) { bounds[0] = rate; } else { bounds[1] = rate; } rate = bounds[0] + (bounds[1] - bounds[0]) / 2; /* const test = payment_function(rate, periods, pv, fv, type); console.info("R", rate, "TP", test, payment, "d", Math.abs(payment-test), bounds); if (Math.abs(payment - test) < epsilon) { return { type: ValueType.number, value: rate }; } if ((test < payment && payment > 0) || (test > payment && payment < 0)) { // reduce rate console.info("T=P"); const next_rate = (bounds[1] + rate) / 2; bounds = [rate, bounds[1]]; rate = next_rate; } */ } return { type: ValueType.number, value: rate }; }, }, FV: { description: 'Returns the future value of an investment', arguments: [ { name: 'Rate', }, { name: 'Periods', }, { name: 'Payment', }, { name: 'Present Value', default: 0 }, { name: 'Type', default: 0 }, ], fn: (rate: number, periods: number, payment: number, pv = 0, type = 0): UnionValue => { return { type: ValueType.number, value: fv_function(rate, periods, payment, pv, type), }; }, }, PV: { description: 'Returns the present value of an investment', arguments: [ { name: 'Rate', }, { name: 'Periods', }, { name: 'Payment', }, { name: 'Future Value', default: 0 }, { name: 'Type', default: 0 }, ], fn: (rate: number, periods: number, payment: number, fv = 0, type = 0): UnionValue => { if (rate === 0) { return { type: ValueType.number, value: -(fv + payment * periods), } } if (type) { payment += (fv * (1 / ((1 + rate) * ((Math.pow(1 + rate, periods) -1)/rate)))); return { type: ValueType.number, value: -(payment + payment / rate * (1 - Math.pow(1 + rate, -(periods - 1)))) }; } return { type: ValueType.number, value: -(fv + (payment / rate * (Math.pow(1 + rate, periods) - 1))) / Math.pow(1 + rate, periods) }; }, }, NPER: { description: 'Returns the number of periods of an investment', arguments: [ { name: 'Rate', }, { name: 'Payment', }, { name: 'Present Value', }, { name: 'Future Value', default: 0 }, { name: 'Type', default: 0 }, ], fn: (rate :number, payment: number, pv = 0, fv = 0, type = 0): UnionValue => { if (type) { return { type: ValueType.number, value: 1 + (-Math.log(1 + rate * (1 - pv / -payment)) + Math.log(1 + fv * rate / (-payment * (1 + rate)))) / Math.log(1 + rate) }; } return { type: ValueType.number, value: (Math.log(Math.pow(1 - pv * rate / -payment, -1)) + Math.log(1 + fv * rate / -payment)) / Math.log(1 + rate) }; }, }, PMT: { description: 'Returns the periodic payment of a loan', arguments: [ { name: 'Rate', }, { name: 'Periods', }, { name: 'Present Value', }, { name: 'Future Value', default: 0 }, { name: 'Type', default: 0 }, ], fn: (rate: number, periods: number, pv: number, fv = 0, type = 0): UnionValue => { if (rate === 0) { return { type: ValueType.number, value: -(pv+fv)/periods, } } return { type: ValueType.number, value: payment_function(rate, periods, pv, fv, type), }; }, } };