Coverage for src/cvx/simulator/state.py: 100%
98 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 02:49 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 02:49 +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 # assert np.isclose(self.value + self.cash, self.aum), f"{self.value + self.cash} != {self.aum}"
163 # return self.value + self.cash
164 return self.aum
166 @property
167 def value(self) -> float:
168 """Get the value of all positions in the portfolio.
170 This computes the total value of all holdings at current prices,
171 not including cash.
173 Returns:
174 -------
175 float
176 The sum of values of all positions
178 Notes:
179 -----
180 If positions are missing (None), the sum will effectively be zero.
182 """
183 return float(self.cashposition.sum())
185 @property
186 def cashposition(self) -> pd.Series:
187 """Get the cash value of each position in the portfolio.
189 This computes the cash value of each position by multiplying
190 the number of units by the current price for each asset.
192 Returns:
193 -------
194 pd.Series
195 Series with the cash value of each position, indexed by asset
197 """
198 return self.prices * self.position
200 @property
201 def position(self) -> pd.Series:
202 """Get the current position (number of units) for each asset.
204 Returns:
205 -------
206 pd.Series
207 Series with the number of units held for each asset, indexed by asset.
208 If the position is not yet set, returns an empty series with the
209 correct index.
211 Examples:
212 --------
213 Before anything is bought the position carries the assets, but no units:
215 >>> import pandas as pd
216 >>> from cvx.simulator import State
217 >>> state = State()
218 >>> state.aum = 1000.0
219 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0})
220 >>> state.position
221 A NaN
222 B NaN
223 dtype: float64
225 Assigning a position also records the trades needed to reach it:
227 >>> state.position = pd.Series({"A": 5.0, "B": 4.0})
228 >>> state.position
229 A 5.0
230 B 4.0
231 dtype: float64
232 >>> state.trades
233 A 5.0
234 B 4.0
235 dtype: float64
237 """
238 if self._position is None:
239 return pd.Series(index=self.assets, dtype=float)
241 return self._position
243 @position.setter
244 def position(self, position: np.ndarray | pd.Series) -> None:
245 """Update the position of the portfolio.
247 This method updates the position (number of units) for each asset,
248 computes the required trades to reach the new position, and updates
249 the internal state.
251 Parameters
252 ----------
253 position : Union[np.ndarray, pd.Series]
254 The new position to set, either as a numpy array or pandas Series.
255 If a numpy array, it must have the same length as self.assets.
257 """
258 # update the position
259 position = pd.Series(index=self.assets, data=position)
261 # compute the trades (can be fractional)
262 self._trades = position.subtract(self.position, fill_value=0.0)
264 # update only now as otherwise the trades would be wrong
265 self._position = position
267 @property
268 def gmv(self) -> float:
269 """Get the gross market value of the portfolio.
271 The gross market value is the sum of the absolute values of all positions,
272 which represents the total market exposure including both long and short positions.
274 Returns:
275 -------
276 float
277 The gross market value (abs(short) + long)
279 """
280 return float(self.cashposition.abs().sum())
282 @property
283 def time(self) -> datetime | None:
284 """Get the current time of the portfolio state.
286 Returns:
287 -------
288 Optional[datetime]
289 The current time in the simulation, or None if not set
291 """
292 return self._time
294 @time.setter
295 def time(self, time: datetime) -> None:
296 """Update the time of the portfolio state.
298 This method updates the current time and computes the number of days
299 between the new time and the previous time.
301 Parameters
302 ----------
303 time : datetime
304 The new time to set
306 """
307 if self.time is None:
308 self._days = 0
309 self._time = time
310 else:
311 self._days = (time - self.time).days
312 self._time = time
314 @property
315 def days(self) -> int:
316 """Get the number of days between the current and previous time.
318 Returns:
319 -------
320 int
321 Number of days between the current and previous time
323 Notes:
324 -----
325 This is useful for computing interest when holding cash or for
326 time-dependent calculations.
328 """
329 return self._days
331 @property
332 def assets(self) -> pd.Index:
333 """Get the assets currently in the portfolio.
335 Returns:
336 -------
337 pd.Index
338 Index of assets with valid prices in the portfolio.
339 If no prices are set, returns an empty index.
341 """
342 if self._prices is None:
343 return pd.Index(data=[], dtype=str)
345 return self.prices.dropna().index
347 @property
348 def trades(self) -> pd.Series | None:
349 """Get the trades needed to reach the current position.
351 Returns:
352 -------
353 Optional[pd.Series]
354 Series of trades (changes in position) needed to reach the current position.
355 None if no trades have been calculated yet.
357 Notes:
358 -----
359 This is helpful when computing trading costs following a position change.
360 Positive values represent buys, negative values represent sells.
362 """
363 return self._trades
365 @property
366 def mask(self) -> np.ndarray:
367 """Get a boolean mask for assets with valid (non-NaN) prices.
369 Returns:
370 -------
371 np.ndarray
372 Boolean array where True indicates a valid price and False indicates
373 a missing (NaN) price. Returns an empty array if no prices are set.
375 """
376 if self._prices is None:
377 return np.empty(0, dtype=bool)
379 return np.asarray(np.isfinite(self.prices.values))
381 @property
382 def prices(self) -> pd.Series:
383 """Get the current prices of assets in the portfolio.
385 Returns:
386 -------
387 pd.Series
388 Series of current prices indexed by asset.
389 Returns an empty series if no prices are set.
391 """
392 if self._prices is None:
393 return pd.Series(dtype=float)
394 return self._prices
396 @prices.setter
397 def prices(self, prices: pd.Series | dict[str, float]) -> None:
398 """Update the prices of assets in the portfolio.
400 This method updates the prices and calculates the profit achieved
401 due to price changes. It also updates the portfolio's AUM by adding
402 the profit.
404 Parameters
405 ----------
406 prices : pd.Series
407 New prices for assets in the portfolio
409 Notes:
410 -----
411 The profit is calculated as the difference between the portfolio value
412 before and after the price update.
414 """
415 # Convert dict to Series if necessary
416 prices = pd.Series(prices)
418 value_before = (self.prices * self.position).sum() # self.cashposition.sum()
419 value_after = (prices * self.position).sum()
421 self._prices = prices
422 self._profit = value_after - value_before
423 self.aum += self.profit
425 @property
426 def profit(self) -> float:
427 """Get the profit achieved between the previous and current prices.
429 Returns:
430 -------
431 float
432 The profit (or loss) achieved due to price changes since the
433 last price update
435 """
436 return self._profit
438 @property
439 def aum(self) -> float:
440 """Get the current assets under management (AUM) of the portfolio.
442 Returns:
443 -------
444 float
445 The total assets under management
447 """
448 return self._aum
450 @aum.setter
451 def aum(self, aum: float) -> None:
452 """Update the assets under management (AUM) of the portfolio.
454 Parameters
455 ----------
456 aum : float
457 The new assets under management value to set
459 """
460 self._aum = aum
462 @property
463 def weights(self) -> pd.Series:
464 """Get the weight of each asset in the portfolio.
466 This computes the weighting of each asset as a fraction of the
467 total portfolio value (NAV).
469 Returns:
470 -------
471 pd.Series
472 Series containing the weight of each asset as a fraction of the
473 total portfolio value, indexed by asset
475 Notes:
476 -----
477 If positions are missing, a series of zeros is effectively returned.
478 The sum of weights equals 1.0 for a fully invested portfolio with no leverage.
480 Examples:
481 --------
482 >>> import pandas as pd
483 >>> from cvx.simulator import State
484 >>> state = State()
485 >>> state.aum = 1000.0
486 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0})
487 >>> state.position = pd.Series({"A": 5.0, "B": 4.0})
489 The weights are fractions of NAV, so they sum to less than 1.0 here —
490 the remaining 0.3 is the uninvested cash:
492 >>> state.weights
493 A 0.5
494 B 0.2
495 dtype: float64
496 >>> float(state.weights.sum())
497 0.7
499 """
500 if not np.isclose(self.nav, self.aum):
501 msg = f"{self.nav} != {self.aum}"
502 raise ValueError(msg)
504 return self.cashposition / self.nav
506 @property
507 def leverage(self) -> float:
508 """Get the leverage of the portfolio.
510 Leverage is calculated as the sum of the absolute values of all position
511 weights. For a long-only portfolio with no cash, this equals 1.0.
512 For a portfolio with shorts or leverage, this will be greater than 1.0.
514 Returns:
515 -------
516 float
517 The leverage ratio of the portfolio
519 Notes:
520 -----
521 A leverage of 2.0 means the portfolio has twice the market exposure
522 compared to its net asset value, which could be achieved through
523 borrowing or short selling.
525 Examples:
526 --------
527 A long 1000 / short 500 book on a NAV of 1000. The net exposure is only
528 500, but the leverage counts both legs:
530 >>> import pandas as pd
531 >>> from cvx.simulator import State
532 >>> state = State()
533 >>> state.aum = 1000.0
534 >>> state.prices = pd.Series({"A": 100.0, "B": 50.0})
535 >>> state.position = pd.Series({"A": 10.0, "B": -10.0})
536 >>> state.weights
537 A 1.0
538 B -0.5
539 dtype: float64
540 >>> state.leverage
541 1.5
542 >>> state.gmv
543 1500.0
545 """
546 return float(self.weights.abs().sum())