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

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

123 

124 if not self.prices.index.is_unique: 

125 msg = "Index must have unique values" 

126 raise ValueError(msg) 

127 

128 self._state = State() 

129 

130 self._units = pd.DataFrame( 

131 index=self.prices.index, 

132 columns=self.prices.columns, 

133 data=np.nan, 

134 dtype=float, 

135 ) 

136 

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

138 

139 self._state.aum = self.initial_aum 

140 

141 @property 

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

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

144 

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. 

147 

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) 

154 

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. 

159 

160 """ 

161 return self.prices.apply(valid) 

162 

163 @property 

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

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

166 

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

168 

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 

175 

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. 

180 

181 """ 

182 return self.prices.apply( 

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

184 ).transpose() 

185 

186 @property 

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

188 """The index of the portfolio. 

189 

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) 

194 

195 @property 

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

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

198 

199 This property retrieves the current prices from the internal state 

200 for all assets that are currently in the portfolio. 

201 

202 Returns: 

203 ------- 

204 np.array 

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

206 

207 Notes: 

208 ----- 

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

210 during iteration through the portfolio's time index. 

211 

212 """ 

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

214 

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

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

217 

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. 

220 

221 Yield: 

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

223 state: the current state of the portfolio, 

224 

225 taking into account the stock prices at each interval. 

226 

227 """ 

228 for t in self.index: 

229 # update the current prices for the portfolio 

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

231 

232 # update the current time for the state 

233 self._state.time = t 

234 

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

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

237 

238 @property 

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

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

241 

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

243 

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

245 """ 

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

247 

248 @position.setter 

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

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

251 

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. 

254 

255 Parameters 

256 ---------- 

257 position : pd.Series 

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

259 

260 Returns: 

261 ------- 

262 None 

263 

264 """ 

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

266 self._state.position = position 

267 

268 @property 

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

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

271 

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

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

274 

275 Returns: 

276 ------- 

277 pd.Series 

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

279 indexed by asset 

280 

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. 

286 

287 """ 

288 return self.position * self.current_prices 

289 

290 @cashposition.setter 

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

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

293 

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

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

296 

297 Parameters 

298 ---------- 

299 cashposition : pd.Series 

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

301 indexed by asset 

302 

303 Returns: 

304 ------- 

305 None 

306 

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 

312 

313 """ 

314 self.position = cashposition / self.current_prices 

315 

316 @property 

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

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

319 

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

321 assets over all time points in the portfolio. 

322 

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 

328 

329 Notes: 

330 ----- 

331 This property is particularly useful for testing and for building 

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

333 

334 """ 

335 return self._units 

336 

337 def build(self) -> Portfolio: 

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

339 

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. 

342 

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 

348 

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. 

354 

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

369 

370 The portfolio carries the units the loop accumulated: 

371 

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

373 A 5.0 

374 B 4.0 

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

376 

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

378 

379 >>> portfolio.aum = 10.0 

380 Traceback (most recent call last): 

381 ... 

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

383 

384 """ 

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

386 

387 @property 

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

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

390 

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. 

394 

395 Returns: 

396 ------- 

397 np.array 

398 An array of weights for each asset in the portfolio 

399 

400 Notes: 

401 ----- 

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

403 Negative weights represent short positions. 

404 

405 """ 

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

407 

408 @weights.setter 

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

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

411 

412 This setter updates the portfolio weights and automatically converts 

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

414 

415 Parameters 

416 ---------- 

417 weights : np.array 

418 An array of weights for each asset in the portfolio 

419 

420 Returns: 

421 ------- 

422 None 

423 

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 

429 

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: 

434 

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 

453 

454 """ 

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

456 

457 @property 

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

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

460 

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

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

463 

464 Returns: 

465 ------- 

466 pd.Series 

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

468 

469 Notes: 

470 ----- 

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

472 including both invested positions and uninvested cash. 

473 

474 """ 

475 return self._aum 

476 

477 @aum.setter 

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

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

480 

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

482 also updates the internal state's AUM. 

483 

484 Parameters 

485 ---------- 

486 aum : float 

487 The new AUM value to set 

488 

489 Returns: 

490 ------- 

491 None 

492 

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. 

497 

498 """ 

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

500 self._state.aum = aum