Coverage for src/cvx/simulator/builder.py: 100%
79 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 02:49 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 02:49 +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 # assert isinstance(self.prices, pd.DataFrame)
120 if not self.prices.index.is_monotonic_increasing:
121 msg = "Index must be monotonically increasing"
122 raise ValueError(msg)
124 if not self.prices.index.is_unique:
125 msg = "Index must have unique values"
126 raise ValueError(msg)
128 self._state = State()
130 self._units = pd.DataFrame(
131 index=self.prices.index,
132 columns=self.prices.columns,
133 data=np.nan,
134 dtype=float,
135 )
137 self._aum = pd.Series(index=self.prices.index, dtype=float)
139 self._state.aum = self.initial_aum
141 @property
142 def valid(self) -> pd.Series:
143 """Check the validity of price data for each asset.
145 This property analyzes each column of the prices DataFrame to determine
146 if there are any missing values between the first and last valid data points.
148 Returns:
149 -------
150 pd.DataFrame
151 A DataFrame with the same columns as prices, containing boolean values
152 indicating whether each asset's price series is valid (True) or has
153 missing values in the middle (False)
155 Notes:
156 -----
157 A valid price series can have missing values at the beginning or end,
158 but not in the middle between the first and last valid data points.
160 """
161 return self.prices.apply(valid)
163 @property
164 def intervals(self) -> pd.DataFrame:
165 """Get the first and last valid index for each asset's price series.
167 This property identifies the time range for which each asset has valid price data.
169 Returns:
170 -------
171 pd.DataFrame
172 A DataFrame with assets as rows and two columns:
173 - 'first': The first valid index (timestamp) for each asset
174 - 'last': The last valid index (timestamp) for each asset
176 Notes:
177 -----
178 This is useful for determining the valid trading period for each asset,
179 especially when different assets have different data availability periods.
181 """
182 return self.prices.apply(
183 lambda ts: pd.Series({"first": ts.first_valid_index(), "last": ts.last_valid_index()})
184 ).transpose()
186 @property
187 def index(self) -> pd.DatetimeIndex:
188 """The index of the portfolio.
190 Returns: pd.Index: A pandas index representing the
191 time period for which the portfolio data is available.
192 """
193 return pd.DatetimeIndex(self.prices.index)
195 @property
196 def current_prices(self) -> np.ndarray:
197 """Get the current prices for all assets in the portfolio.
199 This property retrieves the current prices from the internal state
200 for all assets that are currently in the portfolio.
202 Returns:
203 -------
204 np.array
205 An array of current prices for all assets in the portfolio
207 Notes:
208 -----
209 The prices are retrieved from the internal state, which is updated
210 during iteration through the portfolio's time index.
212 """
213 return np.asarray(self._state.prices[self._state.assets])
215 def __iter__(self) -> Generator[tuple[pd.DatetimeIndex, State]]:
216 """Iterate over object in a for loop.
218 The method yields a list of dates seen so far and returns a tuple
219 containing the list of dates and the current portfolio state.
221 Yield:
222 time: a pandas DatetimeIndex object containing the dates seen so far.
223 state: the current state of the portfolio,
225 taking into account the stock prices at each interval.
227 """
228 for t in self.index:
229 # update the current prices for the portfolio
230 self._state.prices = self.prices.loc[t]
232 # update the current time for the state
233 self._state.time = t
235 # yield the vector of times seen so far and the current state
236 yield self.index[self.index <= t], self._state
238 @property
239 def position(self) -> pd.Series:
240 """The position property returns the current position of the portfolio.
242 It returns a pandas Series object containing the current position of the portfolio.
244 Returns: pd.Series: a pandas Series object containing the current position of the portfolio.
245 """
246 return self._units.loc[self._state.time]
248 @position.setter
249 def position(self, position: pd.Series) -> None:
250 """Set the current position of the portfolio.
252 This setter updates the position (number of units) for each asset in the portfolio
253 at the current time point. It also updates the internal state's position.
255 Parameters
256 ----------
257 position : pd.Series
258 A pandas Series containing the new position (number of units) for each asset
260 Returns:
261 -------
262 None
264 """
265 self._units.loc[self._state.time, self._state.assets] = position
266 self._state.position = position
268 @property
269 def cashposition(self) -> pd.Series:
270 """Get the current cash value of each position in the portfolio.
272 This property calculates the cash value of each position by multiplying
273 the number of units by the current price for each asset.
275 Returns:
276 -------
277 pd.Series
278 A pandas Series containing the cash value of each position,
279 indexed by asset
281 Notes:
282 -----
283 This is different from the 'cash' property, which represents
284 uninvested money. This property represents the market value
285 of each invested position.
287 """
288 return self.position * self.current_prices
290 @cashposition.setter
291 def cashposition(self, cashposition: pd.Series) -> None:
292 """Set the current cash value of each position in the portfolio.
294 This setter updates the cash value of each position and automatically
295 converts the cash values to positions (units) using the current prices.
297 Parameters
298 ----------
299 cashposition : pd.Series
300 A pandas Series containing the new cash value for each position,
301 indexed by asset
303 Returns:
304 -------
305 None
307 Notes:
308 -----
309 This is a convenient way to specify positions in terms of currency
310 amounts rather than number of units. The conversion formula is:
311 position = cashposition / prices
313 """
314 self.position = cashposition / self.current_prices
316 @property
317 def units(self) -> pd.DataFrame:
318 """Get the complete history of portfolio holdings.
320 This property returns the entire DataFrame of holdings (units) for all
321 assets over all time points in the portfolio.
323 Returns:
324 -------
325 pd.DataFrame
326 A DataFrame containing the number of units held for each asset over time,
327 with dates as index and assets as columns
329 Notes:
330 -----
331 This property is particularly useful for testing and for building
332 the final Portfolio object via the build() method.
334 """
335 return self._units
337 def build(self) -> Portfolio:
338 """Create a new Portfolio instance from the current builder state.
340 This method creates a new immutable Portfolio object based on the
341 current state of the Builder, which can be used for analysis and reporting.
343 Returns:
344 -------
345 Portfolio
346 A new instance of the Portfolio class with the attributes
347 (prices, units, aum) as specified in the Builder
349 Notes:
350 -----
351 The resulting Portfolio object will be immutable (frozen) and will
352 have the same data as the Builder from which it was built, but
353 with a different interface focused on analysis rather than construction.
355 Examples:
356 --------
357 >>> import pandas as pd
358 >>> from cvx.simulator import Builder
359 >>> dates = pd.date_range("2020-01-01", periods=4)
360 >>> prices = pd.DataFrame(
361 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
362 ... index=dates,
363 ... )
364 >>> builder = Builder(prices=prices, initial_aum=1000.0)
365 >>> for t, state in builder:
366 ... builder.position = pd.Series({"A": 5.0, "B": 4.0})
367 ... builder.aum = state.aum
368 >>> portfolio = builder.build()
370 The portfolio carries the units the loop accumulated:
372 >>> portfolio.units.iloc[-1]
373 A 5.0
374 B 4.0
375 Name: 2020-01-04 00:00:00, dtype: float64
377 The result is frozen — analysis only, no further construction:
379 >>> portfolio.aum = 10.0
380 Traceback (most recent call last):
381 ...
382 dataclasses.FrozenInstanceError: cannot assign to field 'aum'
384 """
385 return Portfolio(prices=self.prices, units=self.units, aum=self.aum)
387 @property
388 def weights(self) -> np.ndarray:
389 """Get the current portfolio weights for each asset.
391 This property retrieves the weight of each asset in the portfolio
392 from the internal state. Weights represent the proportion of the
393 portfolio's value invested in each asset.
395 Returns:
396 -------
397 np.array
398 An array of weights for each asset in the portfolio
400 Notes:
401 -----
402 Weights sum to 1.0 for a fully invested portfolio with no leverage.
403 Negative weights represent short positions.
405 """
406 return np.asarray(self._state.weights[self._state.assets])
408 @weights.setter
409 def weights(self, weights: np.ndarray) -> None:
410 """Set the current portfolio weights for each asset.
412 This setter updates the portfolio weights and automatically converts
413 the weights to positions (units) using the current prices and NAV.
415 Parameters
416 ----------
417 weights : np.array
418 An array of weights for each asset in the portfolio
420 Returns:
421 -------
422 None
424 Notes:
425 -----
426 This is a convenient way to rebalance the portfolio by specifying
427 the desired allocation as weights rather than exact positions.
428 The conversion formula is: position = NAV * weights / prices
430 Examples:
431 --------
432 Hold an equal-weighted book, rebalanced every day. The weights are
433 constant, so the units drift as the relative prices move:
435 >>> import numpy as np
436 >>> import pandas as pd
437 >>> from cvx.simulator import Builder
438 >>> dates = pd.date_range("2020-01-01", periods=4)
439 >>> prices = pd.DataFrame(
440 ... {"A": [100.0, 102.0, 104.0, 103.0], "B": [50.0, 51.0, 52.0, 51.0]},
441 ... index=dates,
442 ... )
443 >>> builder = Builder(prices=prices, initial_aum=1000.0)
444 >>> for t, state in builder:
445 ... builder.weights = np.array([0.5, 0.5])
446 ... builder.aum = state.aum
447 >>> builder.units.round(4)
448 A B
449 2020-01-01 5.00 10.0000
450 2020-01-02 5.00 10.0000
451 2020-01-03 5.00 10.0000
452 2020-01-04 4.90 9.8971
454 """
455 self.position = self._state.nav * weights / self.current_prices
457 @property
458 def aum(self) -> pd.Series:
459 """Get the assets under management (AUM) history of the portfolio.
461 This property returns the entire series of AUM values over time,
462 representing the total value of the portfolio at each time point.
464 Returns:
465 -------
466 pd.Series
467 A Series containing the AUM values over time, with dates as index
469 Notes:
470 -----
471 AUM (assets under management) represents the total value of the portfolio,
472 including both invested positions and uninvested cash.
474 """
475 return self._aum
477 @aum.setter
478 def aum(self, aum: float) -> None:
479 """Set the current assets under management (AUM) of the portfolio.
481 This setter updates the AUM value at the current time point and
482 also updates the internal state's AUM.
484 Parameters
485 ----------
486 aum : float
487 The new AUM value to set
489 Returns:
490 -------
491 None
493 Notes:
494 -----
495 Changing the AUM affects the portfolio's ability to take positions,
496 as position sizes are often calculated as a fraction of AUM.
498 """
499 self._aum[self._state.time] = aum
500 self._state.aum = aum