rangekeeper 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Union, Optional
5
+
6
+ import aenum
7
+ import enum
8
+ from datetime import datetime
9
+ import dateutil.relativedelta
10
+ import pandas as pd
11
+
12
+ import rangekeeper as rk
13
+
14
+ class Type(enum.Enum):
15
+ YEAR = 'A-DEC' # Anchored on end of December
16
+ QUARTER = 'Q-DEC' # Anchored on end of March, June, September, & December
17
+ MONTH = 'M' # Anchored on end-of-month
18
+ SEMIMONTH = 'SM' # Twice-monthly periods anchored on mid-month and end-of-month dates
19
+ WEEK = 'W' # Anchored on Sundays
20
+ DAY = 'D' # Daily
21
+
22
+
23
+ def from_value(value: str):
24
+ if value == 'A-DEC':
25
+ return Type.YEAR
26
+ if value == 'Q-DEC':
27
+ return Type.QUARTER
28
+ if value == 'M':
29
+ return Type.MONTH
30
+ if value == 'SM':
31
+ return Type.SEMIMONTH
32
+ if value == 'W':
33
+ return Type.WEEK
34
+ if value == 'D':
35
+ return Type.DAY
36
+
37
+
38
+ def include_date(
39
+ date: pd.Timestamp,
40
+ duration: Type) -> pd.Period:
41
+ """
42
+ Returns a pd.Period that encompasses the given date
43
+ """
44
+
45
+ return pd.Period(year=date.year, month=date.month, day=date.day, freq=duration.value)
46
+
47
+
48
+ def period_index(
49
+ include_start: pd.Timestamp,
50
+ period_type: Type,
51
+ bound: Union[pd.Timestamp, int] = None) -> pd.PeriodIndex:
52
+ """
53
+ Returns a pd.PeriodIndex from a start date with periods of given duration.
54
+ Either an end date, or number of periods must be given to bound the sequence.
55
+
56
+ :param include_start: pd.Timestamp start date
57
+ :param period_type: Period frequency
58
+ :param bound: A terminating condition; either a pd.Timestamp end date or a (integer) number of periods
59
+ """
60
+ if isinstance(bound, pd.Timestamp):
61
+ return pd.period_range(
62
+ start=include_start,
63
+ end=bound,
64
+ freq=period_type.value,
65
+ name='periods')
66
+ elif isinstance(bound, int):
67
+ return pd.period_range(
68
+ start=include_start,
69
+ periods=bound,
70
+ freq=period_type.value,
71
+ name='periods')
72
+
73
+ def offset_periodindex(
74
+ index: pd.PeriodIndex,
75
+ offset_start: Optional[int] = None,
76
+ offset_end: Optional[int] = None) -> pd.PeriodIndex:
77
+ """
78
+ Returns a pd.PeriodIndex with periods offset by given number of periods (of type given by the index)
79
+ """
80
+ start = index[0].to_timestamp()
81
+ if offset_start is not None:
82
+ start = offset_date(
83
+ date=period_index[0].to_timestamp(),
84
+ period_type=from_value(index.freq),
85
+ num_periods=offset_start)
86
+ end = index[-1].to_timestamp()
87
+ if offset_end is not None:
88
+ end = offset_date(
89
+ date=index[-1].to_timestamp(),
90
+ period_type=from_value(index.freq),
91
+ num_periods=offset_end)
92
+ return period_index(
93
+ include_start=start,
94
+ period_type=from_value(index.freq),
95
+ bound=end)
96
+
97
+ def to_span(period_index: pd.PeriodIndex):
98
+ """
99
+ Returns a Span encompassing a pd.PeriodIndex
100
+ """
101
+ return rk.span.Span(
102
+ start_date=period_index[0].to_timestamp(how='start'),
103
+ end_date=period_index[-1].to_timestamp(how='end'))
104
+
105
+ def single_period_index(period: pd.Period) -> pd.PeriodIndex:
106
+ return pd.period_range(
107
+ start=period,
108
+ periods=1)
109
+
110
+
111
+ def to_datestamps(
112
+ period_index: pd.PeriodIndex,
113
+ end: bool = True) -> pd.DatetimeIndex:
114
+ """
115
+ Returns a pd.DatetimeIndex from a pd.PeriodIndex with Datetimes being Dates
116
+ :param end: If True, the end of the period is used. If False, the start of the period is used.
117
+ """
118
+ if end:
119
+ timestamps = period_index.to_timestamp(how='end')
120
+ else:
121
+ timestamps = period_index.to_timestamp(how='start')
122
+ datestamps = timestamps.date
123
+ return pd.DatetimeIndex(datestamps)
124
+
125
+
126
+ def periods_per_year(period_type: Type) -> int:
127
+ return {
128
+ Type.YEAR: 1,
129
+ Type.QUARTER: 4,
130
+ Type.MONTH: 12,
131
+ Type.SEMIMONTH: 24,
132
+ Type.WEEK: 52,
133
+ Type.DAY: 365
134
+ }.get(period_type, None)
135
+
136
+
137
+ def offset_date(
138
+ date: pd.Timestamp,
139
+ period_type: Type = Type.DAY,
140
+ num_periods: int = 1) -> pd.Timestamp:
141
+ """
142
+ Offsets a date by a given number of periods
143
+ :param date:
144
+ :type date:
145
+ :param period_type:
146
+ :type period_type:
147
+ :param num_periods:
148
+ :type num_periods:
149
+ :return:
150
+ :rtype:
151
+ """
152
+ if period_type.value == Type.YEAR.value:
153
+ return date + pd.DateOffset(years=num_periods)
154
+ elif period_type.value == Type.QUARTER.value:
155
+ return date + pd.DateOffset(months=3 * num_periods)
156
+ elif period_type.value == Type.MONTH.value:
157
+ return date + pd.DateOffset(months=num_periods)
158
+ elif period_type.value == Type.WEEK.value:
159
+ return date + pd.DateOffset(weeks=num_periods)
160
+ elif period_type.value == Type.DAY.value:
161
+ return date + pd.DateOffset(days=num_periods)
162
+
163
+
164
+ def duration(
165
+ start_date: pd.Timestamp,
166
+ end_date: pd.Timestamp,
167
+ period_type: Type,
168
+ inclusive: bool = False) -> int:
169
+ """
170
+ Returns the whole integer (i.e. no remainder) number of periods between
171
+ given dates.
172
+ If inclusive is True, the end_date is included in the calculation.
173
+ """
174
+ calc_end_date = end_date
175
+ if inclusive:
176
+ calc_end_date = offset_date(
177
+ date=end_date,
178
+ period_type=Type.DAY,
179
+ num_periods=1)
180
+
181
+ delta = dateutil.relativedelta.relativedelta(calc_end_date, start_date)
182
+
183
+ if period_type.value == Type.YEAR.value:
184
+ result = delta.years
185
+ elif period_type.value == Type.QUARTER.value:
186
+ result = (delta.years * 4) + math.floor(delta.months / 3)
187
+ elif period_type.value == Type.MONTH.value:
188
+ result = (delta.years * 12) + delta.months
189
+ elif period_type.value == Type.SEMIMONTH.value:
190
+ result = (delta.years * 24) + delta.months * 2
191
+ elif period_type.value == Type.WEEK.value:
192
+ result = math.floor((calc_end_date - start_date).days / 7)
193
+ elif period_type.value == Type.DAY.value:
194
+ result = (calc_end_date - start_date).days
195
+ else:
196
+ raise ValueError('Unsupported period type: {0}'.format(period_type))
197
+
198
+ def direction(rd: dateutil.relativedelta.relativedelta) -> int:
199
+ """
200
+ Check whether a relativedelta object is negative
201
+ From https://stackoverflow.com/a/57906103/10964780
202
+ """
203
+ try:
204
+ datetime.min + rd
205
+ return 1
206
+ except OverflowError:
207
+ return -1
208
+
209
+ return result * direction(delta)
210
+
211
+
212
+ def to_range_index(
213
+ period_index: pd.PeriodIndex,
214
+ start_period: pd.Period = None,
215
+ end_period: pd.Period = None) -> pd.RangeIndex:
216
+ """
217
+ Returns a RangeIndex that maps a PeriodIndex to a range of integers.
218
+ """
219
+
220
+ index_offset = None
221
+ if start_period is None:
222
+ start_period = period_index[0]
223
+ if end_period is None:
224
+ end_period = period_index[-1]
225
+ bounds_index = pd.period_range(
226
+ start=start_period,
227
+ end=end_period,
228
+ freq=period_index.freq)
229
+
230
+ for i in range(bounds_index.size):
231
+ if period_index.__contains__(bounds_index[i]):
232
+ index_offset = i
233
+ break
234
+ else:
235
+ continue
236
+ return pd.RangeIndex(start=index_offset, stop=period_index.size + index_offset, step=1)
rangekeeper/policy.py ADDED
@@ -0,0 +1,20 @@
1
+ from abc import abstractmethod
2
+ from typing import Callable, List, Any, Dict
3
+
4
+ import rangekeeper as rk
5
+
6
+
7
+ class Policy:
8
+ def __init__(
9
+ self,
10
+ condition: Callable[[rk.flux.Flow], List[bool]],
11
+ action: Callable[[object, List[bool]], object]):
12
+ self.condition = condition
13
+ self.action = action
14
+
15
+ def execute(
16
+ self,
17
+ args: tuple) -> object:
18
+ state, model = args # This is done to assist multiprocessing
19
+ decision = self.condition(state)
20
+ return self.action(model, decision)
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import typing
5
+ from typing import Optional, Union, Tuple
6
+
7
+ import enum
8
+ import numpy as np
9
+ import pandas as pd
10
+ import scipy.stats as ss
11
+
12
+ import rangekeeper as rk
13
+
14
+
15
+ class Padding(enum.Enum):
16
+ """
17
+ Enum for padding types.
18
+ """
19
+ NIL = 'nil' # Zero out factors or densities until bound
20
+ UNITIZE = 'unitize' # Repeat unit factor or density until bound
21
+ EXTEND = 'extend' # 'Repeat last factor or density until bound'
22
+
23
+
24
+ class Projection:
25
+ sequence: pd.PeriodIndex
26
+ bounds: Tuple[pd.Period, pd.Period]
27
+
28
+ def __init__(
29
+ self,
30
+ sequence: pd.PeriodIndex,
31
+ bounds: Tuple[pd.Period, pd.Period] = None):
32
+ self.sequence = sequence
33
+
34
+ if bounds is None:
35
+ self.bounds = (sequence[0], sequence[-1])
36
+ else:
37
+ self.bounds = bounds
38
+
39
+ # def __init__(
40
+ # self,
41
+ # sequence: Union[pd.RangeIndex, pd.PeriodIndex],
42
+ # bounds: Union[Tuple[int, int], Tuple[pd.Period, pd.Period]] = None):
43
+ #
44
+ # if isinstance(sequence, pd.RangeIndex):
45
+ # if bounds is None:
46
+ # self.sequence = sequence
47
+ # self.bounds = (0, len(sequence))
48
+ # elif isinstance(bounds[0], int):
49
+ # self.sequence = sequence
50
+ # self.bounds = bounds
51
+ # else:
52
+ # raise ValueError(f"sequence and bounds must match in type (int or pd.Period)")
53
+ #
54
+ # elif isinstance(sequence, pd.PeriodIndex):
55
+ # if bounds is None:
56
+ # self.sequence = periodicity.to_range_index(
57
+ # period_index=sequence)
58
+ # self.bounds = (0, len(sequence))
59
+ # elif isinstance(bounds[0], pd.Period):
60
+ # self.sequence = periodicity.to_range_index(
61
+ # period_index=sequence,
62
+ # start=bounds[0],
63
+ # end=bounds[1])
64
+ # bounds_range = pd.period_range(
65
+ # start=bounds[0],
66
+ # end=bounds[1],
67
+ # freq=sequence.freq)
68
+ # self.bounds = (0, len(bounds_range))
69
+ # else:
70
+ # raise ValueError(f"sequence and bounds must match in type (int or pd.Period)")
71
+ #
72
+ # else:
73
+ # raise ValueError(f"sequence must be of type (pd.RangeIndex or pd.PeriodIndex)")
74
+
75
+ @staticmethod
76
+ def _pad(
77
+ value: float,
78
+ length: int,
79
+ type: Optional[Padding]) -> np.ndarray:
80
+ if type == Padding.NIL:
81
+ return np.zeros(shape=length)
82
+ elif type == Padding.UNITIZE:
83
+ return np.ones(shape=length)
84
+ elif type == Padding.EXTEND:
85
+ return np.full(shape=length, fill_value=value)
86
+ elif type is None:
87
+ return np.empty(shape=0)
88
+
89
+
90
+ class Extrapolation(Projection):
91
+ form: rk.extrapolation.Form
92
+ padding: Tuple[Optional[Padding], Optional[Padding]]
93
+
94
+ def __init__(
95
+ self,
96
+ form: rk.extrapolation.Form,
97
+ sequence: pd.PeriodIndex,
98
+ bounds: Tuple[pd.Period, pd.Period] = None,
99
+ padding: Tuple[Padding, Padding] = None):
100
+ super().__init__(
101
+ sequence=sequence,
102
+ bounds=bounds)
103
+ self.form = form
104
+
105
+ if padding is None:
106
+ if self.bounds[0] == sequence[0] and self.bounds[1] == sequence[-1]:
107
+ self.padding = (None, None)
108
+ else:
109
+ raise ValueError('Error: Since the bounds do not match sequence start & end,'
110
+ ' padding must be specified.')
111
+ else:
112
+ self.padding = padding
113
+
114
+ def terms(self) -> pd.Series:
115
+ terms = self.form.terms(
116
+ sequence=rk.periodicity.to_range_index(period_index=self.sequence))
117
+ left_length = self.sequence[0] - self.bounds[0]
118
+ right_length = self.bounds[1] - self.sequence[-1]
119
+ left = self._pad(
120
+ value=terms[0],
121
+ length=left_length.n,
122
+ type=self.padding[0])
123
+ right = self._pad(
124
+ value=terms[-1],
125
+ length=right_length.n,
126
+ type=self.padding[1])
127
+ data = np.concatenate((left, terms, right))
128
+
129
+ return pd.Series(
130
+ data=data,
131
+ index=rk.periodicity.to_datestamps(
132
+ rk.periodicity.period_index(
133
+ include_start=self.bounds[0].to_timestamp(how='start'),
134
+ period_type=rk.periodicity.from_value(self.sequence.freq),
135
+ bound=self.bounds[1].to_timestamp(how='start'))))
136
+
137
+
138
+ class Distribution(Projection):
139
+ form: rk.distribution.Form
140
+
141
+ def __init__(
142
+ self,
143
+ form: rk.distribution.Form,
144
+ sequence: pd.PeriodIndex,
145
+ bounds: Tuple[pd.Period, pd.Period] = None):
146
+ super().__init__(
147
+ sequence=sequence)
148
+ self.form = form
149
+
150
+ if bounds is None:
151
+ self.bounds = (sequence[0], sequence[-1])
152
+ elif (bounds[0] > sequence[0]) or (bounds[1] < sequence[-1]):
153
+ raise ValueError('Error: Bounds cannot be within the sequence for '
154
+ 'Distribution Projections')
155
+ else:
156
+ self.bounds = bounds
157
+
158
+ self._parameters = self._seq_to_params()
159
+
160
+ left_length = self.sequence[0] - self.bounds[0]
161
+ right_length = self.bounds[1] - self.sequence[-1]
162
+ self._left_padding = self._pad(
163
+ value=0,
164
+ length=left_length.n,
165
+ type=Padding.NIL)
166
+ self._right_padding = self._pad(
167
+ value=0,
168
+ length=right_length.n,
169
+ type=Padding.NIL)
170
+
171
+ # self._index = periodicity.period_index(
172
+ # include_start=self.bounds[0].to_timestamp(),
173
+ # period_type=periodicity.from_value(self.sequence.freq),
174
+ # bound=self.bounds[1].to_timestamp())
175
+
176
+ def _seq_to_params(self) -> [float]:
177
+ range_index = rk.periodicity.to_range_index(
178
+ period_index=self.sequence)
179
+ range_index = range_index.insert(
180
+ loc=len(range_index),
181
+ item=range_index[-1] + 1)
182
+ max = range_index[-1]
183
+ return [index / max for index in range_index]
184
+
185
+ def interval_density(self) -> pd.Series:
186
+ densities = self.form.interval_density(parameters=self._parameters)
187
+ data = np.concatenate((self._left_padding, densities, self._right_padding))
188
+
189
+ return pd.Series(
190
+ data=data,
191
+ index=rk.periodicity.to_datestamps(
192
+ rk.periodicity.period_index(
193
+ include_start=self.bounds[0].to_timestamp(),
194
+ period_type=rk.periodicity.from_value(self.sequence.freq),
195
+ bound=self.bounds[1].to_timestamp())))
196
+
197
+ def cumulative_density(self) -> [float]:
198
+ densities = self.form.cumulative_density(parameters=self._parameters)
199
+ data = np.concatenate((self._left_padding, densities, self._right_padding))
200
+
201
+ period_index = rk.periodicity.period_index(
202
+ include_start=self.bounds[0].to_timestamp(),
203
+ period_type=rk.periodicity.from_value(self.sequence.freq),
204
+ bound=self.bounds[1].to_timestamp())
205
+ datestamp_index = rk.periodicity.to_datestamps(period_index=period_index)
206
+ datestamp_index = datestamp_index.insert(loc=0, item=self.bounds[0].start_time)
207
+
208
+ return pd.Series(
209
+ data=data,
210
+ index=datestamp_index)
211
+
212
+ # class DistributiveInterpolation(Interpolation):
213
+ # """
214
+ # A projection that apportions values over a range, according to a specified
215
+ # distribution type.
216
+ # """
217
+ # dist: distribution.Form
218
+ #
219
+ # def __init__(
220
+ # self,
221
+ # dist: distribution.Form,
222
+ # sequence: Union[pd.RangeIndex, pd.PeriodIndex],
223
+ # bounds: Union[Tuple[int, int], Tuple[pd.Period, pd.Period]]):
224
+ # super().__init__(
225
+ # sequence=sequence,
226
+ # bounds=bounds)
227
+ # self.dist = dist
228
+
229
+ # def map(
230
+ # self,
231
+ # left_padding: Padding = None,
232
+ # right_padding: Padding = None) -> pd.Series:
233
+
234
+
235
+ # if isinstance(self.sequence, pd.RangeIndex):
236
+ # if isinstance(self, Extrapolation):
237
+ # factors = self.factor()
238
+ # left = self.pad(
239
+ # value=factors[0],
240
+ # length=self.sequence[0] - self.bounds[0],
241
+ # type=left_padding)
242
+ # right = self.pad(
243
+ # value=factors[-1],
244
+ # length=self.bounds[1] - self.sequence[-1],
245
+ # type=right_padding)
246
+ # return pd.Series(
247
+ # data=np.concatenate((left, factors, right)),
248
+ # index=range(self.bounds[0], self.bounds[1]))