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
« 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.
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"""
21from dataclasses import dataclass
22from datetime import datetime
24import numpy as np
25import pandas as pd
28@dataclass()
29class State:
30 """Represents the current state of a portfolio during simulation.
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.
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).
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
57 """
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
67 @property
68 def cash(self) -> float:
69 """Get the current amount of cash available in the portfolio.
71 Returns
72 -------
73 float
74 The cash component of the portfolio, calculated as NAV minus
75 the value of all positions
77 """
78 return self.nav - self.value
80 @cash.setter
81 def cash(self, cash: float) -> None:
82 """Update the amount of cash available in the portfolio.
84 This updates the AUM (assets under management) based on the new
85 cash amount while keeping the value of positions constant.
87 Parameters
88 ----------
89 cash : float
90 The new cash amount to set
92 """
93 self.aum = cash + self.value
95 @property
96 def nav(self) -> float:
97 """Get the net asset value (NAV) of the portfolio.
99 The NAV represents the total value of the portfolio, including
100 both the value of positions and available cash.
102 Returns
103 -------
104 float
105 The net asset value of the portfolio
107 Notes
108 -----
109 This is equivalent to the AUM (assets under management).
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
116 @property
117 def value(self) -> float:
118 """Get the value of all positions in the portfolio.
120 This computes the total value of all holdings at current prices,
121 not including cash.
123 Returns
124 -------
125 float
126 The sum of values of all positions
128 Notes
129 -----
130 If positions are missing (None), the sum will effectively be zero.
132 """
133 return self.cashposition.sum()
135 @property
136 def cashposition(self) -> pd.Series:
137 """Get the cash value of each position in the portfolio.
139 This computes the cash value of each position by multiplying
140 the number of units by the current price for each asset.
142 Returns
143 -------
144 pd.Series
145 Series with the cash value of each position, indexed by asset
147 """
148 return self.prices * self.position
150 @property
151 def position(self) -> pd.Series:
152 """Get the current position (number of units) for each asset.
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.
161 """
162 if self._position is None:
163 return pd.Series(index=self.assets, dtype=float)
165 return self._position
167 @position.setter
168 def position(self, position: np.ndarray | pd.Series) -> None:
169 """Update the position of the portfolio.
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.
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.
181 """
182 # update the position
183 position = pd.Series(index=self.assets, data=position)
185 # compute the trades (can be fractional)
186 self._trades = position.subtract(self.position, fill_value=0.0)
188 # update only now as otherwise the trades would be wrong
189 self._position = position
191 @property
192 def gmv(self) -> float:
193 """Get the gross market value of the portfolio.
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.
198 Returns
199 -------
200 float
201 The gross market value (abs(short) + long)
203 """
204 return self.cashposition.abs().sum()
206 @property
207 def time(self) -> datetime | None:
208 """Get the current time of the portfolio state.
210 Returns
211 -------
212 Optional[datetime]
213 The current time in the simulation, or None if not set
215 """
216 return self._time
218 @time.setter
219 def time(self, time: datetime) -> None:
220 """Update the time of the portfolio state.
222 This method updates the current time and computes the number of days
223 between the new time and the previous time.
225 Parameters
226 ----------
227 time : datetime
228 The new time to set
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
238 @property
239 def days(self) -> int:
240 """Get the number of days between the current and previous time.
242 Returns
243 -------
244 int
245 Number of days between the current and previous time
247 Notes
248 -----
249 This is useful for computing interest when holding cash or for
250 time-dependent calculations.
252 """
253 return self._days
255 @property
256 def assets(self) -> pd.Index:
257 """Get the assets currently in the portfolio.
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.
265 """
266 if self._prices is None:
267 return pd.Index(data=[], dtype=str)
269 return self.prices.dropna().index
271 @property
272 def trades(self) -> pd.Series | None:
273 """Get the trades needed to reach the current position.
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.
281 Notes
282 -----
283 This is helpful when computing trading costs following a position change.
284 Positive values represent buys, negative values represent sells.
286 """
287 return self._trades
289 @property
290 def mask(self) -> np.ndarray:
291 """Get a boolean mask for assets with valid (non-NaN) prices.
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.
299 """
300 if self._prices is None:
301 return np.empty(0, dtype=bool)
303 return np.isfinite(self.prices.values)
305 @property
306 def prices(self) -> pd.Series:
307 """Get the current prices of assets in the portfolio.
309 Returns
310 -------
311 pd.Series
312 Series of current prices indexed by asset.
313 Returns an empty series if no prices are set.
315 """
316 if self._prices is None:
317 return pd.Series(dtype=float)
318 return self._prices
320 @prices.setter
321 def prices(self, prices: pd.Series | dict) -> None:
322 """Update the prices of assets in the portfolio.
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.
328 Parameters
329 ----------
330 prices : pd.Series
331 New prices for assets in the portfolio
333 Notes
334 -----
335 The profit is calculated as the difference between the portfolio value
336 before and after the price update.
338 """
339 value_before = (self.prices * self.position).sum() # self.cashposition.sum()
340 value_after = (prices * self.position).sum()
342 self._prices = prices
343 self._profit = value_after - value_before
344 self.aum += self.profit
346 @property
347 def profit(self) -> float:
348 """Get the profit achieved between the previous and current prices.
350 Returns
351 -------
352 float
353 The profit (or loss) achieved due to price changes since the
354 last price update
356 """
357 return self._profit
359 @property
360 def aum(self) -> float:
361 """Get the current assets under management (AUM) of the portfolio.
363 Returns
364 -------
365 float
366 The total assets under management
368 """
369 return self._aum
371 @aum.setter
372 def aum(self, aum: float) -> None:
373 """Update the assets under management (AUM) of the portfolio.
375 Parameters
376 ----------
377 aum : float
378 The new assets under management value to set
380 """
381 self._aum = aum
383 @property
384 def weights(self) -> pd.Series:
385 """Get the weight of each asset in the portfolio.
387 This computes the weighting of each asset as a fraction of the
388 total portfolio value (NAV).
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
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.
401 """
402 if not np.isclose(self.nav, self.aum):
403 raise ValueError(f"{self.nav} != {self.aum}")
405 return self.cashposition / self.nav
407 @property
408 def leverage(self) -> float:
409 """Get the leverage of the portfolio.
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.
415 Returns
416 -------
417 float
418 The leverage ratio of the portfolio
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.
426 """
427 return float(self.weights.abs().sum())