rangekeeper 0.7.7__tar.gz → 0.7.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/PKG-INFO +15 -8
  2. rangekeeper-0.7.9/README.md +25 -0
  3. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/Rangekeeper.egg-info/PKG-INFO +15 -8
  4. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/pyproject.toml +1 -1
  5. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/flux.py +35 -15
  6. rangekeeper-0.7.9/rangekeeper/formula/financial.py +234 -0
  7. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/tests/test_api.py +56 -50
  8. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/tests/test_formulas.py +23 -11
  9. rangekeeper-0.7.7/README.md +0 -18
  10. rangekeeper-0.7.7/rangekeeper/formula/financial.py +0 -189
  11. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/Rangekeeper.egg-info/SOURCES.txt +0 -0
  12. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/Rangekeeper.egg-info/dependency_links.txt +0 -0
  13. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/Rangekeeper.egg-info/requires.txt +0 -0
  14. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/Rangekeeper.egg-info/top_level.txt +0 -0
  15. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/__init__.py +0 -0
  16. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/api.py +0 -0
  17. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/conftest.py +0 -0
  18. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/distribution.py +0 -0
  19. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/duration.py +0 -0
  20. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/dynamics/__init__.py +0 -0
  21. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/dynamics/black_swan.py +0 -0
  22. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/dynamics/cyclicality.py +0 -0
  23. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/dynamics/market.py +0 -0
  24. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/dynamics/noise.py +0 -0
  25. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/dynamics/trend.py +0 -0
  26. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/dynamics/volatility.py +0 -0
  27. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/extrapolation.py +0 -0
  28. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/format.py +0 -0
  29. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/formula/__init__.py +0 -0
  30. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/graph.py +0 -0
  31. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/measure.py +0 -0
  32. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/policy.py +0 -0
  33. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/projection.py +0 -0
  34. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/segmentation.py +0 -0
  35. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/rangekeeper/space.py +0 -0
  36. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/setup.cfg +0 -0
  37. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/tests/test_dynamics.py +0 -0
  38. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/tests/test_graph.py +0 -0
  39. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/tests/test_measures.py +0 -0
  40. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/tests/test_models.py +0 -0
  41. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/tests/test_modules.py +0 -0
  42. {rangekeeper-0.7.7 → rangekeeper-0.7.9}/tests/test_projections.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Rangekeeper
3
- Version: 0.7.7
3
+ Version: 0.7.9
4
4
  Summary: A Python library assisting financial modelling in real estate asset & development planning, decision-making, cashflow forecasting, and scenario analysis.
5
5
  Author-email: Daniel Fink <danfink@mit.edu>
6
6
  License-Expression: MPL-2.0
@@ -30,16 +30,23 @@ This directory holds the source code for the Rangekeeper Library
30
30
 
31
31
  ## Installation
32
32
  The library is offered through PyPI, and so its installation in other projects can be performed by:
33
- `pip install rangekeeper` or `poetry add rangekeeper`
33
+ `pip install rangekeeper`, `poetry add rangekeeper`, or `uv add rangekeeper`, depending on your virtual environment manager.
34
34
 
35
35
  ## Development
