Coverage for src/cvx/simulator/builder.py: 100%
79 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 04:18 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 04:18 +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.
62 Examples:
63 --------
64 Load the prices, then iterate. Each step yields the timestamps seen so far
65 and the current state; assigning to the builder records that day's position.
67 >>> import pandas as pd
68 >>> from cvx.simulator import Builder
69 >>> dates = pd.date_range("2020-01-01", periods=4)
70 >>> prices = pd.DataFrame(
71 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
72 ... index=dates,
73 ... )
74 >>> builder = Builder(prices=prices, initial_aum=1000.0)
75 >>> for t, state in builder:
76 ... builder.position = pd.Series({"A": 5.0, "B": 4.0})
77 ... builder.aum = state.aum
79 The AUM series tracks the value of the book as prices move:
81 >>> builder.aum
82 2020-01-01 1000.0
83 2020-01-02 1014.0
84 2020-01-03 1028.0
85 2020-01-04 1019.0
86 Freq: D, dtype: float64
88 Once the loop is done, freeze the result into a Portfolio:
90 >>> portfolio = builder.build()
91 >>> type(portfolio).__name__
92 'Portfolio'
94 """
96 prices: pd.DataFrame
97 initial_aum: float = 1e6
99 _state: State = field(init=False)
100 _units: pd.DataFrame = field(init=False)
101 _aum: pd.Series = field(init=False)
103 def __post_init__(self) -> None:
104 """Initialize the Builder instance after creation.
106 This method is automatically called after the object is initialized.
107 It sets up the internal state, creates empty DataFrames for units and AUM,
108 and initializes the AUM with the provided initial_aum value.
110 The method performs several validations on the prices DataFrame:
111 - Checks that the index is monotonically increasing
112 - Checks that the index has unique values
114 Returns:
115 -------
116 None
118 """
119 if not self.prices.index.is_monotonic_increasing:
120 msg = "`prices` index must be monotonic increasing."
121 raise ValueError(msg)
123 if not self.prices.index.is_unique:
124 msg = "`prices` index must be unique."
125 raise ValueError(msg)
127 self._state = State()
129 self._units = pd.DataFrame(
130 index=self.prices.index,
131 columns=self.prices.columns,
132 data=np.nan,
133 dtype=float,
134 )
136 self._aum = pd.Series(index=self.prices.index, dtype=float)
138 self._state.aum = self.initial_aum
140 @property
141 def valid(self) -> pd.Series:
142 """Check the validity of price data for each asset.
144 This property analyzes each column of the prices DataFrame to determine
145 if there are any missing values between the first and last valid data points.
147 Returns:
148 -------
149 pd.DataFrame
150 A DataFrame with the same columns as prices, containing boolean values
151 indicating whether each asset's price series is valid (True) or has
152 missing values in the middle (False)
154 Notes:
155 -----
156 A valid price series can have missing values at the beginning or end,
157 but not in the middle between the first and last valid data points.
159 """
160 return self.prices.apply(valid)
162 @property
163 def intervals(self) -> pd.DataFrame:
164 """Get the first and last valid index for each asset's price series.
166 This property identifies the time range for which each asset has valid price data.
168 Returns:
169 -------
170 pd.DataFrame
171 A DataFrame with assets as rows and two columns:
172 - 'first': The first valid index (timestamp) for each asset
173 - 'last': The last valid index (timestamp) for each asset
175 Notes:
176 -----
177 This is useful for determining the valid trading period for each asset,
178 especially when different assets have different data availability periods.
180 """
181 return self.prices.apply(
182 lambda ts: pd.Series({"first": ts.first_valid_index(), "last": ts.last_valid_index()})
183 ).transpose()
185 @property
186 def index(self) -> pd.DatetimeIndex:
187 """The index of the portfolio.
189 Returns: pd.Index: A pandas index representing the
190 time period for which the portfolio data is available.
191 """
192 return pd.DatetimeIndex(self.prices.index)
194 @property
195 def current_prices(self) -> np.ndarray:
196 """Get the current prices for all assets in the portfolio.
198 This property retrieves the current prices from the internal state
199 for all assets that are currently in the portfolio.
201 Returns:
202 -------
203 np.array
204 An array of current prices for all assets in the portfolio
206 Notes:
207 -----
208 The prices are retrieved from the internal state, which is updated
209 during iteration through the portfolio's time index.
211 """
212 return np.asarray(self._state.prices[self._state.assets])
214 def __iter__(self) -> Generator[tuple[pd.DatetimeIndex, State]]:
215 """Iterate over object in a for loop.
217 The method yields a list of dates seen so far and returns a tuple
218 containing the list of dates and the current portfolio state.
220 Yield:
221 time: a pandas DatetimeIndex object containing the dates seen so far.
222 state: the current state of the portfolio,
224 taking into account the stock prices at each interval.
226 """
227 for t in self.index:
228 # update the current prices for the portfolio
229 self._state.prices = self.prices.loc[t]
231 # update the current time for the state
232 self._state.time = t
234 # yield the vector of times seen so far and the current state
235 yield self.index[self.index <= t], self._state
237 @property
238 def position(self) -> pd.Series:
239 """The position property returns the current position of the portfolio.
241 It returns a pandas Series object containing the current position of the portfolio.
243 Returns: pd.Series: a pandas Series object containing the current position of the portfolio.
244 """
245 return self._units.loc[self._state.time]
247 @position.setter
248 def position(self, position: pd.Series) -> None:
249 """Set the current position of the portfolio.
251 This setter updates the position (number of units) for each asset in the portfolio
252 at the current time point. It also updates the internal state's position.
254 Parameters
255 ----------
256 position : pd.Series
257 A pandas Series containing the new position (number of units) for each asset
259 Returns:
260 -------
261 None
263 """
264 self._units.loc[self._state.time, self._state.assets] = position
265 self._state.position = position
267 @property
268 def cashposition(self) -> pd.Series:
269 """Get the current cash value of each position in the portfolio.
271 This property calculates the cash value of each position by multiplying
272 the number of units by the current price for each asset.
274 Returns:
275 -------
276 pd.Series
277 A pandas Series containing the cash value of each position,
278 indexed by asset
280 Notes:
281 -----
282 This is different from the 'cash' property, which represents
283 uninvested money. This property represents the market value
284 of each invested position.
286 """
287 return self.position * self.current_prices
289 @cashposition.setter
290 def cashposition(self, cashposition: pd.Series) -> None:
291 """Set the current cash value of each position in the portfolio.
293 This setter updates the cash value of each position and automatically
294 converts the cash values to positions (units) using the current prices.
296 Parameters
297 ----------
298 cashposition : pd.Series
299 A pandas Series containing the new cash value for each position,
300 indexed by asset
302 Returns:
303 -------
304 None
306 Notes:
307 -----
308 This is a convenient way to specify positions in terms of currency
309 amounts rather than number of units. The conversion formula is:
310 position = cashposition / prices
312 """
313 self.position = cashposition / self.current_prices
315 @property
316 def units(self) -> pd.DataFrame:
317 """Get the complete history of portfolio holdings.
319 This property returns the entire DataFrame of holdings (units) for all
320 assets over all time points in the portfolio.
322 Returns:
323 -------
324 pd.DataFrame
325 A DataFrame containing the number of units held for each asset over time,
326 with dates as index and assets as columns
328 Notes:
329 -----
330 This property is particularly useful for testing and for building
331 the final Portfolio object via the build() method.
333 """
334 return self._units
336 def build(self) -> Portfolio:
337 """Create a new Portfolio instance from the current builder state.
339 This method creates a new immutable Portfolio object based on the
340 current state of the Builder, which can be used for analysis and reporting.
342 Returns:
343 -------
344 Portfolio
345 A new instance of the Portfolio class with the attributes
346 (prices, units, aum) as specified in the Builder
348 Notes:
349 -----
350 The resulting Portfolio object will be immutable (frozen) and will
351 have the same data as the Builder from which it was built, but
352 with a different interface focused on analysis rather than construction.
354 Examples:
355 --------
356 >>> import pandas as pd
357 >>> from cvx.simulator import Builder
358 >>> dates = pd.date_range("2020-01-01", periods=4)
359 >>> prices = pd.DataFrame(
360 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
361 ... index=dates,
362 ... )
363 >>> builder = Builder(prices=prices, initial_aum=1000.0)
364 >>> for t, state in builder:
365 ... builder.position = pd.Series({"A": 5.0, "B": 4.0})
366 ... builder.aum = state.aum
367 >>> portfolio = builder.build()
369 The portfolio carries the units the loop accumulated:
371 >>> portfolio.units.iloc[-1]
372 A 5.0
373 B 4.0
374 Name: 2020-01-04 00:00:00, dtype: float64
376 The result is frozen — analysis only, no further construction:
378 >>> portfolio.aum = 10.0
379 Traceback (most recent call last):
380 ...
381 dataclasses.FrozenInstanceError: cannot assign to field 'aum'
383 """
384 return Portfolio(prices=self.prices, units=self.units, aum=self.aum)
386 @property
387 def weights(self) -> np.ndarray:
388 """Get the current portfolio weights for each asset.
390 This property retrieves the weight of each asset in the portfolio
391 from the internal state. Weights represent the proportion of the
392 portfolio's value invested in each asset.
394 Returns:
395 -------
396 np.array
397 An array of weights for each asset in the portfolio
399 Notes:
400 -----
401 Weights sum to 1.0 for a fully invested portfolio with no leverage.
402 Negative weights represent short positions.
404 """
405 return np.asarray(self._state.weights[self._state.assets])
407 @weights.setter
408 def weights(self, weights: np.ndarray) -> None:
409 """Set the current portfolio weights for each asset.
411 This setter updates the portfolio weights and automatically converts
412 the weights to positions (units) using the current prices and NAV.
414 Parameters
415 ----------
416 weights : np.array
417 An array of weights for each asset in the portfolio
419 Returns:
420 -------
421 None
423 Notes:
424 -----
425 This is a convenient way to rebalance the portfolio by specifying
426 the desired allocation as weights rather than exact positions.
427 The conversion formula is: position = NAV * weights / prices
429 Examples:
430 --------
431 Hold an equal-weighted book, rebalanced every day. The weights are
432 constant, so the units drift as the relative prices move:
434 >>> import numpy as np
435 >>> import pandas as pd
436 >>> from cvx.simulator import Builder
437 >>> dates = pd.date_range("2020-01-01", periods=4)
438 >>> prices = pd.DataFrame(
439 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
440 ... index=dates,
441 ... )
442 >>> builder = Builder(prices=prices, initial_aum=1000.0)
443 >>> for t, state in builder:
444 ... builder.weights = np.array([0.5, 0.5])
445 ... builder.aum = state.aum
446 >>> builder.units.round(4)
447 A B
448 2020-01-01 5.00 10.0000
449 2020-01-02 5.00 10.0000
450 2020-01-03 5.00 10.0000
451 2020-01-04 4.90 9.8971
453 """
454 self.position = self._state.nav * weights / self.current_prices
456 @property
457 def aum(self) -> pd.Series:
458 """Get the assets under management (AUM) history of the portfolio.
460 This property returns the entire series of AUM values over time,
461 representing the total value of the portfolio at each time point.
463 Returns:
464 -------
465 pd.Series
466 A Series containing the AUM values over time, with dates as index
468 Notes:
469 -----
470 AUM (assets under management) represents the total value of the portfolio,
471 including both invested positions and uninvested cash.
473 """
474 return self._aum
476 @aum.setter
477 def aum(self, aum: float) -> None:
478 """Set the current assets under management (AUM) of the portfolio.
480 This setter updates the AUM value at the current time point and
481 also updates the internal state's AUM.
483 Parameters
484 ----------
485 aum : float
486 The new AUM value to set
488 Returns:
489 -------
490 None
492 Notes:
493 -----
494 Changing the AUM affects the portfolio's ability to take positions,
495 as position sizes are often calculated as a fraction of AUM.
497 """
498 self._aum[self._state.time] = aum
499 self._state.aum = aum