Coverage for src/cvx/simulator/state.py: 100%
99 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 04:18 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-09 04:18 +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 Examples:
58 --------
59 A state starts empty and is filled in by assignment. Order matters: prices
60 define which assets exist, so they are set before any position.
62 >>> import pandas as pd
63 >>> from cvx.simulator import State
64 >>> state = State()
65 >>> state.aum = 1000.0
66 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0})
67 >>> state.position = pd.Series({"A": 5.0, "B": 4.0})
69 The derived quantities follow from those three assignments:
71 >>> state.cashposition
72 A 500.0
73 B 200.0
74 dtype: float64
75 >>> state.value
76 700.0
77 >>> float(state.cash)
78 300.0
80 Updating the prices books the profit and moves the AUM with it:
82 >>> state.prices = pd.Series({"A": 110.0, "B": 50.0})
83 >>> float(state.profit)
84 50.0
85 >>> float(state.nav)
86 1050.0
88 """
90 _prices: pd.Series | None = None
91 _position: pd.Series | None = None
92 _trades: pd.Series | None = None
93 _time: datetime | None = None
94 _days: int = 0
95 _profit: float = 0.0
96 _aum: float = 0.0
98 @property
99 def cash(self) -> float:
100 """Get the current amount of cash available in the portfolio.
102 Returns:
103 -------
104 float
105 The cash component of the portfolio, calculated as NAV minus
106 the value of all positions
108 Examples:
109 --------
110 >>> import pandas as pd
111 >>> from cvx.simulator import State
112 >>> state = State()
113 >>> state.aum = 1000.0
114 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0})
116 With nothing invested the whole AUM is cash:
118 >>> float(state.cash)
119 1000.0
121 Buying 700 of stock moves that amount out of cash:
123 >>> state.position = pd.Series({"A": 5.0, "B": 4.0})
124 >>> float(state.cash)
125 300.0
127 """
128 return self.nav - self.value
130 @cash.setter
131 def cash(self, cash: float) -> None:
132 """Update the amount of cash available in the portfolio.
134 This updates the AUM (assets under management) based on the new
135 cash amount while keeping the value of positions constant.
137 Parameters
138 ----------
139 cash : float
140 The new cash amount to set
142 """
143 self.aum = cash + self.value
145 @property
146 def nav(self) -> float:
147 """Get the net asset value (NAV) of the portfolio.
149 The NAV represents the total value of the portfolio, including
150 both the value of positions and available cash.
152 Returns:
153 -------
154 float
155 The net asset value of the portfolio
157 Notes:
158 -----
159 This is equivalent to the AUM (assets under management).
161 """
162 return self.aum
164 @property
165 def value(self) -> float:
166 """Get the value of all positions in the portfolio.
168 This computes the total value of all holdings at current prices,
169 not including cash.
171 Returns:
172 -------
173 float
174 The sum of values of all positions
176 Notes:
177 -----
178 If positions are missing (None), the sum will effectively be zero.
180 """
181 return float(self.cashposition.sum())
183 @property
184 def cashposition(self) -> pd.Series:
185 """Get the cash value of each position in the portfolio.
187 This computes the cash value of each position by multiplying
188 the number of units by the current price for each asset.
190 Returns:
191 -------
192 pd.Series
193 Series with the cash value of each position, indexed by asset
195 """
196 return self.prices * self.position
198 @property
199 def position(self) -> pd.Series:
200 """Get the current position (number of units) for each asset.
202 Returns:
203 -------
204 pd.Series
205 Series with the number of units held for each asset, indexed by asset.
206 If the position is not yet set, returns an empty series with the
207 correct index.
209 Examples:
210 --------
211 Before anything is bought the position carries the assets, but no units:
213 >>> import pandas as pd
214 >>> from cvx.simulator import State
215 >>> state = State()
216 >>> state.aum = 1000.0
217 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0})
218 >>> state.position
219 A NaN
220 B NaN
221 dtype: float64
223 Assigning a position also records the trades needed to reach it:
225 >>> state.position = pd.Series({"A": 5.0, "B": 4.0})
226 >>> state.position
227 A 5.0
228 B 4.0
229 dtype: float64
230 >>> state.trades
231 A 5.0
232 B 4.0
233 dtype: float64
235 """
236 if self._position is None:
237 return pd.Series(index=self.assets, dtype=float)
239 return self._position
241 @position.setter
242 def position(self, position: np.ndarray | pd.Series) -> None:
243 """Update the position of the portfolio.
245 This method updates the position (number of units) for each asset,
246 computes the required trades to reach the new position, and updates
247 the internal state.
249 Parameters
250 ----------
251 position : Union[np.ndarray, pd.Series]
252 The new position to set, either as a numpy array or pandas Series.
253 If a numpy array, it must have the same length as self.assets.
255 """
256 # update the position
257 position = pd.Series(index=self.assets, data=position)
259 # compute the trades (can be fractional)
260 self._trades = position.subtract(self.position, fill_value=0.0)
262 # update only now as otherwise the trades would be wrong
263 self._position = position
265 @property
266 def gmv(self) -> float:
267 """Get the gross market value of the portfolio.
269 The gross market value is the sum of the absolute values of all positions,
270 which represents the total market exposure including both long and short positions.
272 Returns:
273 -------
274 float
275 The gross market value (abs(short) + long)
277 """
278 return float(self.cashposition.abs().sum())
280 @property
281 def time(self) -> datetime | None:
282 """Get the current time of the portfolio state.
284 Returns:
285 -------
286 Optional[datetime]
287 The current time in the simulation, or None if not set
289 """
290 return self._time
292 @time.setter
293 def time(self, time: datetime) -> None:
294 """Update the time of the portfolio state.
296 This method updates the current time and computes the number of days
297 between the new time and the previous time.
299 Parameters
300 ----------
301 time : datetime
302 The new time to set
304 """
305 if self.time is None:
306 self._days = 0
307 self._time = time
308 else:
309 self._days = (time - self.time).days
310 self._time = time
312 @property
313 def days(self) -> int:
314 """Get the number of days between the current and previous time.
316 Returns:
317 -------
318 int
319 Number of days between the current and previous time
321 Notes:
322 -----
323 This is useful for computing interest when holding cash or for
324 time-dependent calculations.
326 """
327 return self._days
329 @property
330 def assets(self) -> pd.Index:
331 """Get the assets currently in the portfolio.
333 Returns:
334 -------
335 pd.Index
336 Index of assets with valid prices in the portfolio.
337 If no prices are set, returns an empty index.
339 """
340 if self._prices is None:
341 return pd.Index(data=[], dtype=str)
343 return self.prices.dropna().index
345 @property
346 def trades(self) -> pd.Series | None:
347 """Get the trades needed to reach the current position.
349 Returns:
350 -------
351 Optional[pd.Series]
352 Series of trades (changes in position) needed to reach the current position.
353 None if no trades have been calculated yet.
355 Notes:
356 -----
357 This is helpful when computing trading costs following a position change.
358 Positive values represent buys, negative values represent sells.
360 """
361 return self._trades
363 @property
364 def mask(self) -> np.ndarray:
365 """Get a boolean mask for assets with valid (non-NaN) prices.
367 Returns:
368 -------
369 np.ndarray
370 Boolean array where True indicates a valid price and False indicates
371 a missing (NaN) price. Returns an empty array if no prices are set.
373 """
374 if self._prices is None:
375 return np.empty(0, dtype=bool)
377 return np.asarray(np.isfinite(self.prices.values))
379 @property
380 def prices(self) -> pd.Series:
381 """Get the current prices of assets in the portfolio.
383 Returns:
384 -------
385 pd.Series
386 Series of current prices indexed by asset.
387 Returns an empty series if no prices are set.
389 """
390 if self._prices is None:
391 return pd.Series(dtype=float)
392 return self._prices
394 @prices.setter
395 def prices(self, prices: pd.Series | dict[str, float]) -> None:
396 """Update the prices of assets in the portfolio.
398 This method updates the prices and calculates the profit achieved
399 due to price changes. It also updates the portfolio's AUM by adding
400 the profit.
402 Parameters
403 ----------
404 prices : pd.Series
405 New prices for assets in the portfolio
407 Notes:
408 -----
409 The profit is calculated as the difference between the portfolio value
410 before and after the price update.
412 """
413 # Convert dict to Series if necessary
414 prices = pd.Series(prices)
416 value_before = (self.prices * self.position).sum() # self.cashposition.sum()
417 value_after = (prices * self.position).sum()
419 self._prices = prices
420 self._profit = value_after - value_before
421 self.aum += self.profit
423 @property
424 def profit(self) -> float:
425 """Get the profit achieved between the previous and current prices.
427 Returns:
428 -------
429 float
430 The profit (or loss) achieved due to price changes since the
431 last price update
433 """
434 return self._profit
436 @property
437 def aum(self) -> float:
438 """Get the current assets under management (AUM) of the portfolio.
440 Returns:
441 -------
442 float
443 The total assets under management
445 """
446 return self._aum
448 @aum.setter
449 def aum(self, aum: float) -> None:
450 """Update the assets under management (AUM) of the portfolio.
452 Parameters
453 ----------
454 aum : float
455 The new assets under management value to set
457 """
458 self._aum = aum
460 @property
461 def weights(self) -> pd.Series:
462 """Get the weight of each asset in the portfolio.
464 This computes the weighting of each asset as a fraction of the
465 total portfolio value (NAV).
467 Returns:
468 -------
469 pd.Series
470 Series containing the weight of each asset as a fraction of the
471 total portfolio value, indexed by asset
473 Raises:
474 ------
475 ValueError
476 If a position is held while the NAV is zero or not finite, so no
477 weight is defined
479 Notes:
480 -----
481 If positions are missing, a series of zeros is effectively returned.
482 The sum of weights equals 1.0 for a fully invested portfolio with no leverage.
484 Examples:
485 --------
486 >>> import pandas as pd
487 >>> from cvx.simulator import State
488 >>> state = State()
489 >>> state.aum = 1000.0
490 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0})
491 >>> state.position = pd.Series({"A": 5.0, "B": 4.0})
493 The weights are fractions of NAV, so they sum to less than 1.0 here —
494 the remaining 0.3 is the uninvested cash:
496 >>> state.weights
497 A 0.5
498 B 0.2
499 dtype: float64
500 >>> float(state.weights.sum())
501 0.7
503 """
504 holding = bool((self.cashposition.abs() > 0).any())
505 if holding and (self.nav == 0 or not np.isfinite(self.nav)):
506 msg = f"weights are undefined for a NAV of {self.nav}; set `aum` to a finite, non-zero value first."
507 raise ValueError(msg)
509 return self.cashposition / self.nav
511 @property
512 def leverage(self) -> float:
513 """Get the leverage of the portfolio.
515 Leverage is calculated as the sum of the absolute values of all position
516 weights. For a long-only portfolio with no cash, this equals 1.0.
517 For a portfolio with shorts or leverage, this will be greater than 1.0.
519 Returns:
520 -------
521 float
522 The leverage ratio of the portfolio
524 Notes:
525 -----
526 A leverage of 2.0 means the portfolio has twice the market exposure
527 compared to its net asset value, which could be achieved through
528 borrowing or short selling.
530 Examples:
531 --------
532 A long 1000 / short 500 book on a NAV of 1000. The net exposure is only
533 500, but the leverage counts both legs:
535 >>> import pandas as pd
536 >>> from cvx.simulator import State
537 >>> state = State()
538 >>> state.aum = 1000.0
539 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0})
540 >>> state.position = pd.Series({"A": 10.0, "B": -10.0})
541 >>> state.weights
542 A 1.0
543 B -0.5
544 dtype: float64
545 >>> state.leverage
546 1.5
547 >>> state.gmv
548 1500.0
550 """
551 return float(self.weights.abs().sum())