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

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

66 

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 

78 

79 The AUM series tracks the value of the book as prices move: 

80 

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 

87 

88 Once the loop is done, freeze the result into a Portfolio: 

89 

90 >>> portfolio = builder.build() 

91 >>> type(portfolio).__name__ 

92 'Portfolio' 

93 

94 """ 

95 

96 prices: pd.DataFrame 

97 initial_aum: float = 1e6 

98 

99 _state: State = field(init=False) 

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

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

102 

103 def __post_init__(self) -> None: 

104 """Initialize the Builder instance after creation. 

105 

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. 

109 

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 

113 

114 Returns: 

115 ------- 

116 None 

117 

118 """ 

119 if not self.prices.index.is_monotonic_increasing: 

120 msg = "`prices` index must be monotonic increasing." 

121 raise ValueError(msg) 

122 

123 if not self.prices.index.is_unique: 

124 msg = "`prices` index must be unique." 

125 raise ValueError(msg) 

126 

127 self._state = State() 

128 

129 self._units = pd.DataFrame( 

130 index=self.prices.index, 

131 columns=self.prices.columns, 

132 data=np.nan, 

133 dtype=float, 

134 ) 

135 

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

137 

138 self._state.aum = self.initial_aum 

139 

140 @property 

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

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

143 

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. 

146 

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) 

153 

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. 

158 

159 """ 

160 return self.prices.apply(valid) 

161 

162 @property 

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

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

165 

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

167 

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 

174 

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. 

179 

180 """ 

181 return self.prices.apply( 

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

183 ).transpose() 

184 

185 @property 

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

187 """The index of the portfolio. 

188 

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) 

193 

194 @property 

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

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

197 

198 This property retrieves the current prices from the internal state 

199 for all assets that are currently in the portfolio. 

200 

201 Returns: 

202 ------- 

203 np.array 

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

205 

206 Notes: 

207 ----- 

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

209 during iteration through the portfolio's time index. 

210 

211 """ 

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

213 

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

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

216 

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. 

219 

220 Yield: 

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

222 state: the current state of the portfolio, 

223 

224 taking into account the stock prices at each interval. 

225 

226 """ 

227 for t in self.index: 

228 # update the current prices for the portfolio 

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

230 

231 # update the current time for the state 

232 self._state.time = t 

233 

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

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

236 

237 @property 

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

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

240 

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

242 

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

244 """ 

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

246 

247 @position.setter 

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

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

250 

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. 

253 

254 Parameters 

255 ---------- 

256 position : pd.Series 

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

258 

259 Returns: 

260 ------- 

261 None 

262 

263 """ 

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

265 self._state.position = position 

266 

267 @property 

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

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

270 

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

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

273 

274 Returns: 

275 ------- 

276 pd.Series 

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

278 indexed by asset 

279 

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. 

285 

286 """ 

287 return self.position * self.current_prices 

288 

289 @cashposition.setter 

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

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

292 

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

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

295 

296 Parameters 

297 ---------- 

298 cashposition : pd.Series 

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

300 indexed by asset 

301 

302 Returns: 

303 ------- 

304 None 

305 

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 

311 

312 """ 

313 self.position = cashposition / self.current_prices 

314 

315 @property 

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

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

318 

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

320 assets over all time points in the portfolio. 

321 

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 

327 

328 Notes: 

329 ----- 

330 This property is particularly useful for testing and for building 

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

332 

333 """ 

334 return self._units 

335 

336 def build(self) -> Portfolio: 

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

338 

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. 

341 

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 

347 

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. 

353 

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() 

368 

369 The portfolio carries the units the loop accumulated: 

370 

371 >>> portfolio.units.iloc[-1] 

372 A 5.0 

373 B 4.0 

374 Name: 2020-01-04 00:00:00, dtype: float64 

375 

376 The result is frozen — analysis only, no further construction: 

377 

378 >>> portfolio.aum = 10.0 

379 Traceback (most recent call last): 

380 ... 

381 dataclasses.FrozenInstanceError: cannot assign to field 'aum' 

382 

383 """ 

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

385 

386 @property 

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

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

389 

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. 

393 

394 Returns: 

395 ------- 

396 np.array 

397 An array of weights for each asset in the portfolio 

398 

399 Notes: 

400 ----- 

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

402 Negative weights represent short positions. 

403 

404 """ 

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

406 

407 @weights.setter 

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

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

410 

411 This setter updates the portfolio weights and automatically converts 

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

413 

414 Parameters 

415 ---------- 

416 weights : np.array 

417 An array of weights for each asset in the portfolio 

418 

419 Returns: 

420 ------- 

421 None 

422 

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 

428 

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: 

433 

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 

452 

453 """ 

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

455 

456 @property 

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

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

459 

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

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

462 

463 Returns: 

464 ------- 

465 pd.Series 

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

467 

468 Notes: 

469 ----- 

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

471 including both invested positions and uninvested cash. 

472 

473 """ 

474 return self._aum 

475 

476 @aum.setter 

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

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

479 

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

481 also updates the internal state's AUM. 

482 

483 Parameters 

484 ---------- 

485 aum : float 

486 The new AUM value to set 

487 

488 Returns: 

489 ------- 

490 None 

491 

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. 

496 

497 """ 

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

499 self._state.aum = aum