Coverage for src/cvx/simulator/builder.py: 100%
79 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 13:14 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 13:14 +0000
1"""Builder class for the CVX Simulator."""
3# Copyright 2023 Stanford University Convex Optimization Group
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16from __future__ import annotations
18from collections.abc import Generator
19from dataclasses import dataclass, field
21import numpy as np
22import pandas as pd
23import polars as pl
25from .portfolio import Portfolio
26from .state import State
27from .utils.interpolation import valid
30def polars2pandas(dframe: pl.DataFrame, date_col: str = "date") -> pd.DataFrame:
31 """Convert a Polars DataFrame to a Pandas DataFrame.
33 Ensuring the date column is cast to a datetime format and
34 all other columns are cast to Float64. The resulting Pandas DataFrame is indexed by the specified date column.
36 Args:
37 dframe (pl.DataFrame): The Polars DataFrame to be converted.
38 date_col (str): The name of the column containing date values, defaults to "date".
40 Returns:
41 pd.DataFrame: The converted Pandas DataFrame with the date column as its index.
43 """
44 dframe = dframe.with_columns(pl.col(date_col).cast(pl.Datetime("us")))
45 dframe = dframe.with_columns([pl.col(col).cast(pl.Float64) for col in dframe.columns if col != date_col])
46 return dframe.to_pandas().set_index(date_col)
49@dataclass
50class Builder:
51 """The Builder is an auxiliary class used to build portfolios.
53 It overloads the __iter__ method to allow the class to iterate over
54 the timestamps for which the portfolio data is available.
56 In each iteration we can update the portfolio by setting either
57 the weights, the position or the cash position.
59 After the iteration has been completed we build a Portfolio object
60 by calling the build method.
61 """
63 prices: pd.DataFrame
64 initial_aum: float = 1e6
66 _state: State = field(init=False)
67 _units: pd.DataFrame = field(init=False)
68 _aum: pd.Series = field(init=False)
70 def __post_init__(self) -> None:
71 """Initialize the Builder instance after creation.
73 This method is automatically called after the object is initialized.
74 It sets up the internal state, creates empty DataFrames for units and AUM,
75 and initializes the AUM with the provided initial_aum value.
77 The method performs several validations on the prices DataFrame:
78 - Checks that the index is monotonically increasing
79 - Checks that the index has unique values
81 Returns:
82 -------
83 None
85 """
86 # assert isinstance(self.prices, pd.DataFrame)
87 if not self.prices.index.is_monotonic_increasing:
88 msg = "Index must be monotonically increasing"
89 raise ValueError(msg)
91 if not self.prices.index.is_unique:
92 msg = "Index must have unique values"
93 raise ValueError(msg)
95 self._state = State()
97 self._units = pd.DataFrame(
98 index=self.prices.index,
99 columns=self.prices.columns,
100 data=np.nan,
101 dtype=float,
102 )
104 self._aum = pd.Series(index=self.prices.index, dtype=float)
106 self._state.aum = self.initial_aum
108 @property
109 def valid(self) -> pd.Series:
110 """Check the validity of price data for each asset.
112 This property analyzes each column of the prices DataFrame to determine
113 if there are any missing values between the first and last valid data points.
115 Returns:
116 -------
117 pd.DataFrame
118 A DataFrame with the same columns as prices, containing boolean values
119 indicating whether each asset's price series is valid (True) or has
120 missing values in the middle (False)
122 Notes:
123 -----
124 A valid price series can have missing values at the beginning or end,
125 but not in the middle between the first and last valid data points.
127 """
128 return self.prices.apply(valid)
130 @property
131 def intervals(self) -> pd.DataFrame:
132 """Get the first and last valid index for each asset's price series.
134 This property identifies the time range for which each asset has valid price data.
136 Returns:
137 -------
138 pd.DataFrame
139 A DataFrame with assets as rows and two columns:
140 - 'first': The first valid index (timestamp) for each asset
141 - 'last': The last valid index (timestamp) for each asset
143 Notes:
144 -----
145 This is useful for determining the valid trading period for each asset,
146 especially when different assets have different data availability periods.
148 """
149 return self.prices.apply(
150 lambda ts: pd.Series({"first": ts.first_valid_index(), "last": ts.last_valid_index()})
151 ).transpose()
153 @property
154 def index(self) -> pd.DatetimeIndex:
155 """The index of the portfolio.
157 Returns: pd.Index: A pandas index representing the
158 time period for which the portfolio data is available.
159 """
160 return pd.DatetimeIndex(self.prices.index)
162 @property
163 def current_prices(self) -> np.ndarray:
164 """Get the current prices for all assets in the portfolio.
166 This property retrieves the current prices from the internal state
167 for all assets that are currently in the portfolio.
169 Returns:
170 -------
171 np.array
172 An array of current prices for all assets in the portfolio
174 Notes:
175 -----
176 The prices are retrieved from the internal state, which is updated
177 during iteration through the portfolio's time index.
179 """
180 return np.asarray(self._state.prices[self._state.assets])
182 def __iter__(self) -> Generator[tuple[pd.DatetimeIndex, State]]:
183 """Iterate over object in a for loop.
185 The method yields a list of dates seen so far and returns a tuple
186 containing the list of dates and the current portfolio state.
188 Yield:
189 time: a pandas DatetimeIndex object containing the dates seen so far.
190 state: the current state of the portfolio,
192 taking into account the stock prices at each interval.
194 """
195 for t in self.index:
196 # update the current prices for the portfolio
197 self._state.prices = self.prices.loc[t]
199 # update the current time for the state
200 self._state.time = t
202 # yield the vector of times seen so far and the current state
203 yield self.index[self.index <= t], self._state
205 @property
206 def position(self) -> pd.Series:
207 """The position property returns the current position of the portfolio.
209 It returns a pandas Series object containing the current position of the portfolio.
211 Returns: pd.Series: a pandas Series object containing the current position of the portfolio.
212 """
213 return self._units.loc[self._state.time]
215 @position.setter
216 def position(self, position: pd.Series) -> None:
217 """Set the current position of the portfolio.
219 This setter updates the position (number of units) for each asset in the portfolio
220 at the current time point. It also updates the internal state's position.
222 Parameters
223 ----------
224 position : pd.Series
225 A pandas Series containing the new position (number of units) for each asset
227 Returns:
228 -------
229 None
231 """
232 self._units.loc[self._state.time, self._state.assets] = position
233 self._state.position = position
235 @property
236 def cashposition(self) -> pd.Series:
237 """Get the current cash value of each position in the portfolio.
239 This property calculates the cash value of each position by multiplying
240 the number of units by the current price for each asset.
242 Returns:
243 -------
244 pd.Series
245 A pandas Series containing the cash value of each position,
246 indexed by asset
248 Notes:
249 -----
250 This is different from the 'cash' property, which represents
251 uninvested money. This property represents the market value
252 of each invested position.
254 """
255 return self.position * self.current_prices
257 @cashposition.setter
258 def cashposition(self, cashposition: pd.Series) -> None:
259 """Set the current cash value of each position in the portfolio.
261 This setter updates the cash value of each position and automatically
262 converts the cash values to positions (units) using the current prices.
264 Parameters
265 ----------
266 cashposition : pd.Series
267 A pandas Series containing the new cash value for each position,
268 indexed by asset
270 Returns:
271 -------
272 None
274 Notes:
275 -----
276 This is a convenient way to specify positions in terms of currency
277 amounts rather than number of units. The conversion formula is:
278 position = cashposition / prices
280 """
281 self.position = cashposition / self.current_prices
283 @property
284 def units(self) -> pd.DataFrame:
285 """Get the complete history of portfolio holdings.
287 This property returns the entire DataFrame of holdings (units) for all
288 assets over all time points in the portfolio.
290 Returns:
291 -------
292 pd.DataFrame
293 A DataFrame containing the number of units held for each asset over time,
294 with dates as index and assets as columns
296 Notes:
297 -----
298 This property is particularly useful for testing and for building
299 the final Portfolio object via the build() method.
301 """
302 return self._units
304 def build(self) -> Portfolio:
305 """Create a new Portfolio instance from the current builder state.
307 This method creates a new immutable Portfolio object based on the
308 current state of the Builder, which can be used for analysis and reporting.
310 Returns:
311 -------
312 Portfolio
313 A new instance of the Portfolio class with the attributes
314 (prices, units, aum) as specified in the Builder
316 Notes:
317 -----
318 The resulting Portfolio object will be immutable (frozen) and will
319 have the same data as the Builder from which it was built, but
320 with a different interface focused on analysis rather than construction.
322 """
323 return Portfolio(prices=self.prices, units=self.units, aum=self.aum)
325 @property
326 def weights(self) -> np.ndarray:
327 """Get the current portfolio weights for each asset.
329 This property retrieves the weight of each asset in the portfolio
330 from the internal state. Weights represent the proportion of the
331 portfolio's value invested in each asset.
333 Returns:
334 -------
335 np.array
336 An array of weights for each asset in the portfolio
338 Notes:
339 -----
340 Weights sum to 1.0 for a fully invested portfolio with no leverage.
341 Negative weights represent short positions.
343 """
344 return np.asarray(self._state.weights[self._state.assets])
346 @weights.setter
347 def weights(self, weights: np.ndarray) -> None:
348 """Set the current portfolio weights for each asset.
350 This setter updates the portfolio weights and automatically converts
351 the weights to positions (units) using the current prices and NAV.
353 Parameters
354 ----------
355 weights : np.array
356 An array of weights for each asset in the portfolio
358 Returns:
359 -------
360 None
362 Notes:
363 -----
364 This is a convenient way to rebalance the portfolio by specifying
365 the desired allocation as weights rather than exact positions.
366 The conversion formula is: position = NAV * weights / prices
368 """
369 self.position = self._state.nav * weights / self.current_prices
371 @property
372 def aum(self) -> pd.Series:
373 """Get the assets under management (AUM) history of the portfolio.
375 This property returns the entire series of AUM values over time,
376 representing the total value of the portfolio at each time point.
378 Returns:
379 -------
380 pd.Series
381 A Series containing the AUM values over time, with dates as index
383 Notes:
384 -----
385 AUM (assets under management) represents the total value of the portfolio,
386 including both invested positions and uninvested cash.
388 """
389 return self._aum
391 @aum.setter
392 def aum(self, aum: float) -> None:
393 """Set the current assets under management (AUM) of the portfolio.
395 This setter updates the AUM value at the current time point and
396 also updates the internal state's AUM.
398 Parameters
399 ----------
400 aum : float
401 The new AUM value to set
403 Returns:
404 -------
405 None
407 Notes:
408 -----
409 Changing the AUM affects the portfolio's ability to take positions,
410 as position sizes are often calculated as a fraction of AUM.
412 """
413 self._aum[self._state.time] = aum
414 self._state.aum = aum