Coverage for src/cvxsimulator/state.py: 100%

96 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-10 18:45 +0000

1# Copyright 2023 Stanford University Convex Optimization Group 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14"""Portfolio state management for the CVX Simulator. 

15 

16This module provides the State class, which represents the current state of a portfolio 

17during simulation. It tracks positions, prices, cash, and other portfolio metrics, 

18and is updated by the Builder class during the simulation process. 

19""" 

20 

21from dataclasses import dataclass 

22from datetime import datetime 

23 

24import numpy as np 

25import pandas as pd 

26 

27 

28@dataclass() 

29class State: 

30 """Represents the current state of a portfolio during simulation. 

31 

32 The State class tracks the current positions, prices, cash, and other metrics 

33 of a portfolio at a specific point in time. It is updated within a loop by the 

34 Builder class during the simulation process. 

35 

36 The class provides properties for accessing various portfolio metrics like 

37 cash, NAV, value, weights, and leverage. It also provides setter methods 

38 for updating the portfolio state (aum, cash, position, prices). 

39 

40 Attributes 

41 ---------- 

42 _prices : pd.Series 

43 Current prices of assets in the portfolio 

44 _position : pd.Series 

45 Current positions (units) of assets in the portfolio 

46 _trades : pd.Series 

47 Trades needed to reach the current position 

48 _time : datetime 

49 Current time in the simulation 

50 _days : int 

51 Number of days between the current and previous time 

52 _profit : float 

53 Profit achieved between the previous and current prices 

54 _aum : float 

55 Current assets under management (AUM) of the portfolio 

56 

57 """ 

58 

59 _prices: pd.Series | None = None 

60 _position: pd.Series | None = None 

61 _trades: pd.Series | None = None 

62 _time: datetime | None = None 

63 _days: int = 0 

64 _profit: float = 0.0 

65 _aum: float = 0.0 

66 

67 @property 

68 def cash(self) -> float: 

69 """Get the current amount of cash available in the portfolio. 

70 

71 Returns 

72 ------- 

73 float 

74 The cash component of the portfolio, calculated as NAV minus 

75 the value of all positions 

76 

77 """ 

78 return self.nav - self.value 

79 

80 @cash.setter 

81 def cash(self, cash: float) -> None: 

82 """Update the amount of cash available in the portfolio. 

83 

84 This updates the AUM (assets under management) based on the new 

85 cash amount while keeping the value of positions constant. 

86 

87 Parameters 

88 ---------- 

89 cash : float 

90 The new cash amount to set 

91 

92 """ 

93 self.aum = cash + self.value 

94 

95 @property 

96 def nav(self) -> float: 

97 """Get the net asset value (NAV) of the portfolio. 

98 

99 The NAV represents the total value of the portfolio, including 

100 both the value of positions and available cash. 

101 

102 Returns 

103 ------- 

104 float 

105 The net asset value of the portfolio 

106 

107 Notes 

108 ----- 

109 This is equivalent to the AUM (assets under management). 

110 

111 """ 

112 # assert np.isclose(self.value + self.cash, self.aum), f"{self.value + self.cash} != {self.aum}" 

113 # return self.value + self.cash 

114 return self.aum 

115 

116 @property 

117 def value(self) -> float: 

118 """Get the value of all positions in the portfolio. 

119 

120 This computes the total value of all holdings at current prices, 

121 not including cash. 

122 

123 Returns 

124 ------- 

125 float 

126 The sum of values of all positions 

127 

128 Notes 

129 ----- 

130 If positions are missing (None), the sum will effectively be zero. 

131 

132 """ 

133 return self.cashposition.sum() 

134 

135 @property 

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

137 """Get the cash value of each position in the portfolio. 

138 

139 This computes the cash value of each position by multiplying 

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

141 

142 Returns 

143 ------- 

144 pd.Series 

145 Series with the cash value of each position, indexed by asset 

146 

147 """ 

148 return self.prices * self.position 

149 

150 @property 

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

152 """Get the current position (number of units) for each asset. 

153 

154 Returns 

155 ------- 

156 pd.Series 

157 Series with the number of units held for each asset, indexed by asset. 

158 If the position is not yet set, returns an empty series with the 

159 correct index. 

160 

161 """ 

162 if self._position is None: 

163 return pd.Series(index=self.assets, dtype=float) 

164 

165 return self._position 

166 

167 @position.setter 

168 def position(self, position: np.ndarray | pd.Series) -> None: 

169 """Update the position of the portfolio. 

170 

171 This method updates the position (number of units) for each asset, 

172 computes the required trades to reach the new position, and updates 

173 the internal state. 

174 

175 Parameters 

176 ---------- 

177 position : Union[np.ndarray, pd.Series] 

178 The new position to set, either as a numpy array or pandas Series. 

179 If a numpy array, it must have the same length as self.assets. 

180 

181 """ 

182 # update the position 

183 position = pd.Series(index=self.assets, data=position) 

184 

185 # compute the trades (can be fractional) 

186 self._trades = position.subtract(self.position, fill_value=0.0) 

187 

188 # update only now as otherwise the trades would be wrong 

189 self._position = position 

190 

191 @property 

192 def gmv(self) -> float: 

193 """Get the gross market value of the portfolio. 

194 

195 The gross market value is the sum of the absolute values of all positions, 

196 which represents the total market exposure including both long and short positions. 

197 

198 Returns 

199 ------- 

200 float 

201 The gross market value (abs(short) + long) 

202 

203 """ 

204 return self.cashposition.abs().sum() 

205 

206 @property 

207 def time(self) -> datetime | None: 

208 """Get the current time of the portfolio state. 

209 

210 Returns 

211 ------- 

212 Optional[datetime] 