36
- If you wish to contribute to its development, it is recommended to use [Poetry](https://python-poetry.org/) for environment and dependency management:
36
+ If you wish to contribute to its development, it is recommended to use [uv](https://docs.astral.sh/uv/) for
37
+ environment and dependency management:
37
38
 
38
39
  ### Environment Setup
39
40
 
40
- 1. Install poetry, if you haven't yet: <https://python-poetry.org/docs/master/#installing-with-the-official-installer>
41
+ 1. Install uv, if you haven't yet: <https://docs.astral.sh/uv/>
41
42
  2. Clone this repo.
42
- 3. Use a terminal to install poetry packages from the repo's directory: `<path_to_repo>$ poetry install`
43
- 4. If you wish to develop this repo alongside other projects locally, you may install the local Rangekeeper library via `<path_to_repo>$ poetry run pip install -e <path_to_rangekeeper_repo>`,
44
- replacing `<path_to_rangekeeper_repo>` with its location on your system.
45
- 5. Some tests require API access to [Speckle](https://speckle.systems/). It is recommended to use the [Poetry Dotenv Plugin](https://github.com/mpeteuil/poetry-dotenv-plugin) via `poetry self add poetry-dotenv-plugin`, and add a `.env` file in the project's root directory with your `SPECKLE_TOKEN` environment variable.
43
+ 3. Create a virtual environment: `uv venv .venv`
44
+ 4. Activate the virtual environment: `source .venv/bin/activate`
45
+ 5. Install dependencies: `uv pip install -r <(uv pip compile pyproject.toml)`
46
+ 6. Some tests require API access to [Speckle](https://speckle.systems/). It is recommended to use [Python-Dotenv](https://github.com/theskumar/python-dotenv), and add a `.env` file in the project's root directory with your `SPECKLE_TOKEN` environment variable.
47
+
48
+ ### Publishing
49
+ 1. First, remove any previously built packages: `rm -rf dist/`
50
+ 2. Build the package: `uv build`
51
+ 3. Then, publish it to PyPI with your `UV_PUBLISH_TOKEN` recorded in the .env file: `export $(grep -v
52
+ '^#' .env | xargs) && uv publish --token $UV_PUBLISH_TOKEN`
@@ -0,0 +1,25 @@
1
+ # Rangekeeper Source
2
+ This directory holds the source code for the Rangekeeper Library
3
+
4
+ ## Installation
5
+ The library is offered through PyPI, and so its installation in other projects can be performed by:
6
+ `pip install rangekeeper`, `poetry add rangekeeper`, or `uv add rangekeeper`, depending on your virtual environment manager.
7
+
8
+ ## Development
9
+ If you wish to contribute to its development, it is recommended to use [uv](https://docs.astral.sh/uv/) for
10
+ environment and dependency management:
11
+
12
+ ### Environment Setup
13
+
14
+ 1. Install uv, if you haven't yet: <https://docs.astral.sh/uv/>
15
+ 2. Clone this repo.
16
+ 3. Create a virtual environment: `uv venv .venv`
17
+ 4. Activate the virtual environment: `source .venv/bin/activate`
18
+ 5. Install dependencies: `uv pip install -r <(uv pip compile pyproject.toml)`
19
+ 6. Some tests require API access to [Speckle](https://speckle.systems/). It is recommended to use [Python-Dotenv](https://github.com/theskumar/python-dotenv), and add a `.env` file in the project's root directory with your `SPECKLE_TOKEN` environment variable.
20
+
21
+ ### Publishing
22
+ 1. First, remove any previously built packages: `rm -rf dist/`
23
+ 2. Build the package: `uv build`
24
+ 3. Then, publish it to PyPI with your `UV_PUBLISH_TOKEN` recorded in the .env file: `export $(grep -v
25
+ '^#' .env | xargs) && uv publish --token $UV_PUBLISH_TOKEN`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Rangekeeper
3
- Version: 0.7.7
3
+ Version: 0.7.9
4
4
  Summary: A Python library assisting financial modelling in real estate asset & development planning, decision-making, cashflow forecasting, and scenario analysis.
5
5
  Author-email: Daniel Fink <danfink@mit.edu>
6
6
  License-Expression: MPL-2.0
@@ -30,16 +30,23 @@ This directory holds the source code for the Rangekeeper Library
30
30
 
31
31
  ## Installation
32
32
  The library is offered through PyPI, and so its installation in other projects can be performed by:
33
- `pip install rangekeeper` or `poetry add rangekeeper`
33
+ `pip install rangekeeper`, `poetry add rangekeeper`, or `uv add rangekeeper`, depending on your virtual environment manager.
34
34
 
35
35
  ## Development
36
- If you wish to contribute to its development, it is recommended to use [Poetry](https://python-poetry.org/) for environment and dependency management:
36
+ If you wish to contribute to its development, it is recommended to use [uv](https://docs.astral.sh/uv/) for
37
+ environment and dependency management:
37
38
 
38
39
  ### Environment Setup
39
40
 
40
- 1. Install poetry, if you haven't yet: <https://python-poetry.org/docs/master/#installing-with-the-official-installer>
41
+ 1. Install uv, if you haven't yet: <https://docs.astral.sh/uv/>
41
42
  2. Clone this repo.
42
- 3. Use a terminal to install poetry packages from the repo's directory: `<path_to_repo>$ poetry install`
43
- 4. If you wish to develop this repo alongside other projects locally, you may install the local Rangekeeper library via `<path_to_repo>$ poetry run pip install -e <path_to_rangekeeper_repo>`,
44
- replacing `<path_to_rangekeeper_repo>` with its location on your system.
45
- 5. Some tests require API access to [Speckle](https://speckle.systems/). It is recommended to use the [Poetry Dotenv Plugin](https://github.com/mpeteuil/poetry-dotenv-plugin) via `poetry self add poetry-dotenv-plugin`, and add a `.env` file in the project's root directory with your `SPECKLE_TOKEN` environment variable.
43
+ 3. Create a virtual environment: `uv venv .venv`
44
+ 4. Activate the virtual environment: `source .venv/bin/activate`
45
+ 5. Install dependencies: `uv pip install -r <(uv pip compile pyproject.toml)`
46
+ 6. Some tests require API access to [Speckle](https://speckle.systems/). It is recommended to use [Python-Dotenv](https://github.com/theskumar/python-dotenv), and add a `.env` file in the project's root directory with your `SPECKLE_TOKEN` environment variable.
47
+
48
+ ### Publishing
49
+ 1. First, remove any previously built packages: `rm -rf dist/`
50
+ 2. Build the package: `uv build`
51
+ 3. Then, publish it to PyPI with your `UV_PUBLISH_TOKEN` recorded in the .env file: `export $(grep -v
52
+ '^#' .env | xargs) && uv publish --token $UV_PUBLISH_TOKEN`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "Rangekeeper"
3
- version = "0.7.7"
3
+ version = "0.7.9"
4
4
  description = "A Python library assisting financial modelling in real estate asset & development planning, decision-making, cashflow forecasting, and scenario analysis."
5
5
  authors = [
6
6
  { name = "Daniel Fink", email = "danfink@mit.edu" }
@@ -5,7 +5,7 @@ import itertools
5
5
  import json
6
6
  import locale
7
7
  import os
8
- from typing import Dict, Union, Optional, Tuple
8
+ from typing import Dict, Union, Optional, Tuple, List
9
9
 
10
10
  import matplotlib.pyplot as plt
11
11
  import numpy as np
@@ -19,19 +19,22 @@ import rangekeeper as rk
19
19
  def _format_series(
20
20
  series: pd.Series,
21
21
  units: pint.Unit,
22
+ to_datestamps: bool = True,
22
23
  decimals: int = 2,
23
24
  ):
25
+ index = pd.Series(series.index.date, name="date") if to_datestamps else series.index
26
+
24
27
  if units.dimensionality == "[currency]":
25
28
  formatted = pd.Series(
26
29
  data=[str(locale.currency(value, grouping=True)) for value in series],
27
- index=pd.Series(series.index, name="date"),
30
+ index=index,
28
31
  name=series.name,
29
32
  )
30
33
  else:
31
34
  floatfmt = "{:." + str(decimals) + "f}"
32
35
  formatted = pd.Series(
33
36
  data=[str(floatfmt.format(value)) for value in series],
34
- index=pd.Series(series.index, name="date"),
37
+ index=index,
35
38
  name=series.name,
36
39
  )
37
40
 
@@ -133,7 +136,9 @@ class Flow:
133
136
  "Movements: "
134
137
  + os.linesep
135
138
  + _format_series(
136
- series=self.movements, units=self.units, decimals=decimals
139
+ series=self.movements,
140
+ units=self.units,
141
+ decimals=decimals,
137
142
  ).to_markdown(
138
143
  stralign="right",
139
144
  numalign="right",
@@ -393,10 +398,10 @@ class Flow:
393
398
 
394
399
  class Stream:
395
400
  name: str
396
- flows: [Flow]
401
+ flows: List[Flow]
397
402
  frequency: rk.duration.Type
398
- start_date: pd.Timestamp
399
- end_date: pd.Timestamp
403
+ start_date: datetime.date
404
+ end_date: datetime.date
400
405
  frame: pd.DataFrame
401
406
 
402
407
  """
@@ -405,7 +410,7 @@ class Stream:
405
410
 
406
411
  def __init__(
407
412
  self,
408
- flows: [Flow],
413
+ flows: List[Flow],
409
414
  frequency: rk.duration.Type,
410
415
  name: str = None,
411
416
  ):
@@ -437,9 +442,9 @@ class Stream:
437
442
  self.start_date = min(flows_dates)
438
443
  self.end_date = max(flows_dates)
439
444
 
440
- index = rk.duration.Sequence.from_bounds(
441
- include_start=self.start_date, frequency=self.frequency, bound=self.end_date
442
- )
445
+ # index = rk.duration.Sequence.from_bounds(
446
+ # include_start=self.start_date, frequency=self.frequency, bound=self.end_date
447
+ # )
443
448
  self._resampled_flows = [
444
449
  flow.to_periods(frequency=self.frequency) for flow in self.flows
445
450
  ]
@@ -465,10 +470,22 @@ class Stream:
465
470
  for flow in self.flows:
466
471
  series = flow.to_periods(frequency=self.frequency)
467
472
  formatted_flows.append(
468
- _format_series(series=series, units=flow.units, decimals=decimals)
473
+ _format_series(
474
+ series=series,
475
+ units=flow.units,
476
+ to_datestamps=False,
477
+ decimals=decimals,
478
+ )
469
479
  )
470
480
  # formatted_flows = [flow._format_movements(decimals=decimals) for flow in self._resampled_flows]
471
- frame = pd.concat(formatted_flows, axis=1).fillna(0).sort_index()
481
+ frame = (
482
+ pd.concat(
483
+ formatted_flows,
484
+ axis=1,
485
+ )
486
+ .fillna(0)
487
+ .sort_index()
488
+ )
472
489
  return frame
473
490
 
474
491
  def display(
@@ -487,7 +504,10 @@ class Stream:
487
504
  "Flows: "
488
505
  + os.linesep
489
506
  + self._format_flows(decimals=decimals).to_markdown(
490
- tablefmt=tablefmt, stralign="right", numalign="right", floatfmt=floatfmt
507
+ tablefmt=tablefmt,
508
+ stralign="right",
509
+ numalign="right",
510
+ floatfmt=floatfmt,
491
511
  )
492
512
  )
493
513
 
@@ -820,7 +840,7 @@ class Stream:
820
840
 
821
841
  def extend(
822
842
  self,
823
- flows: [Flow],
843
+ flows: List[Flow],
824
844
  ):
825
845
  """
826
846
  Returns a new Stream with additional Flows
@@ -0,0 +1,234 @@
1
+ import enum
2
+ import dataclasses
3
+ import numpy as np
4
+ import numba
5
+ import pandas as pd
6
+
7
+ import rangekeeper as rk
8
+
9
+
10
+ class Balance:
11
+ startings: rk.flux.Flow
12
+ endings: rk.flux.Flow
13
+ overdraft: rk.flux.Flow
14
+
15
+ def __init__(
16
+ self,
17
+ starting: float,
18
+ transactions: rk.flux.Flow,
19
+ frequency: rk.duration.Type,
20
+ name: str = "",
21
+ ):
22
+ """
23
+ Calculate the balance of a financial account given a starting balance and a series of transactions.
24
+ Returns an object with the starting and ending balances, as well as the overdraft amount, as `Flow`s.
25
+ Overdraft is the portion of transactions occurring while the balance is non-positive.
26
+ """
27
+
28
+ transactions = transactions.resample(frequency=frequency)
29
+
30
+ result = self._calculate_balance(
31
+ startings=numba.typed.List([np.float64(starting)]),
32
+ transactions=numba.typed.List(transactions.movements.to_list()),
33
+ )
34
+
35
+ self.startings = rk.flux.Flow(
36
+ movements=pd.Series(
37
+ result[0],
38
+ index=transactions.movements.index,
39
+ ),
40
+ units=transactions.units,
41
+ name=f"{name} Start Balance",
42
+ )
43
+
44
+ self.endings = rk.flux.Flow(
45
+ movements=pd.Series(
46
+ result[1],
47
+ index=transactions.movements.index,
48
+ ),
49
+ units=transactions.units,
50
+ name=f"{name} End Balance",
51
+ )
52
+
53
+ self.overdraft = rk.flux.Flow(
54
+ movements=pd.Series(
55
+ result[2],
56
+ index=transactions.movements.index,
57
+ ),
58
+ units=transactions.units,
59
+ name=f"{name} Overdraft",
60
+ )
61
+
62
+ @classmethod
63
+ def from_flows(
64
+ cls,
65
+ startings: rk.flux.Flow,
66
+ endings: rk.flux.Flow,
67
+ frequency: rk.duration.Type,
68
+ name: str = "",
69
+ ):
70
+ name = startings.name if name is None else name
71
+ if startings.units != endings.units:
72
+ raise ValueError("The units of the starting and ending flows must match.")
73
+ units = startings.units
74
+
75
+ startings = startings.resample(frequency=frequency)
76
+ endings = endings.resample(frequency=frequency)
77
+
78
+ transactions = rk.flux.Flow(
79
+ movements=endings.movements - startings.movements,
80
+ units=units,
81
+ )
82
+ starting = startings.movements.iloc[0]
83
+
84
+ return cls(
85
+ starting=starting,
86
+ transactions=transactions,
87
+ frequency=frequency,
88
+ name=name,
89
+ )
90
+
91
+ @staticmethod
92
+ @numba.jit
93
+ def _calculate_balance(
94
+ startings: numba.typed.List,
95
+ transactions: numba.typed.List,
96
+ ) -> (
97
+ numba.typed.List,
98
+ numba.typed.List,
99
+ numba.typed.List,
100
+ ):
101
+
102
+ endings = numba.typed.List.empty_list(numba.float64)
103
+ overdrafts = numba.typed.List.empty_list(numba.float64)
104
+ for i in range(len(transactions)):
105
+ ending = float(startings[-1] + transactions[i])
106
+
107
+ overdrafts.append(ending if ending < 0 else 0)
108
+ endings.append(ending if ending > 0 else 0)
109
+ startings.append(endings[-1])
110
+
111
+ return (
112
+ startings[:-1],
113
+ endings,
114
+ overdrafts,
115
+ )
116
+
117
+
118
+ class Interest:
119
+ amounts: rk.flux.Flow
120
+ balance: Balance
121
+
122
+ class Type(enum.Enum):
123
+ INTEREST_ONLY = "interest_only"
124
+ COMPOUND = "compound"
125
+ CAPITALIZED = "capitalized"
126
+
127
+ def __init__(
128
+ self,
129
+ rate: float | rk.flux.Flow,
130
+ transactions: rk.flux.Flow,
131
+ frequency: rk.duration.Type,
132
+ type: Type = Type.COMPOUND,
133
+ ):
134
+ """
135
+ Calculate interest expense on a loan or other interest-bearing liability,
136
+ described by a series of transactions.
137
+ Assumes interest is compounded on a positive, outstanding balance per period
138
+ (i.e. draws are positive, payments are negative).
139
+ Make sure the rate is consistent with the frequency.
140
+ """
141
+
142
+ transactions = transactions.resample(frequency=frequency)
143
+
144
+ if isinstance(rate, float):
145
+ rate = rk.flux.Flow.from_projection(
146
+ name="Interest Rates",
147
+ value=rate,
148
+ proj=rk.projection.Extrapolation(
149
+ form=rk.extrapolation.Recurring(),
150
+ sequence=rk.duration.Sequence.from_datestamps(
151
+ datestamps=transactions.movements.index, frequency=frequency
152
+ ),
153
+ ),
154
+ )
155
+ else:
156
+ raise NotImplementedError("Only float rates are supported currently.")
157
+
158
+ if len(transactions.movements) != len(rate.movements):
159
+ raise ValueError(
160
+ "The number of transactions must match the number of rates."
161
+ )
162
+
163
+ startings, endings, amounts = self._calc_interest(
164
+ transactions=numba.typed.List(transactions.movements.to_list()),
165
+ rates=numba.typed.List(rate.movements.to_list()),
166
+ type=type.value,
167
+ )
168
+
169
+ startings = rk.flux.Flow(
170
+ movements=pd.Series(startings, index=transactions.movements.index),
171
+ units=transactions.units,
172
+ )
173
+ endings = rk.flux.Flow(
174
+ movements=pd.Series(endings, index=transactions.movements.index),
175
+ units=transactions.units,
176
+ )
177
+
178
+ self.balance = rk.formula.financial.Balance.from_flows(
179
+ startings=startings,
180
+ endings=endings,
181
+ frequency=frequency,
182
+ )
183
+
184
+ self.amounts = rk.flux.Flow(
185
+ movements=pd.Series(amounts, index=transactions.movements.index),
186
+ units=transactions.units,
187
+ name="Interest",
188
+ )
189
+
190
+ @staticmethod
191
+ @numba.jit
192
+ def _calc_interest(
193
+ transactions: numba.typed.List,
194
+ rates: numba.typed.List,
195
+ type: str,
196
+ ) -> (
197
+ numba.typed.List,
198
+ numba.typed.List,
199
+ numba.typed.List,
200
+ ):
201
+
202
+ startings = numba.typed.List.empty_list(numba.float64)
203
+ endings = numba.typed.List.empty_list(numba.float64)
204
+ amounts = numba.typed.List.empty_list(numba.float64)
205
+
206
+ for i in range(len(transactions)):
207
+ if i == 0:
208
+ startings.append(0)
209
+ else:
210
+ startings.append(endings[-1])
211
+
212
+ principal = startings[i] + transactions[i]
213
+ if type == "interest_only":
214
+ interest = principal * rates[i] if principal > 0 else 0
215
+ endings.append(principal)
216
+
217
+ elif type == "compound":
218
+ interest = principal * rates[i] if principal > 0 else 0
219
+ endings.append(principal + interest)
220
+
221
+ elif type == "capitalized":
222
+ # Since we are capitalizing interest, the amount (draw) must include interest to pay on the principal.
223
+ # Derived from i = r * (P + i)
224
+ interest = (
225
+ (principal * rates[i]) / (1 - rates[i]) if principal > 0 else 0
226
+ )
227
+ endings.append(principal + interest)
228
+
229
+ else:
230
+ raise ValueError(f"Invalid instrument: {type}")
231
+
232
+ amounts.append(interest)
233
+
234
+ return (startings, endings, amounts)
@@ -1,90 +1,93 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
- import pprint as pp
5
3
  # Pytests file.
6
4
  # Note: gathers tests according to a naming convention.
7
5
  # By default any file that is to contain tests must be named starting with 'test_',
8
6
  # classes that hold tests must be named starting with 'Test',
9
7
  # and any function in a file that should be treated as a test must also start with 'test_'.
10
8
  import os
11
- import locale
12
- import multiprocess as mp
13
- from typing import List, Callable, Dict
14
-
15
- import matplotlib
16
- import matplotlib.pyplot as plt
17
- import networkx as nx
18
- from pyvis.network import Network
19
- import plotly.express as px
20
- import plotly.graph_objects as go
9
+ import pprint as pp
10
+
21
11
  # In addition, in order to enable pytest to find all modules,
22
12
  # run tests via a 'python -m pytest tests/<test_file>.py' command from the root directory of this project
23
13
  import pandas as pd
24
- pd.set_option('display.max_columns', None)
25
- import numpy as np
26
- import scipy.stats as ss
27
- import pint
14
+
15
+ pd.set_option("display.max_columns", None)
28
16
 
29
17
  import rangekeeper as rk
30
18
 
31
19
 
32
20
  class TestApi:
33
21
  stream_id = "c0f66c35e3"
34
- speckle = rk.api.Speckle(token=os.getenv('SPECKLE_TOKEN'))
22
+ speckle = rk.api.Speckle(token=os.getenv("SPECKLE_TOKEN"))
35
23
  model = speckle.get_commit(stream_id=stream_id)
36
24
 
37
25
  def test_connection(self):
38
- latest_commit_id = TestApi.speckle.get_latest_commit_id(stream_id=TestApi.stream_id)
39
- print('Latest Commit: {0}'.format(latest_commit_id))
26
+ latest_commit_id = TestApi.speckle.get_latest_commit_id(
27
+ stream_id=TestApi.stream_id
28
+ )
29
+ print("Latest Commit: {0}".format(latest_commit_id))
40
30
 
41
31
  metadata = TestApi.speckle.get_metadata(stream_id=TestApi.stream_id)
42
32
  print(metadata)
43
33
 
44
- assert metadata.name == 'RangekeeperDemo'
34
+ assert metadata.name == "RangekeeperDemo"
45
35
 
46
36
  roots = TestApi.model.get_dynamic_member_names()
47
37
 
48
38
  assert len(roots) == 2
49
- assert '@property' in roots
39
+ assert "@property" in roots
50
40
 
51
41
  def test_conversion(self):
52
- parsed = rk.api.Speckle.parse(base=TestApi.model['@property'])
42
+ parsed = rk.api.Speckle.parse(base=TestApi.model["@property"])
53
43
  root_base = list(parsed.values())[0]
54
- print('Root Base:\n {0}'.format(root_base))
55
- assert root_base.speckle_type == 'Rangekeeper.Entity:Rangekeeper.Assembly'
44
+ print("Root Base:\n {0}".format(root_base))
45
+ assert root_base.speckle_type == "Rangekeeper.Entity:Rangekeeper.Assembly"
56
46
 
57
47
  root_entity = rk.graph.Entity.from_base(root_base)
58
- assert root_entity.name == 'property'
48
+ assert root_entity.name == "property"
59
49
  assert len(root_entity.graph.nodes) == 5
60
50
  assert type(root_entity) == rk.graph.Assembly
61
- print('Root Entity:\n {0}'.format(root_entity))
62
-
63
- buildingA = [node for node in root_entity.graph.nodes if node.name == 'buildingA'][0]
64
- print('BuildingA:\n {0}'.format(buildingA))
65
- buildingA_containment = buildingA.get_relatives(outgoing=True, relationship_type='contains')
66
- print('BuildingA Containment: \n {0}\n'.format(buildingA_containment))
67
-
68
- buildingAresidential = [node for node in buildingA.graph.nodes if node.name == 'buildingAresidential'][0]
69
- print('BuildingAresidential:\n {0}'.format(buildingAresidential))
51
+ print("Root Entity:\n {0}".format(root_entity))
52
+
53
+ buildingA = [
54
+ node for node in root_entity.graph.nodes if node.name == "buildingA"
55
+ ][0]
56
+ print("BuildingA:\n {0}".format(buildingA))
57
+ buildingA_containment = buildingA.get_relatives(
58
+ outgoing=True, relationship_type="contains"
59
+ )
60
+ print("BuildingA Containment: \n {0}\n".format(buildingA_containment))
61
+
62
+ buildingAresidential = [
63
+ node
64
+ for node in buildingA.graph.nodes
65
+ if node.name == "buildingAresidential"
66
+ ][0]
67
+ print("BuildingAresidential:\n {0}".format(buildingAresidential))
70
68
 
71
69
  def test_develop(self):
72
- parsed = rk.api.Speckle.parse(base=TestApi.model['@property'])
70
+ parsed = rk.api.Speckle.parse(base=TestApi.model["@property"])
73
71
  # print('\nParsed: \n{0}'.format(pp.pprint([base['name'] for base in parsed.values()])))
74
72
  # print('\nCount Parsed: \n{0}'.format(len(parsed)))
75
73
 
76
74
  property = rk.api.Speckle.to_rk(
77
- bases=list(parsed.values()),
78
- name='property',
79
- type='property')
75
+ bases=list(parsed.values()), name="property", type="property"
76
+ )
80
77
  # print('\nScenario: \n{0}'.format(scenario))
81
78
  # print('\nCount Scenario: \n{0}'.format(len(scenario.graph.nodes())))
82
79
 
83
80
  spatial_containment = rk.graph.Assembly.from_graph(
84
81
  graph=property.graph.edge_subgraph(
85
- [edge for edge in property.graph.edges(keys=True) if edge[2] == 'spatiallyContains']),
86
- name='spatial_containment',
87
- type='subgraph')
82
+ [
83
+ edge
84
+ for edge in property.graph.edges(keys=True)
85
+ if edge[2] == "spatiallyContains"
86
+ ]
87
+ ),
88
+ name="spatial_containment",
89
+ type="subgraph",
90
+ )
88
91
  # print('\nSpatial Containment: \n{0}'.format(spatial_containment))
89
92
 
90
93
  roots = property.get_roots() # ['spatiallyContains']
@@ -93,7 +96,11 @@ class TestApi:
93
96
  subassemblies = property.get_subassemblies()
94
97
  # print('\nSubassemblies: \n{0}'.format(pp.pprint(list(subassemblies.values()))))
95
98
 
96
- buildingA = [assembly for assembly in subassemblies.values() if assembly['name'] == 'buildingA'][0]
99
+ buildingA = [
100
+ assembly
101
+ for assembly in subassemblies.values()
102
+ if assembly["name"] == "buildingA"
103
+ ][0]
97
104
  # print('\nBuildingA: \n{0}'.format(buildingA))
98
105
 
99
106
  subentities = buildingA.get_subentities()
@@ -106,27 +113,26 @@ class TestApi:
106
113
  hierarchical_layout=False,
107
114
  display=False,
108
115
  height=1600,
109
- )
116
+ )
110
117
 
111
118
  spatial_containment_dict = spatial_containment.to_dict()
112
119
  # print('\nSpatial Containment Dict: \n{0}'.format(pp.pprint(spatial_containment_dict)))
113
120
 
114
- spatial_containment.aggregate(
115
- property='gfa',
116
- label='subtotal_gfa')
121
+ spatial_containment.aggregate(property="gfa", label="subtotal_gfa")
117
122
 
118
- print('\nGFA Aggregation: \n{0}'.format(pp.pprint(spatial_containment.to_dict())))
123
+ print(
124
+ "\nGFA Aggregation: \n{0}".format(pp.pprint(spatial_containment.to_dict()))
125
+ )
119
126
  #
120
- df = pd.DataFrame.from_dict(spatial_containment.to_dict(), orient='index')
127
+ df = pd.DataFrame.from_dict(spatial_containment.to_dict(), orient="index")
121
128
  print(df)
122
129
 
123
130
  foo = spatial_containment.to_DataFrame()
124
131
  #
125
132
 
126
- fig = spatial_containment.sunburst('subtotal_gfa')
133
+ fig = spatial_containment.sunburst("subtotal_gfa")
127
134
  fig.show()
128
135
 
129
-
130
136
  #
131
137
 
132
138
  #
@@ -95,7 +95,7 @@ class Model:
95
95
  frequency=self.params["frequency"],
96
96
  )
97
97
 
98
- self.overdraft = self.equity.overdraft()
98
+ self.overdraft = self.equity.overdraft
99
99
 
100
100
  self.interest = rk.formula.financial.Interest(
101
101
  rate=self.params["interest_rate_pa"]
@@ -122,7 +122,9 @@ class TestFinancial:
122
122
  draws_periods=9,
123
123
  payments=value,
124
124
  payments_start_date=rk.duration.offset(
125
- date=start_date, duration=rk.duration.Type.MONTH, amount=9
125
+ date=start_date,
126
+ duration=rk.duration.Type.MONTH,
127
+ amount=9,
126
128
  ),
127
129
  payments_periods=3,
128
130
  )
@@ -145,6 +147,10 @@ class TestFinancial:
145
147
  frequency=self.params["frequency"],
146
148
  )
147
149
 
150
+ interest.balance.startings.display()
151
+ interest.balance.endings.display()
152
+ interest.amounts.display()
153
+
148
154
  assert interest.balance.endings.movements.iloc[-1] == approx(525580.95)
149
155
  assert interest.amounts.total() == approx(25580.95)
150
156
 
@@ -177,11 +183,13 @@ class TestFinancial:
177
183
  frequency=self.params["frequency"],
178
184
  type=rk.formula.financial.Interest.Type.CAPITALIZED,
179
185
  )
180
- assert interest.balance.endings.movements.iloc[-1] == approx(510577.82)
181
- assert interest.amounts.total() == approx(10577.82)
186
+ # assert interest.balance.endings.movements.iloc[-1] == approx(510577.82)
187
+ # assert interest.amounts.total() == approx(10577.82)
182
188
 
183
189
  interest.balance.startings.display()
184
190
  interest.balance.endings.display()
191
+ interest.amounts.display()
192
+ interest.balance.overdraft.display()
185
193
 
186
194
  def test_balance(self):
187
195
  transactions = rk.flux.Stream(
@@ -196,7 +204,9 @@ class TestFinancial:
196
204
  frequency=self.params["frequency"],
197
205
  type=rk.formula.financial.Interest.Type.CAPITALIZED,
198
206
  )
199
- assert interest.balance.endings.movements.iloc[-1] == approx(-488680.57)
207
+ interest.balance.overdraft.display()
208
+ assert interest.balance.overdraft.movements.iloc[-1] == approx(-333333.33)
209
+ assert interest.balance.overdraft.total() == approx(-488680.57)
200
210
  assert interest.amounts.total() == approx(11319.43)
201
211
 
202
212
  interest.balance.startings.display()
@@ -206,9 +216,7 @@ class TestFinancial:
206
216
  def test_balances(self):
207
217
  transactions = rk.flux.Stream(
208
218
  name="Transactions",
209
- flows=[
210
- self.model.draws.sum().invert(),
211
- ],
219
+ flows=[self.model.draws.sum().invert()],
212
220
  frequency=self.params["frequency"],
213
221
  )
214
222
  equity = rk.formula.financial.Balance(
@@ -221,14 +229,17 @@ class TestFinancial:
221
229
  equity.startings.display()
222
230
  equity.endings.display()
223
231
 
224
- overdraft = equity.overdraft()
232
+ overdraft = equity.overdraft
225
233
  overdraft.display()
226
234
 
227
235
  interest = rk.formula.financial.Interest(
228
236
  rate=self.params["interest_rate_pa"]
229
237
  / rk.duration.Period.yearly_count((self.params["frequency"])),
230
238
  transactions=rk.flux.Stream(
231
- flows=[overdraft.invert(), self.model.payments.invert()],
239
+ flows=[
240
+ overdraft.invert(),
241
+ self.model.payments.invert(),
242
+ ],
232
243
  frequency=self.params["frequency"],
233
244
  ).sum(),
234
245
  frequency=self.params["frequency"],
@@ -239,7 +250,8 @@ class TestFinancial:
239
250
  interest.amounts.display()
240
251
  print(interest.amounts.total())
241
252
 
242
- profit = interest.balance.overdraft(name="Profit").invert()
253
+ profit = interest.balance.overdraft.invert()
254
+ profit.name = "Profit"
243
255
  profit.display()
244
256
 
245
257
  assert profit.total() == approx(671969.16)
@@ -1,18 +0,0 @@
1
- # Rangekeeper Source
2
- This directory holds the source code for the Rangekeeper Library
3
-
4
- ## Installation
5
- The library is offered through PyPI, and so its installation in other projects can be performed by:
6
- `pip install rangekeeper` or `poetry add rangekeeper`
7
-
8
- ## Development
9
- If you wish to contribute to its development, it is recommended to use [Poetry](https://python-poetry.org/) for environment and dependency management:
10
-
11
- ### Environment Setup
12
-
13
- 1. Install poetry, if you haven't yet: <https://python-poetry.org/docs/master/#installing-with-the-official-installer>
14
- 2. Clone this repo.
15
- 3. Use a terminal to install poetry packages from the repo's directory: `<path_to_repo>$ poetry install`
16
- 4. If you wish to develop this repo alongside other projects locally, you may install the local Rangekeeper library via `<path_to_repo>$ poetry run pip install -e <path_to_rangekeeper_repo>`,
17
- replacing `<path_to_rangekeeper_repo>` with its location on your system.
18
- 5. Some tests require API access to [Speckle](https://speckle.systems/). It is recommended to use the [Poetry Dotenv Plugin](https://github.com/mpeteuil/poetry-dotenv-plugin) via `poetry self add poetry-dotenv-plugin`, and add a `.env` file in the project's root directory with your `SPECKLE_TOKEN` environment variable.
@@ -1,189 +0,0 @@
1
- import enum
2
- import dataclasses
3
- import numpy as np
4
- import numba
5
- import pandas as pd
6
-
7
- import rangekeeper as rk
8
-
9
-
10
- class Balance:
11
- startings: rk.flux.Flow
12
- endings: rk.flux.Flow
13
-
14
- def __init__(
15
- self,
16
- starting: float,
17
- transactions: rk.flux.Flow,
18
- frequency: rk.duration.Type,
19
- ):
20
- """
21
- Calculate the balance of a financial account given a starting balance and a series of transactions.
22
- """
23
-
24
- transactions = transactions.resample(frequency=frequency)
25
-
26
- result = self._calculate_balance(
27
- startings=numba.typed.List([np.float64(starting)]),
28
- transactions=numba.typed.List(transactions.movements.to_list()))
29
-
30
- self.startings=rk.flux.Flow(
31
- movements=pd.Series(result[0], index=transactions.movements.index),
32
- units=transactions.units,
33
- name='Start Balance')
34
-
35
- self.endings=rk.flux.Flow(
36
- movements=pd.Series(result[1], index=transactions.movements.index),
37
- units=transactions.units,
38
- name='End Balance')
39
-
40
- @classmethod
41
- def from_flows(
42
- cls,
43
- startings: rk.flux.Flow,
44
- endings: rk.flux.Flow,
45
- ):
46
- balance = super().__new__(cls)
47
- balance.startings = startings
48
- balance.endings = endings
49
-
50
- return balance
51
-
52
- def overdraft(
53
- self,
54
- name: str = None,
55
- ) -> rk.flux.Flow:
56
- """
57
- Identifies the portion of transactions occurring while the balance is negative.
58
- """
59
-
60
- starts = self.startings.movements.where(self.startings.movements < 0).fillna(0)
61
- ends = self.endings.movements.where(self.endings.movements < 0).fillna(0)
62
-
63
- overdrafts = ends - starts
64
- overdrafts = overdrafts.where(overdrafts < 0).dropna()
65
-
66
- return rk.flux.Flow(
67
- movements=overdrafts,
68
- units=self.startings.units,
69
- name='Overdraft' if name is None else name)
70
-
71
-
72
- @staticmethod
73
- @numba.jit
74
- def _calculate_balance(
75
- startings: numba.typed.List,
76
- transactions: numba.typed.List) -> (numba.typed.List, numba.typed.List):
77
-
78
- endings = numba.typed.List.empty_list(numba.float64)
79
- for i in range(len(transactions)):
80
- endings.append(float(startings[-1] + transactions[i]))
81
- startings.append(endings[-1])
82
-
83
- return (startings[:-1], endings)
84
-
85
-
86
- class Interest:
87
- amounts: rk.flux.Flow
88
- balance: Balance
89
-
90
- class Type(enum.Enum):
91
- INTEREST_ONLY = 'interest_only'
92
- COMPOUND = 'compound'
93
- CAPITALIZED = 'capitalized'
94
-
95
- def __init__(
96
- self,
97
- rate: float | rk.flux.Flow,
98
- transactions: rk.flux.Flow,
99
- frequency: rk.duration.Type,
100
- type: Type = Type.COMPOUND,
101
- ):
102
- """
103
- Calculate interest expense on a loan or other interest-bearing liability,
104
- described by a series of transactions.
105
- Assumes interest is compounded on a positive, outstanding balance per period
106
- (i.e. draws are positive, payments are negative).
107
- Make sure the rate is consistent with the frequency.
108
- """
109
-
110
- transactions = transactions.resample(frequency=frequency)
111
-
112
- if isinstance(rate, float):
113
- rate = rk.flux.Flow.from_projection(
114
- name='Interest Rates',
115
- value=rate,
116
- proj=rk.projection.Extrapolation(
117
- form=rk.extrapolation.Recurring(),
118
- sequence=rk.duration.Sequence.from_datestamps(
119
- datestamps=transactions.movements.index,
120
- frequency=frequency)))
121
- else:
122
- raise NotImplementedError('Only float rates are supported currently.')
123
-
124
- if len(transactions.movements) != len(rate.movements):
125
- raise ValueError('The number of transactions must match the number of rates.')
126
-
127
- startings, endings, interests = self._calc_interest(
128
- transactions=numba.typed.List(transactions.movements.to_list()),
129
- rates=numba.typed.List(rate.movements.to_list()),
130
- type=type.value)
131
-
132
- self.balance = rk.formula.financial.Balance.from_flows(
133
- startings=rk.flux.Flow(
134
- movements=pd.Series(startings, index=transactions.movements.index),
135
- units=transactions.units,
136
- name='Start Balance'),
137
- endings=rk.flux.Flow(
138
- movements=pd.Series(endings, index=transactions.movements.index),
139
- units=transactions.units,
140
- name='End Balance')
141
- )
142
-
143
- self.amounts = rk.flux.Flow(
144
- movements=pd.Series(interests, index=transactions.movements.index),
145
- units=transactions.units,
146
- name='Interest')
147
-
148
-
149
- @staticmethod
150
- @numba.jit
151
- def _calc_interest(
152
- transactions: numba.typed.List,
153
- rates: numba.typed.List,
154
- type: str,
155
- ) -> (numba.typed.List, numba.typed.List, numba.typed.List):
156
-
157
- startings = numba.typed.List.empty_list(numba.float64)
158
- endings = numba.typed.List.empty_list(numba.float64)
159
- amounts = numba.typed.List.empty_list(numba.float64)
160
-
161
- for i in range(len(transactions)):
162
- if i == 0:
163
- startings.append(0)
164
- else:
165
- startings.append(endings[-1])
166
-
167
- principal = startings[i] + transactions[i]
168
- if type == 'interest_only':
169
- interest = principal * rates[i] if principal > 0 else 0
170
- endings.append(principal)
171
-
172
- elif type == 'compound':
173
- interest = principal * rates[i] if principal > 0 else 0
174
- endings.append(principal + interest)
175
-
176
- elif type == 'capitalized':
177
- interest = (principal * rates[i]) / (1 - rates[i]) if principal > 0 else 0 # Since we are capitalizing
178
- # interest, the amount (draw) must include interest to pay on the principal. Derived from i = r * (P + i)
179
- endings.append(principal + interest)
180
-
181
- else:
182
- raise ValueError(f'Invalid instrument: {type}')
183
-
184
- amounts.append(interest)
185
-
186
- return (startings, endings, amounts)
187
-
188
-
189
-
File without changes