diff --git a/binder/requirements.txt b/binder/requirements.txt index db18772..0080c1b 100644 --- a/binder/requirements.txt +++ b/binder/requirements.txt @@ -1,6 +1,7 @@ matplotlib networkx>=2.0,<2.7 numpy +numpy-financial openpyxl>=2.6.2 python-dateutil pydot diff --git a/setup.py b/setup.py index c560d05..40c13d5 100644 --- a/setup.py +++ b/setup.py @@ -63,6 +63,7 @@ def changes(): install_requires=[ 'networkx>=2.0,<2.7', 'numpy', + 'numpy-financial', 'openpyxl>=2.6.2', 'python-dateutil', 'ruamel.yaml', diff --git a/src/pycel/lib/financial.py b/src/pycel/lib/financial.py new file mode 100644 index 0000000..7e6b8f6 --- /dev/null +++ b/src/pycel/lib/financial.py @@ -0,0 +1,35 @@ +# -*- coding: UTF-8 -*- +# +# Copyright 2011-2019 by Dirk Gorissen, Stephen Rauch and Contributors +# All rights reserved. +# This file is part of the Pycel Library, Licensed under GPLv3 (the 'License') +# You may not use this work except in compliance with the License. +# You may obtain a copy of the Licence at: +# https://www.gnu.org/licenses/gpl-3.0.en.html + +""" +Python equivalents of Excel financial functions +""" +import numpy_financial as npf + +from pycel.excelutil import flatten + + +def irr(values, guess=None): + # Excel reference: https://support.microsoft.com/en-us/office/ + # irr-function-64925eaa-9988-495b-b290-3ad0c163c1bc + + # currently guess is not used + return npf.irr(list(flatten(values))) + + +def pmt(rate, nper, pv, fv=0, when=0): + # Excel reference: https://support.microsoft.com/en-us/office/ + # pmt-function-0214da64-9a63-4996-bc20-214433fa6441 + return npf.pmt(rate, nper, pv, fv=fv, when=when) + + +def ppmt(rate, per, nper, pv, fv=0, when=0): + # Excel reference: https://support.microsoft.com/en-us/office/ + # ppmt-function-c370d9e3-7749-4ca4-beea-b06c6ac95e1b + return npf.ppmt(rate, per, nper, pv, fv=fv, when=when) diff --git a/src/pycel/lib/lookup.py b/src/pycel/lib/lookup.py index 0c25b2d..d835e42 100644 --- a/src/pycel/lib/lookup.py +++ b/src/pycel/lib/lookup.py @@ -13,6 +13,7 @@ from bisect import bisect_right import numpy as np +from openpyxl.utils import get_column_letter from pycel.excelutil import ( AddressCell, @@ -141,9 +142,19 @@ def compare(idx, val): return result[0] -# def address(value): +def address(row_num, column_num, abs_num=1, style=None, sheet_text=''): # Excel reference: https://support.microsoft.com/en-us/office/ # address-function-d0c26c0d-3991-446b-8de4-ab46431d4f89 + sheet_text = "'" + sheet_text + "'!" if sheet_text else sheet_text + if style == 0: + r = str(row_num) if abs_num in [1, 2] else str([row_num]) + c = str(column_num) if abs_num in [1, 3] else str([column_num]) + return f'{sheet_text}R{r}C{c}' + else: + abs_row = '$' if abs_num in [1, 2] else '' + abs_col = '$' if abs_num in [1, 3] else '' + return f'{sheet_text}{abs_col}{get_column_letter(column_num)}' \ + f'{abs_row}{str(row_num)}' # def areas(value): @@ -174,14 +185,38 @@ def column(ref): return ref.col_idx -# def columns(value): +def columns(values): # Excel reference: https://support.microsoft.com/en-us/office/ # columns-function-4e8e7b4e-e603-43e8-b177-956088fa48ca + if list_like(values): + return len(values[0]) + return 1 -# def filter(value): +def _xlws_filter(values, include, if_empty=VALUE_ERROR): # Excel reference: https://support.microsoft.com/en-us/office/ # filter-function-f4f7cb66-82eb-4767-8f7c-4877ad80c759 + if not list_like(include): + if not isinstance(values, tuple) or len(values) == 1 or len(values[0]) == 1: + return values if include else if_empty + return if_empty + + res = None + if len(values[0]) == len(include[0]) and not len(include) > 1: + transpose = tuple(col for col in zip(*values)) + res = [transpose[i] for i in range(len(transpose)) + if include[0][i]] + res = tuple([col for col in zip(*res)]) + + elif len(values) == len(include) and not len(include[0]) > 1: + res = tuple([values[i] for i in range(len(values)) + if include[i][0]]) + + if res: + return res + if res is None: + return VALUE_ERROR + return if_empty # def formulatext(value): @@ -431,9 +466,12 @@ def row(ref): return ref.row -# def rows(value): +def rows(values): # Excel reference: https://support.microsoft.com/en-us/office/ # rows-function-b592593e-3fc2-47f2-bec1-bda493811597 + if list_like(values): + return len(values) + return 1 # def rtd(value): diff --git a/src/pycel/lib/stats.py b/src/pycel/lib/stats.py index cb41935..da8a2ac 100644 --- a/src/pycel/lib/stats.py +++ b/src/pycel/lib/stats.py @@ -185,14 +185,31 @@ def count(*args): if isinstance(x, (int, float)) and not isinstance(x, bool)) -# def counta(value): +def counta(*args): # Excel reference: https://support.microsoft.com/en-us/office/ # counta-function-7dc98875-d5c1-46f1-9a82-53f3219e2509 + res = 0 + for arg in args: + if list_like(arg): + for row in arg: + for cell in row: + res = res + 1 if cell is not None else res + else: + res = res + 1 if arg else res + return res -# def countblank(value): +def countblank(values): # Excel reference: https://support.microsoft.com/en-us/office/ # countblank-function-6a92d772-675c-4bee-b346-24af6bd3ac22 + res = 0 + if list_like(values): + for row in values: + for cell in row: + res = res + 1 if cell in [None, ''] else res + else: + res = 1 if values in [None, ''] else res + return res def countif(rng, criteria): diff --git a/tests/fixtures/filter.xlsx b/tests/fixtures/filter.xlsx new file mode 100644 index 0000000..9771b09 Binary files /dev/null and b/tests/fixtures/filter.xlsx differ diff --git a/tests/lib/test_financial.py b/tests/lib/test_financial.py new file mode 100644 index 0000000..388a3b4 --- /dev/null +++ b/tests/lib/test_financial.py @@ -0,0 +1,61 @@ +# -*- coding: UTF-8 -*- +# +# Copyright 2011-2021 by Dirk Gorissen, Stephen Rauch and Contributors +# All rights reserved. +# This file is part of the Pycel Library, Licensed under GPLv3 (the 'License') +# You may not use this work except in compliance with the License. +# You may obtain a copy of the Licence at: +# https://www.gnu.org/licenses/gpl-3.0.en.html + +import math + +import pytest + +from pycel.lib.financial import ( + irr, + pmt, + ppmt +) + + +@pytest.mark.parametrize( + 'values, guess, expected', + ( + ((-100, -50, 100, 200, 400), None, 0.671269), + ((-70000, 12000, 15000, 18000, 21000, 26000), + None, 0.086631), + ((-70000, 12000, 15000, 18000, 21000), + None, -0.021245), + ((-70000, 12000, 15000), 0.10, -0.443507) + ) +) +def test_irr(values, guess, expected): + assert math.isclose(irr(values, guess=guess), + expected, abs_tol=1e-4) + + +@pytest.mark.parametrize( + 'rate, nper, pv, fv, when, expected', + ( + (0.05, 12, 100, 400, 0, -36.412705), + (0.00667, 10, 10000, 0, 0, -1037.050788), + (0.00667, 10, 10000, 0, 1, -1030.179490), + (0.005, 216, 0, 50000, 0, -129.0811609) + ) +) +def test_pmt(rate, nper, pv, fv, when, expected): + assert math.isclose(pmt(rate, nper, pv, fv, when), + expected, abs_tol=1e-4) + + +@pytest.mark.parametrize( + 'rate, per, nper, pv, fv, when, expected', + ( + (0.05, 12, 100, 400, 0, 0, -0.262118), + (0.00833, 1, 24, 2000, 0, 0, -75.626160), + (0.08, 10, 10, 200000, 0, 0, -27598.053460) + ) +) +def test_ppmt(rate, per, nper, pv, fv, when, expected): + assert math.isclose(ppmt(rate, per, nper, pv, fv, when), + expected, abs_tol=1e-4) diff --git a/tests/lib/test_lookup.py b/tests/lib/test_lookup.py index 0c80525..57536a2 100644 --- a/tests/lib/test_lookup.py +++ b/tests/lib/test_lookup.py @@ -26,8 +26,10 @@ from pycel.lib.function_helpers import error_string_wrapper, load_to_test_module from pycel.lib.lookup import ( _match, + address, choose, column, + columns, hlookup, index, indirect, @@ -35,6 +37,7 @@ match, offset, row, + rows, vlookup, ) @@ -72,6 +75,24 @@ def test_lookup_ws(fixture_xls_copy): assert indirect == 8 +@pytest.mark.parametrize( + 'row_num, col_num, abs_num, style, sheet_text, expected', + ( + (2, 3, 1, None, '', '$C$2'), + (2, 3, 3, None, '', '$C2'), + (2, 3, 2, None, '', 'C$2'), + (2, 3, 2, False, '', 'R2C[3]'), + (2, 3, 2, True, '', 'C$2'), + (5, 4, 4, True, 'Sheet1', '\'Sheet1\'!D5'), + (5, 4, 1, True, 'Sheet1', '\'Sheet1\'!$D$5'), + (5, 4, 1, None, 'Sheet1', '\'Sheet1\'!$D$5'), + (5, 4, 1, False, 'Sheet1', '\'Sheet1\'!R5C4'), + ) +) +def test_address(row_num, col_num, abs_num, style, sheet_text, expected): + assert address(row_num, col_num, abs_num, style, sheet_text) == expected + + @pytest.mark.parametrize( 'index, data, expected', ( (-1, 'ABCDEFG', VALUE_ERROR), @@ -122,6 +143,25 @@ def test_column(address, expected): assert expected == result +@pytest.mark.parametrize( + 'values, expected', ( + (((1, None, None), (1, 2, None)), 3), + (1, 1), + ("s", 1), + (((1.2, 3.4), (0.4, 5)), 2), + (((None, None, None, None,), ), 4) + ) +) +def test_columns(values, expected): + assert columns(values) == expected + + +def test_xlws_filter(fixture_xls_copy): + compiler = ExcelCompiler(fixture_xls_copy('filter.xlsx')) + result = compiler.validate_serialized() + assert result == {} + + @pytest.mark.parametrize( 'lkup, row_idx, result, approx', ( ('A', 0, VALUE_ERROR, True), @@ -566,6 +606,20 @@ def test_row(address, expected): assert expected == result +@pytest.mark.parametrize( + 'values, expected', ( + (((1, None, None), (1, 2, None)), 2), + (1, 1), + ("s", 1), + (((1.2, 3.4), (0.4, 5)), 2), + (((None, None,), ), 1), + (((1,), (2,), (3,)), 3) + ) +) +def test_rows(values, expected): + assert rows(values) == expected + + @pytest.mark.parametrize( 'lkup, col_idx, result, approx', ( ('A', 0, VALUE_ERROR, True), diff --git a/tests/lib/test_stats.py b/tests/lib/test_stats.py index 55bd170..d7865a3 100644 --- a/tests/lib/test_stats.py +++ b/tests/lib/test_stats.py @@ -32,6 +32,8 @@ averageif, averageifs, count, + counta, + countblank, countif, countifs, forecast, @@ -124,6 +126,33 @@ def test_count(): assert count(data, data[3], data[5], data[7]) +@pytest.mark.parametrize( + 'values, expected', ( + ((((7, 0, 1, None), ), ), 3), + ((((True, False, 0, ''), ), ), 4), + ((True, ), 1), + ((((False, NA_ERROR, VALUE_ERROR), ), ), 3), + (((('', None, 4), (NAME_ERROR, True, 0)), ), 5), + (((('', None, 4), ), 7, ((4, ), (5, )), 10), 6), + ) +) +def test_counta(values, expected): + assert counta(*values) == expected + + +@pytest.mark.parametrize( + 'values, expected', ( + (((7, 0, 1, None), ), 1), + (((True, False, 0, ''), ), 1), + (False, 0), + (((False, NA_ERROR, None), ), 1), + ((('', None, 4), (NAME_ERROR, True, 0)), 2), + ) +) +def test_countblank(values, expected): + assert countblank(values) == expected + + @pytest.mark.parametrize( 'value, criteria, expected', ( (((7, 25, 13, 25), ), '>10', 3),