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

1"""Builder class for the CVX Simulator.""" 

2 

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 

17 

18from collections.abc import Generator 

19from dataclasses import dataclass, field 

20 

21import numpy as np 

22import pandas as pd 

23import polars as pl 

24 

25from .portfolio import Portfolio 

26from .state import State 

27from .utils.interpolation import valid 

28 

29 

30def polars2pandas(dframe: pl.DataFrame, date_col: str = "date") -> pd.DataFrame: 

31 """Convert a Polars DataFrame to a Pandas DataFrame. 

32 

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. 

35 

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". 

39 

40 Returns: 

41 pd.DataFrame: The converted Pandas DataFrame with the date column as its index. 

42 

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) 

47 

48 

49@dataclass 

50class Builder: 

51 """The Builder is an auxiliary class used to build portfolios. 

52 

53 It overloads the __iter__ method to allow the class to iterate over 

54 the timestamps for which the portfolio data is available. 

55 

56 In each iteration we can update the portfolio by setting either 

57 the weights, the position or the cash position. 

58 

59 After the iteration has been completed we build a Portfolio object 

60 by calling the build method. 

61 """ 

62 

63 prices: pd.DataFrame 

64 initial_aum: float = 1e6 

65 

66 _state: State = field(init=False) 

67 _units: pd.DataFrame = field(init=False) 

68 _aum: pd.Series = field(init=False) 

69 

70 def __post_init__(self) -> None: 

71 """Initialize the Builder instance after creation. 

72 

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. 

76 

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 

80 

81 Returns: 

82 ------- 

83 None 

84 

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) 

90 

91 if not self.prices.index.is_unique: 

92 msg = "Index must have unique values" 

93 raise ValueError(msg) 

94 

95 self._state = State() 

96 

97 self._units = pd.DataFrame( 

98 index=self.prices.index, 

99 columns=self.prices.columns, 

100 data=np.nan, 

101 dtype=float, 

102 ) 

103 

104 self._aum = pd.Series(index=self.prices.index, dtype=float) 

105 

106 self._state.aum = self.initial_aum 

107 

108 @property 

109 def valid(self) -> pd.Series: 

110 """Check the validity of price data for each asset. 

111 

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. 

114 

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) 

121 

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. 

126 

127 """ 

128 return self.prices.apply(valid) 

129 

130 @property 

131 def intervals(self) -> pd.DataFrame: 

132 """Get the first and last valid index for each asset's price series. 

133 

134 This property identifies the time range for which each asset has valid price data. 

135 

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 

142 

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. 

147 

148 """ 

149 return self.prices.apply( 

150 lambda ts: pd.Series({"first": ts.first_valid_index(), "last": ts.last_valid_index()}) 

151 ).transpose() 

152 

153 @property 

154 def index(self) -> pd.DatetimeIndex: 

155 """The index of the portfolio. 

156 

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) 

161 

162 @property 

163 def current_prices(self) -> np.ndarray: 

164 """Get the current prices for all assets in the portfolio. 

165 

166 This property retrieves the current prices from the internal state 

167 for all assets that are currently in the portfolio. 

168 

169 Returns: 

170 ------- 

171 np.array 

172 An array of current prices for all assets in the portfolio 

173 

174 Notes: 

175 ----- 

176 The prices are retrieved from the internal state, which is updated 

177 during iteration through the portfolio's time index. 

178 

179 """ 

180 return np.asarray(self._state.prices[self._state.assets]) 

181 

182 def __iter__(self) -> Generator[tuple[pd.DatetimeIndex, State]]: 

183 """Iterate over object in a for loop. 

184 

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. 

187 

188 Yield: 

189 time: a pandas DatetimeIndex object containing the dates seen so far. 

190 state: the current state of the portfolio, 

191 

192 taking into account the stock prices at each interval. 

193 

194 """ 

195 for t in self.index: 

196 # update the current prices for the portfolio 

197 self._state.prices = self.prices.loc[t] 

198 

199 # update the current time for the state 

200 self._state.time = t 

201 

202 # yield the vector of times seen so far and the current state 

203 yield self.index[self.index <= t], self._state 

204 

205 @property 

206 def position(self) -> pd.Series: 

207 """The position property returns the current position of the portfolio. 

208 

209 It returns a pandas Series object containing the current position of the portfolio. 

210 

211 Returns: pd.Series: a pandas Series object containing the current position of the portfolio. 