213 The current time in the simulation, or None if not set 

214 

215 """ 

216 return self._time 

217 

218 @time.setter 

219 def time(self, time: datetime) -> None: 

220 """Update the time of the portfolio state. 

221 

222 This method updates the current time and computes the number of days 

223 between the new time and the previous time. 

224 

225 Parameters 

226 ---------- 

227 time : datetime 

228 The new time to set 

229 

230 """ 

231 if self.time is None: 

232 self._days = 0 

233 self._time = time 

234 else: 

235 self._days = (time - self.time).days 

236 self._time = time 

237 

238 @property 

239 def days(self) -> int: 

240 """Get the number of days between the current and previous time. 

241 

242 Returns 

243 ------- 

244 int 

245 Number of days between the current and previous time 

246 

247 Notes 

248 ----- 

249 This is useful for computing interest when holding cash or for 

250 time-dependent calculations. 

251 

252 """ 

253 return self._days 

254 

255 @property 

256 def assets(self) -> pd.Index: 

257 """Get the assets currently in the portfolio. 

258 

259 Returns 

260 ------- 

261 pd.Index 

262 Index of assets with valid prices in the portfolio. 

263 If no prices are set, returns an empty index. 

264 

265 """ 

266 if self._prices is None: 

267 return pd.Index(data=[], dtype=str) 

268 

269 return self.prices.dropna().index 

270 

271 @property 

272 def trades(self) -> pd.Series | None: 

273 """Get the trades needed to reach the current position. 

274 

275 Returns 

276 ------- 

277 Optional[pd.Series] 

278 Series of trades (changes in position) needed to reach the current position. 

279 None if no trades have been calculated yet. 

280 

281 Notes 

282 ----- 

283 This is helpful when computing trading costs following a position change. 

284 Positive values represent buys, negative values represent sells. 

285 

286 """ 

287 return self._trades 

288 

289 @property 

290 def mask(self) -> np.ndarray: 

291 """Get a boolean mask for assets with valid (non-NaN) prices. 

292 

293 Returns 

294 ------- 

295 np.ndarray 

296 Boolean array where True indicates a valid price and False indicates 

297 a missing (NaN) price. Returns an empty array if no prices are set. 

298 

299 """ 

300 if self._prices is None: 

301 return np.empty(0, dtype=bool) 

302 

303 return np.isfinite(self.prices.values) 

304 

305 @property 

306 def prices(self) -> pd.Series: 

307 """Get the current prices of assets in the portfolio. 

308 

309 Returns 

310 ------- 

311 pd.Series 

312 Series of current prices indexed by asset. 

313 Returns an empty series if no prices are set. 

314 

315 """ 

316 if self._prices is None: 

317 return pd.Series(dtype=float) 

318 return self._prices 

319 

320 @prices.setter 

321 def prices(self, prices: pd.Series | dict) -> None: 

322 """Update the prices of assets in the portfolio. 

323 

324 This method updates the prices and calculates the profit achieved 

325 due to price changes. It also updates the portfolio's AUM by adding 

326 the profit. 

327 

328 Parameters 

329 ---------- 

330 prices : pd.Series 

331 New prices for assets in the portfolio 

332 

333 Notes 

334 ----- 

335 The profit is calculated as the difference between the portfolio value 

336 before and after the price update. 

337 

338 """ 

339 value_before = (self.prices * self.position).sum() # self.cashposition.sum() 

340 value_after = (prices * self.position).sum() 

341 

342 self._prices = prices 

343 self._profit = value_after - value_before 

344 self.aum += self.profit 

345 

346 @property 

347 def profit(self) -> float: 

348 """Get the profit achieved between the previous and current prices. 

349 

350 Returns 

351 ------- 

352 float 

353 The profit (or loss) achieved due to price changes since the 

354 last price update 

355 

356 """ 

357 return self._profit 

358 

359 @property 

360 def aum(self) -> float: 

361 """Get the current assets under management (AUM) of the portfolio. 

362 

363 Returns 

364 ------- 

365 float 

366 The total assets under management 

367 

368 """ 

369 return self._aum 

370 

371 @aum.setter 

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

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

374 

375 Parameters 

376 ---------- 

377 aum : float 

378 The new assets under management value to set 

379 

380 """ 

381 self._aum = aum 

382 

383 @property 

384 def weights(self) -> pd.Series: 

385 """Get the weight of each asset in the portfolio. 

386 

387 This computes the weighting of each asset as a fraction of the 

388 total portfolio value (NAV). 

389 

390 Returns 

391 ------- 

392 pd.Series 

393 Series containing the weight of each asset as a fraction of the 

394 total portfolio value, indexed by asset 

395 

396 Notes 

397 ----- 

398 If positions are missing, a series of zeros is effectively returned. 

399 The sum of weights equals 1.0 for a fully invested portfolio with no leverage. 

400 

401 """ 

402 if not np.isclose(self.nav, self.aum): 

403 raise ValueError(f"{self.nav} != {self.aum}") 

404 

405 return self.cashposition / self.nav 

406 

407 @property 

408 def leverage(self) -> float: 

409 """Get the leverage of the portfolio. 

410 

411 Leverage is calculated as the sum of the absolute values of all position 

412 weights. For a long-only portfolio with no cash, this equals 1.0. 

413 For a portfolio with shorts or leverage, this will be greater than 1.0. 

414 

415 Returns 

416 ------- 

417 float 

418 The leverage ratio of the portfolio 

419 

420 Notes 

421 ----- 

422 A leverage of 2.0 means the portfolio has twice the market exposure 

423 compared to its net asset value, which could be achieved through 

424 borrowing or short selling. 

425 

426 """ 

427 return float(self.weights.abs().sum())