212 """ 

213 return self._units.loc[self._state.time] 

214 

215 @position.setter 

216 def position(self, position: pd.Series) -> None: 

217 """Set the current position of the portfolio. 

218 

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. 

221 

222 Parameters 

223 ---------- 

224 position : pd.Series 

225 A pandas Series containing the new position (number of units) for each asset 

226 

227 Returns: 

228 ------- 

229 None 

230 

231 """ 

232 self._units.loc[self._state.time, self._state.assets] = position 

233 self._state.position = position 

234 

235 @property 

236 def cashposition(self) -> pd.Series: 

237 """Get the current cash value of each position in the portfolio. 

238 

239 This property calculates the cash value of each position by multiplying 

240 the number of units by the current price for each asset. 

241 

242 Returns: 

243 ------- 

244 pd.Series 

245 A pandas Series containing the cash value of each position, 

246 indexed by asset 

247 

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. 

253 

254 """ 

255 return self.position * self.current_prices 

256 

257 @cashposition.setter 

258 def cashposition(self, cashposition: pd.Series) -> None: 

259 """Set the current cash value of each position in the portfolio. 

260 

261 This setter updates the cash value of each position and automatically 

262 converts the cash values to positions (units) using the current prices. 

263 

264 Parameters 

265 ---------- 

266 cashposition : pd.Series 

267 A pandas Series containing the new cash value for each position, 

268 indexed by asset 

269 

270 Returns: 

271 ------- 

272 None 

273 

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 

279 

280 """ 

281 self.position = cashposition / self.current_prices 

282 

283 @property 

284 def units(self) -> pd.DataFrame: 

285 """Get the complete history of portfolio holdings. 

286 

287 This property returns the entire DataFrame of holdings (units) for all 

288 assets over all time points in the portfolio. 

289 

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 

295 

296 Notes: 

297 ----- 

298 This property is particularly useful for testing and for building 

299 the final Portfolio object via the build() method. 

300 

301 """ 

302 return self._units 

303 

304 def build(self) -> Portfolio: 

305 """Create a new Portfolio instance from the current builder state. 

306 

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. 

309 

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 

315 

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. 

321 

322 """ 

323 return Portfolio(prices=self.prices, units=self.units, aum=self.aum) 

324 

325 @property 

326 def weights(self) -> np.ndarray: 

327 """Get the current portfolio weights for each asset. 

328 

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. 

332 

333 Returns: 

334 ------- 

335 np.array 

336 An array of weights for each asset in the portfolio 

337 

338 Notes: 

339 ----- 

340 Weights sum to 1.0 for a fully invested portfolio with no leverage. 

341 Negative weights represent short positions. 

342 

343 """ 

344 return np.asarray(self._state.weights[self._state.assets]) 

345 

346 @weights.setter 

347 def weights(self, weights: np.ndarray) -> None: 

348 """Set the current portfolio weights for each asset. 

349 

350 This setter updates the portfolio weights and automatically converts 

351 the weights to positions (units) using the current prices and NAV. 

352 

353 Parameters 

354 ---------- 

355 weights : np.array 

356 An array of weights for each asset in the portfolio 

357 

358 Returns: 

359 ------- 

360 None 

361 

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 

367 

368 """ 

369 self.position = self._state.nav * weights / self.current_prices 

370 

371 @property 

372 def aum(self) -> pd.Series: 

373 """Get the assets under management (AUM) history of the portfolio. 

374 

375 This property returns the entire series of AUM values over time, 

376 representing the total value of the portfolio at each time point. 

377 

378 Returns: 

379 ------- 

380 pd.Series 

381 A Series containing the AUM values over time, with dates as index 

382 

383 Notes: 

384 ----- 

385 AUM (assets under management) represents the total value of the portfolio, 

386 including both invested positions and uninvested cash. 

387 

388 """ 

389 return self._aum 

390 

391 @aum.setter 

392 def aum(self, aum: float) -> None: 

393 """Set the current assets under management (AUM) of the portfolio. 

394 

395 This setter updates the AUM value at the current time point and 

396 also updates the internal state's AUM. 

397 

398 Parameters 

399 ---------- 

400 aum : float 

401 The new AUM value to set 

402 

403 Returns: 

404 ------- 

405 None 

406 

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. 

411 

412 """ 

413 self._aum[self._state.time] = aum 

414 self._state.aum = aum