diff --git a/cvx/simulator/__init__.py b/cvx/io/__init__.py similarity index 100% rename from cvx/simulator/__init__.py rename to cvx/io/__init__.py diff --git a/cvx/io/json.py b/cvx/io/json.py new file mode 100644 index 0000000..fbdbbbf --- /dev/null +++ b/cvx/io/json.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +import json +from collections.abc import Iterable + +import numpy as np +from numpyencoder import NumpyEncoder + + +def read_json(json_file): + """Read a json file and return a generator of key-value pairs""" + with open(json_file, "r") as f: + json_data = json.load(f) + for name, data in json_data.items(): + if isinstance(data, Iterable): + yield name, np.asarray(data) + else: + yield name, data + + +def write_json(json_file, data): + """Write a json file with the data""" + with open(json_file, "w") as f: + json.dump(data, f, cls=NumpyEncoder) \ No newline at end of file diff --git a/cvx/simulator/builder.py b/cvx/simulator/builder.py deleted file mode 100644 index 71a11c0..0000000 --- a/cvx/simulator/builder.py +++ /dev/null @@ -1,398 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations - -from dataclasses import dataclass, field - -import pandas as pd - -from cvx.simulator.portfolio import EquityPortfolio -from cvx.simulator.trading_costs import TradingCostModel - - -@dataclass -class _State: - """The _State class defines a state object used to keep track of the current - state of the portfolio. - - Attributes: - - prices: a pandas Series object containing the stock prices of the current - portfolio state - - position: a pandas Series object containing the current holdings of the portfolio - - cash: the amount of cash available in the portfolio. - - By default, prices and position are set to None, while cash is set to 1 million. - These attributes can be updated and accessed through setter and getter methods - """ - - prices: pd.Series = None - position: pd.Series = None - cash: float = 1e6 - - @property - def value(self): - """ - The value property computes the value of the portfolio at the current - time taking into account the current holdings and current stock prices. - If the value cannot be computed due to missing positions - (they might be still None), zero is returned instead. - """ - try: - return (self.prices * self.position).sum() - except TypeError: - return 0.0 - - @property - def nav(self): - """ - The nav property computes the net asset value (NAV) of the portfolio, - which is the sum of the current value of the - portfolio as determined by the value property, - and the current amount of cash available in the portfolio. - """ - return self.value + self.cash - - @property - def weights(self): - """ - The weights property computes the weighting of each asset in the current - portfolio as a fraction of the total portfolio value (nav). - - Returns: - - a pandas series object containing the weighting of each asset as a - fraction of the total portfolio value. If the positions are still - missing, then a series of zeroes is returned. - """ - try: - return (self.prices * self.position) / self.nav - except TypeError: - return 0 * self.prices - - @property - def leverage(self): - """ - The `leverage` property computes the leverage of the portfolio, - which is the sum of the absolute values of the portfolio weights. - """ - return self.weights.abs().sum() - - @property - def position_robust(self): - """ - The position_robust property returns the current position of the - portfolio or a series of zeroes if the position is still missing. - """ - if self.position is None: - self.position = 0.0 * self.prices - - return self.position - - def update(self, position, model=None, **kwargs): - """ - The update method updates the current state of the portfolio with the - new input position. It calculates the trades made based on the new - and the previous position, updates the internal position and - cash attributes, and applies any trading costs according to a model parameter. - - The method takes three input parameters: - - position: a pandas series object representing the new position of the - portfolio. - - model: an optional trading cost model (e.g. slippage, fees) to be - incorporated into the update. If None, no trading costs will be applied. - - **kwargs: additional keyword arguments to pass into the trading cost - model. - - Returns: - self: the _State instance with the updated position and cash. - - Updates: - - trades: the difference between positions in the old and new portfolio. - position: the new position of the portfolio. - cash: the new amount of cash in the portfolio after any trades and trading costs are applied. - - Note that the method does not return any value: instead, - it updates the internal state of the _State instance. - """ - trades = position - self.position_robust - - self.position = position - self.cash -= (trades * self.prices).sum() - - if model is not None: - self.cash -= model.eval(self.prices, trades=trades, **kwargs).sum() - - # builder is frozen, so we can't construct a new state - return self - - -def builder( - prices, - weights=None, - market_cap=None, - trade_volume=None, - initial_cash=1e6, - trading_cost_model=None, - max_cap_fraction=None, - min_cap_fraction=None, - max_trade_fraction=None, - min_trade_fraction=None, -): - """The builder function creates an instance of the _Builder class, which - is used to construct a portfolio of assets. The function takes in a pandas - DataFrame of historical prices for the assets in the portfolio, optional - weights for each asset, an initial cash value, and a trading cost model. - The function first asserts that the prices DataFrame has a monotonic - increasing and unique index. It then creates a DataFrame of zeros to hold - the number of shares of each asset owned at each time step. The function - initializes a _Builder object with the stocks DataFrame, the prices - DataFrame (forward-filled), the initial cash value, and the trading cost - model. If weights are provided, they are set for each time step using - set_weights method of the _Builder object. The final output is the - constructed _Builder object.""" - - assert isinstance(prices, pd.DataFrame) - assert prices.index.is_monotonic_increasing - assert prices.index.is_unique - - stocks = pd.DataFrame( - index=prices.index, columns=prices.columns, data=0.0, dtype=float - ) - - builder = _Builder( - stocks=stocks, - prices=prices.ffill(), - initial_cash=float(initial_cash), - trading_cost_model=trading_cost_model, - market_cap=market_cap, - trade_volume=trade_volume, - max_cap_fraction=max_cap_fraction, - min_cap_fraction=min_cap_fraction, - max_trade_fraction=max_trade_fraction, - min_trade_fraction=min_trade_fraction, - ) - - if weights is not None: - for t, state in builder: - builder.set_weights(time=t[-1], weights=weights.loc[t[-1]]) - - return builder - - -@dataclass(frozen=True) -class _Builder: - prices: pd.DataFrame - stocks: pd.DataFrame - trading_cost_model: TradingCostModel - initial_cash: float = 1e6 - _state: _State = field(default_factory=_State) - market_cap: pd.DataFrame = None - trade_volume: pd.DataFrame = None - max_cap_fraction: float = None - min_cap_fraction: float = None - max_trade_fraction: float = None - min_trade_fraction: float = None - - def __post_init__(self): - """ - The __post_init__ method is a special method of initialized instances - of the _Builder class and is called after initialization. - It sets the initial amount of cash in the portfolio to be equal to the input initial_cash parameter. - - The method takes no input parameter. It initializes the cash attribute in the internal - _State object with the initial amount of cash in the portfolio, self.initial_cash. - - Note that this method is often used in Python classes for additional initialization routines - that can only be performed after the object is fully initialized. __post_init__ - is called automatically after the object initialization. - """ - self._state.cash = self.initial_cash - - @property - def index(self): - """A property that returns the index of the portfolio, - which is the time period for which the portfolio data is available. - - Returns: pd.Index: A pandas index representing the - time period for which the portfolio data is available. - - Notes: The function extracts the index of the prices dataframe, - which represents the time periods for which data is available for the portfolio. - The resulting index will be a pandas index object - with the same length as the number of rows in the prices dataframe.""" - - return self.prices.index - - @property - def assets(self): - """A property that returns a list of the assets held by the portfolio. - - Returns: list: A list of the assets held by the portfolio. - - Notes: The function extracts the column names of the prices dataframe, - which correspond to the assets held by the portfolio. - The resulting list will contain the names of all assets - held by the portfolio, without any duplicates.""" - return self.prices.columns - - @property - def returns(self): - return self.prices.pct_change().dropna(axis=0, how="all") - - def cov(self, **kwargs): - # You can do much better using volatility adjusted returns rather than returns - cov = self.returns.ewm(**kwargs).cov() - cov = cov.dropna(how="all", axis=0) - for t in cov.index.get_level_values(level=0).unique(): - yield t, cov.loc[t, :, :] - - # {t: cov.loc[t, :, :] for t in cov.index.get_level_values('date').unique()} - - def set_weights(self, time, weights: pd.Series): - """ - Set the position via weights (e.g. fractions of the nav) - - :param time: time - :param weights: series of weights - """ - assert isinstance(weights, pd.Series), "weights must be a pandas Series" - self[time] = (self._state.nav * weights) / self._state.prices - - def set_cashposition(self, time, cashposition: pd.Series): - """ - Set the position via cash positions (e.g. USD invested per asset) - - :param time: time - :param cashposition: series of cash positions - """ - assert isinstance( - cashposition, pd.Series - ), "cashposition must be a pandas Series" - self[time] = cashposition / self._state.prices - - def set_position(self, time, position): - """ - Set the position via number of assets (e.g. number of stocks) - - :param time: time - :param position: series of number of stocks - """ - assert isinstance(position, pd.Series), "position must be a pandas Series" - self[time] = position - - def __iter__(self): - """ - The __iter__ method allows the object to be iterated over in a for loop, - yielding time and the current state of the portfolio. - The method yields a list of dates seen so far - (excluding the first date) and returns a tuple - containing the list of dates and the current portfolio state. - - Yield: - - interval: a pandas DatetimeIndex object containing the dates seen so far. - - state: the current state of the portfolio, - taking into account the stock prices at each interval. - """ - for t in self.index: - # valuation of the current position - self._state.prices = self.prices.loc[t] - yield self.index[self.index <= t], self._state - - def __setitem__(self, time, position): - """ - The method __setitem__ updates the stock data in the dataframe for a specific time index - with the input position. It first checks that position is a valid input, - meaning it is a pandas Series object and has its index within the assets of the dataframe. - The method takes two input parameters: - - time: time index for which to update the stock data - position: pandas series object containing the updated stock data - - Returns: None - - Updates: - the stock data of the dataframe at the given time index with the input position - the internal state of the portfolio with the updated position, taking into account the trading cost model - - Raises: - AssertionError: if the input position is not a pandas Series object - or its index is not a subset of the assets of the dataframe. - """ - assert isinstance(position, pd.Series) - assert set(position.index).issubset(set(self.assets)) - - if self.market_cap is not None: - # compute capitalization of desired position - cap = position * self._state.prices - # compute relative capitalization - rel_cap = cap / self.market_cap.loc[time] - # clip relative capitalization - rel_cap.clip( - lower=self.min_cap_fraction, upper=self.max_cap_fraction, inplace=True - ) - # move back to capitalization - cap = rel_cap * self.market_cap.loc[time] - # compute position - position = cap / self._state.prices - - if self.trade_volume is not None: - trade = position - self._state.position_robust - - # move to trade in USD - trade = trade * self._state.prices - # compute relative trade volume - rel_trade = trade / self.trade_volume.loc[time] - # clip relative trade volume - rel_trade.clip( - lower=self.min_trade_fraction, - upper=self.max_trade_fraction, - inplace=True, - ) - # move back to trade - trade = rel_trade * self.trade_volume.loc[time] - # move back to trade in number of stocks - trade = trade / self._state.prices - # compute position - position = self._state.position_robust + trade - - self.stocks.loc[time, position.index] = position - self._state.update(position, model=self.trading_cost_model) - - def __getitem__(self, time): - """The __getitem__ method retrieves the stock data for a specific time in the dataframe. - It returns the stock data for that time. The method takes one input parameter: - - time: the time index for which to retrieve the stock data - Returns: stock data for the input time - - Note that the input time must be in the index of the dataframe, otherwise a KeyError will be raised. - """ - return self.stocks.loc[time] - - def build(self): - """A function that creates a new instance of the EquityPortfolio - class based on the internal state of the Portfolio builder object. - - Returns: EquityPortfolio: A new instance of the EquityPortfolio class - with the attributes (prices, stocks, initial_cash, trading_cost_model) as specified in the Portfolio builder. - - Notes: The function simply creates a new instance of the EquityPortfolio - class with the attributes (prices, stocks, initial_cash, trading_cost_model) equal - to the corresponding attributes in the Portfolio builder object. - The resulting EquityPortfolio object will have the same state as the Portfolio builder from which it was built. - """ - - return EquityPortfolio( - prices=self.prices, - stocks=self.stocks, - initial_cash=self.initial_cash, - trading_cost_model=self.trading_cost_model, - ) diff --git a/cvx/simulator/grid.py b/cvx/simulator/grid.py deleted file mode 100644 index 3d219be..0000000 --- a/cvx/simulator/grid.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations - -import numpy as np -import pandas as pd - - -def iron_frame(frame, rule): - """ - The iron_frame function takes a pandas DataFrame - and keeps it constant on a coarser grid. - - :param frame: The frame to be ironed - :param rule: The rule to be used for the construction of the grid - :return: the ironed frame - """ - s_index = resample_index(frame.index, rule) - return _project_frame_to_grid(frame, s_index) - - -def resample_index(index, rule): - """ - The resample_index function resamples a pandas DatetimeIndex object - to a lower frequency using a specified rule. - - - Note that the function does not modify the input index object, - but rather returns a pandas DatetimeIndex - """ - series = pd.Series(index=index, data=index) - a = series.resample(rule=rule).first() - return pd.DatetimeIndex(a.values) - - -def _project_frame_to_grid(frame, grid): - """ - The project_frame_to_grid function projects a pandas DataFrame - to a coarser grid while still sharing the same index. - It does that by taking over values of the frame from the coarser - grid that are then forward filled. - An application would be monthly rebalancing of a portfolio. - E.g. on days in a particular grid we adjust the position and keep - it constant for the rest of the month. - - :param frame: the frame (existing on a finer grid) - :param grid: the coarse grid - :return: a frame changing only values on days in the grid - """ - sample = np.NaN * frame - sample.loc[grid] = frame.loc[grid] - return sample.ffill() diff --git a/cvx/simulator/month.py b/cvx/simulator/month.py deleted file mode 100644 index 2684896..0000000 --- a/cvx/simulator/month.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Popular year vs month performance table. -""" -from __future__ import annotations - -import calendar - -import numpy as np -import pandas as pd - - -def _compound(rets): - """ - Helper function for compounded return calculation. - """ - return (1.0 + rets).prod() - 1.0 - - -def monthlytable(returns: pd.Series): - """ - Get a table of monthly returns. - - Args: - returns: Series of individual returns. - - Returns: - DataFrame with monthly returns, their STDev and YTD. - """ - - # Works better in the first month - # Compute all the intramonth-returns, instead of reapplying some monthly resampling of the NAV - returns = returns.dropna() - - return_monthly = ( - returns.groupby([returns.index.year, returns.index.month]) - .apply(_compound) - .unstack(level=1) - ) - - # make sure all months are in the table! - frame = pd.DataFrame(index=return_monthly.index, columns=range(1, 13), data=np.NaN) - frame[return_monthly.columns] = return_monthly - - frame = frame.rename( - columns={month: calendar.month_abbr[month] for month in frame.columns} - ) - - ytd = frame.apply(_compound, axis=1) - frame["STDev"] = np.sqrt(12) * frame.std(axis=1) - # make sure that you don't include the column for the STDev in your computation - frame["YTD"] = ytd - frame.index.name = "Year" - frame.columns.name = None - # most recent years on top - return frame.iloc[::-1] diff --git a/cvx/simulator/portfolio.py b/cvx/simulator/portfolio.py deleted file mode 100644 index aa97650..0000000 --- a/cvx/simulator/portfolio.py +++ /dev/null @@ -1,606 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum - -import pandas as pd -import quantstats as qs - -from cvx.simulator.grid import iron_frame -from cvx.simulator.trading_costs import TradingCostModel - -qs.extend_pandas() - - -class Plot(Enum): - DAILY_RETURNS = 1 - DISTRIBUTION = 2 - DRAWDOWN = 3 - DRAWDOWNS_PERIODS = 4 - EARNINGS = 5 - HISTOGRAM = 6 - LOG_RETURNS = 7 - MONTHLY_HEATMAP = 8 - # see issue: https://github.com/ranaroussi/quantstats/issues/276 - MONTHLY_RETURNS = 9 - RETURNS = 10 - ROLLING_BETA = 11 - ROLLING_SHARPE = 12 - ROLLING_SORTINO = 13 - ROLLING_VOLATILITY = 14 - YEARLY_RETURNS = 15 - - # - # def __call__(self, returns, **kwargs): - # return self._func(returns=returns, **kwargs) - - def plot(self, returns, **kwargs): - func = getattr(qs.plots, self.name.lower()) - return func(returns=returns, **kwargs) - - -def diff(portfolio1, portfolio2, initial_cash=1e6, trading_cost_model=None): - # check both portfolios are on the same price grid - pd.testing.assert_frame_equal(portfolio1.prices, portfolio2.prices) - - stocks = portfolio1.stocks - portfolio2.stocks - - return EquityPortfolio( - prices=portfolio1.prices, - stocks=stocks, - initial_cash=initial_cash, - trading_cost_model=trading_cost_model, - ) - - -@dataclass(frozen=True) -class EquityPortfolio: - """A class that represents an equity portfolio - and contains dataframes for prices and stock holdings, - as well as optional parameters for trading cost models - and initial cash values. - - Attributes: - prices (pd.DataFrame): A pandas dataframe representing - the prices of various assets held by the portfolio over time. - stocks (pd.DataFrame): A pandas dataframe representing the number of shares - held for each asset in the portfolio over time. - trading_cost_model (TradingCostModel): An optional trading cost model - to use when trading assets in the portfolio. - initial_cash (float): An optional scalar float representing the initial - cash value available for the portfolio. - - Notes: The EquityPortfolio class is designed to represent - a portfolio of assets where only equity positions are held. - The prices and stocks dataframes are assumed to have the same - index object representing the available time periods for which data is available. - If no trading cost model is provided, the trading_cost_model attribute - will be set to None by default. - If no initial cash value is provided, the initial_cash attribute - will be set to a default value of 1,000,000.""" - - prices: pd.DataFrame - stocks: pd.DataFrame - trading_cost_model: TradingCostModel = None - initial_cash: float = 1e6 - - def __post_init__(self): - """A class method that performs input validation after object initialization. - Notes: The post_init method is called after an instance of the EquityPortfolio class has been initialized, - and performs a series of input validation checks to ensure that the prices - and stocks dataframes are in the expected format - with no duplicates or missing data, - and that the stocks dataframe represents valid equity positions - for the assets held in the portfolio. - Specifically, the method checks that both the prices and stocks dataframes - have a monotonic increasing and unique index, - and that the index and columns of the stocks dataframe are subsets - of the index and columns of the prices dataframe, respectively. - If any of these checks fail, an assertion error will be raised.""" - - assert self.prices.index.is_monotonic_increasing - assert self.prices.index.is_unique - assert self.stocks.index.is_monotonic_increasing - assert self.stocks.index.is_unique - - assert set(self.stocks.index).issubset(set(self.prices.index)) - assert set(self.stocks.columns).issubset(set(self.prices.columns)) - - @property - def index(self): - """A property that returns the index of the EquityPortfolio instance, - which is the time period for which the portfolio data is available. - - Returns: pd.Index: A pandas index representing the time period for which the - portfolio data is available. - - Notes: The function extracts the index of the prices dataframe, - which represents the time periods for which data is available for the portfolio. - The resulting index will be a pandas index object with the same length - as the number of rows in the prices dataframe.""" - return self.prices.index - - @property - def assets(self): - """A property that returns a list of the assets held by the EquityPortfolio object. - - Returns: list: A list of the assets held by the EquityPortfolio object. - - Notes: The function extracts the column names of the prices dataframe, - which correspond to the assets held by the EquityPortfolio object. - The resulting list will contain the names of all assets held by the portfolio, without any duplicates. - """ - return self.prices.columns - - @property - def weights(self): - """A property that returns a pandas dataframe representing - the weights of various assets in the portfolio. - - Returns: pd.DataFrame: A pandas dataframe representing the weights - of various assets in the portfolio. - - Notes: The function calculates the weights of various assets - in the portfolio by dividing the equity positions - for each asset (as represented in the equity dataframe) - by the total portfolio value (as represented in the nav dataframe). - Both dataframes are assumed to have the same dimensions. - The resulting dataframe will show the relative weight - of each asset in the portfolio at each point in time.""" - return self.equity.apply(lambda x: x / self.nav) - - def __getitem__(self, time): - """The `__getitem__` method retrieves the stock data for a specific time in the dataframe. - It returns the stock data for that time. - - The method takes one input parameter: - - `time`: the time index for which to retrieve the stock data - - Returns: - - stock data for the input time - - Note that the input time must be in the index of the dataframe, - otherwise a KeyError will be raised.""" - return self.stocks.loc[time] - - @property - def trading_costs(self): - """A property that returns a pandas dataframe - representing the trading costs incurred by the portfolio due to trades made. - - Returns: pd.DataFrame: A pandas dataframe representing the trading - costs incurred by the portfolio due to trades made. - - Notes: The function calculates the trading costs using the specified - trading cost model (if available) and the prices and trading - data represented by the prices and trades_stocks - dataframes, respectively. If no trading cost model is provided, - a dataframe with all zeros will be returned. - The resulting dataframe will have the same dimensions as - the prices and trades_stocks dataframes, - showing the trading costs incurred at each point in time for each asset traded. - """ - if self.trading_cost_model is None: - return 0.0 * self.prices - - return self.trading_cost_model.eval(self.prices, self.trades_stocks) - - @property - def equity(self) -> pd.DataFrame: - """A property that returns a pandas dataframe - representing the equity positions of the portfolio, - which is the value of each asset held by the portfolio. - Returns: pd.DataFrame: A pandas dataframe representing - the equity positions of the portfolio. - - Notes: The function calculates the equity of the portfolio - by multiplying the current prices of each asset - by the number of shares held by the portfolio. - The resulting values are filled forward to account - for any missing data or NaN values. - The equity dataframe will have the same dimensions - as the prices and stocks dataframes.""" - - return (self.prices * self.stocks).ffill() - - @property - def trades_stocks(self) -> pd.DataFrame: - """A property that returns a pandas dataframe representing the trades made in the portfolio in terms of stocks. - - Returns: pd.DataFrame: A pandas dataframe representing the trades made in the portfolio in terms of stocks. - - Notes: The function calculates the trades made by the portfolio by taking - the difference between the current and previous values of the stocks dataframe. - The resulting values will represent the number of shares of each asset - bought or sold by the portfolio at each point in time. - The resulting dataframe will have the same dimensions - as the stocks dataframe, with NaN values filled with zeros.""" - t = self.stocks.diff() - t.loc[self.index[0]] = self.stocks.loc[self.index[0]] - return t.fillna(0.0) - - @property - def trades_currency(self) -> pd.DataFrame: - """A property that returns a pandas dataframe representing - the trades made in the portfolio in terms of currency. - - Returns: pd.DataFrame: A pandas dataframe representing the trades made in the portfolio in terms of currency. - - Notes: The function calculates the trades made in currency by multiplying - the number of shares of each asset bought or sold (as represented in the trades_stocks dataframe) - with the current prices of each asset (as represented in the prices dataframe). - Uses pandas ffill() method to forward fill NaN values in the prices dataframe. - The resulting dataframe will have the same dimensions as the stocks and prices dataframes. - """ - return self.trades_stocks * self.prices.ffill() - - @property - def turnover(self) -> pd.DataFrame: - return self.trades_currency.abs() - - @property - def cash(self) -> pd.Series: - """A property that returns a pandas series representing the cash on hand in the portfolio. - - Returns: pd.Series: A pandas series representing the cash on hand in the portfolio. - - Notes: The function calculates the cash available in the portfolio by subtracting - the sum of trades currency and cumulative trading costs from the initial cash value specified - when constructing the object. Uses pandas cumsum() method - to calculate the cumulative sum of trading costs and - trades currency along the time axis. - The resulting series will show how much money is available for further trades at each point in time. - """ - return ( - self.initial_cash - - self.trades_currency.sum(axis=1).cumsum() - - self.trading_costs.sum(axis=1).cumsum() - ) - - @property - def nav(self) -> pd.Series: - """Returns a pandas series representing the total value - of the portfolio's investments and cash. - - Returns: pd.Series: A pandas series representing the - total value of the portfolio's investments and cash. - """ - return self.equity.sum(axis=1) + self.cash - - @property - def profit(self) -> pd.Series: - """A property that returns a pandas series representing the - profit gained or lost in the portfolio based on changes in asset prices. - - Returns: pd.Series: A pandas series representing the profit - gained or lost in the portfolio based on changes in asset prices. - - Notes: The calculation is based on the difference between - the previous and current prices of the assets in the portfolio, - multiplied by the number of stocks in each asset previously held. - """ - - price_changes = self.prices.ffill().diff() - previous_stocks = self.stocks.shift(1).fillna(0.0) - return (previous_stocks * price_changes).dropna(axis=0, how="all").sum(axis=1) - - @property - def highwater(self) -> pd.Series: - """A function that returns a pandas series representing - the high-water mark of the portfolio, which is the highest point - the portfolio value has reached over time. - - Returns: pd.Series: A pandas series representing the - high-water mark of the portfolio. - - Notes: The function performs a rolling computation based on - the cumulative maximum of the portfolio's value over time, - starting from the beginning of the time period being considered. - Min_periods argument is set to 1 to include the minimum period of one day. - The resulting series will show the highest value the portfolio has reached at each point in time. - """ - return self.nav.expanding(min_periods=1).max() - - @property - def drawdown(self) -> pd.Series: - """A property that returns a pandas series representing the - drawdown of the portfolio, which measures the decline - in the portfolio's value from its (previously) highest - point to its current point. - - Returns: pd.Series: A pandas series representing the - drawdown of the portfolio. - - Notes: The function calculates the ratio of the portfolio's current value - vs. its current high-water-mark and then subtracting the result from 1. - A positive drawdown means the portfolio is currently worth - less than its high-water mark. A drawdown of 0.1 implies that the nav is currently 0.9 times the high-water mark - """ - return 1.0 - self.nav / self.highwater - - def __mul__(self, scalar): - """A method that allows multiplication of the EquityPortfolio object with a scalar constant. - - Args: scalar: A scalar constant that multiplies the number of shares - of each asset held in the EquityPortfolio object. - - Returns: EquityPortfolio: A new EquityPortfolio object multiplied by the scalar constant. - - Notes: The mul method allows multiplication of an EquityPortfolio object - with a scalar constant to increase or decrease - the number of shares held for each asset in the portfolio accordingly. - The method returns a new EquityPortfolio object with the same prices - and trading cost model as the original object, - and with the number of shares for each asset multiplied by the scalar constant - (as represented in the stocks dataframe). - Additionally, the initial cash value is multiplied - by the scalar to maintain the same cash-to-equity ratio as the original portfolio. - """ - assert scalar > 0 - return EquityPortfolio( - prices=self.prices, - stocks=self.stocks * scalar, - initial_cash=self.initial_cash * scalar, - trading_cost_model=self.trading_cost_model, - ) - - def __rmul__(self, scalar): - """A method that allows multiplication of the EquityPortfolio object with a scalar constant in a reversed order. - - Args: scalar: A scalar constant that multiplies the EquityPortfolio object in a reversed order. - - Returns: EquityPortfolio: A new EquityPortfolio object multiplied by the scalar constant. - - Notes: The rmul method allows multiplication of a scalar - constant with an EquityPortfolio object in a reversed order""" - return self.__mul__(scalar) - - def __add__(self, port_new): - """ - A method that allows addition of two EquityPortfolio objects. - """ - # check if the other object is an EquityPortfolio object - assert isinstance(port_new, EquityPortfolio) - - # make sure the prices are aligned for overlapping points - prices_left = self.prices.combine_first(port_new.prices) - prices_right = port_new.prices.combine_first(self.prices) - pd.testing.assert_frame_equal(prices_left, prices_right) - - # bring both portfolios to the finer grid - left = self.reset_prices(prices=prices_left) - right = port_new.reset_prices(prices=prices_left) - - # just make sure the left and right portfolio are now on exactly the same grid - pd.testing.assert_index_equal(left.index, right.index) - - # add the stocks - positions = left.stocks.add(right.stocks, fill_value=0.0) - - # make sure the trading cost models are the same - return EquityPortfolio( - prices=prices_right, - stocks=positions, - initial_cash=self.initial_cash + port_new.initial_cash, - trading_cost_model=self.trading_cost_model, - ) - - def reset_prices(self, prices): - """ - A method that constructs an EquityPortfolio object using finer prices. - """ - # extract the relevant columns from prices - p = prices[self.assets] - - # the prices need to contain the original index - assert set(self.index).issubset(set(prices.index)) - - # build a frame for the stocks - stocks = pd.DataFrame(index=prices.index, columns=self.assets) - - # only forward fill stocks on the subgrid induced by the original index - sub = stocks.truncate(before=self.index[0], after=self.index[-1]) - sub.update(self.stocks) - sub = sub.ffill() - - # outside the original index, the stocks are zero - stocks.update(sub) - stocks = stocks.fillna(0.0) - - return EquityPortfolio( - prices=p, - stocks=stocks, - initial_cash=self.initial_cash, - trading_cost_model=self.trading_cost_model, - ) - - def truncate(self, before=None, after=None): - """ - The truncate method truncates the prices DataFrame, stocks DataFrame - and the cash series of an EquityPortfolio object. - The method also optionally accepts a before and/or after argument - to specify a date range for truncation. - - The method returns a new EquityPortfolio object which is a truncated version - of the original object, with the same trading cost model - and initial cash value. The stocks DataFrame is truncated - using the same before and after arguments and the prices DataFrame - is truncated similarly. The cash value is truncated - to match the new date range and the first value of the - truncated cash series is used as the initial cash value for the new object. - - Note that this method does not modify the original EquityPortfolio object, - but rather returns a new object. - :param before: - :param after: - :return: - """ - return EquityPortfolio( - prices=self.prices.truncate(before=before, after=after), - stocks=self.stocks.truncate(before=before, after=after), - trading_cost_model=self.trading_cost_model, - initial_cash=self.nav.truncate(before=before, after=after).values[0], - ) - - # @property - # def start(self): - # """first index with a profit that is not zero""" - # return self.profit.ne(0).idxmax() - - def resample(self, rule): - """The resample method resamples an EquityPortfolio object to a new frequency - specified by the rule argument. - A new EquityPortfolio object is created with the original prices - DataFrame and the resampled stocks DataFrame. The objects trading cost model and initial cash value - are also copied into the new object. - - Note that the resample method does not modify the original EquityPortfolio object, - but rather returns a new object. - """ - # iron out the stocks index - stocks = iron_frame(self.stocks, rule=rule) - - return EquityPortfolio( - prices=self.prices, - stocks=stocks, - trading_cost_model=self.trading_cost_model, - initial_cash=self.initial_cash, - ) - - def metrics( - self, - benchmark=None, - rf=0.0, - display=True, - mode="basic", - sep=False, - compound=True, - periods_per_year=252, - prepare_returns=True, - match_dates=True, - **kwargs, - ): - """ - The metrics method calculates the performance metrics of an EquityPortfolio object. - - :param kwargs: - :return: - """ - return qs.reports.metrics( - returns=self.nav.pct_change().dropna(), - benchmark=benchmark, - rf=rf, - display=display, - mode=mode, - sep=sep, - compounded=compound, - periods_per_year=periods_per_year, - prepare_returns=prepare_returns, - match_dates=match_dates, - **kwargs, - ) - - def plots( - self, - benchmark=None, - grayscale=False, - figsize=(8, 5), - mode="basic", - compounded=True, - periods_per_year=252, - prepare_returns=True, - match_dates=True, - **kwargs, - ): - return qs.reports.plots( - returns=self.nav.pct_change().dropna(), - benchmark=benchmark, - grayscale=grayscale, - figsize=figsize, - mode=mode, - compounded=compounded, - periods_per_year=periods_per_year, - prepare_returns=prepare_returns, - match_dates=match_dates, - **kwargs, - ) - - def plot(self, kind: Plot, **kwargs): - return kind.plot(returns=self.nav.pct_change().dropna(), **kwargs) - - def html( - self, - benchmark=None, - rf=0.0, - grayscale=False, - title="Strategy Tearsheet", - output=None, - compounded=True, - periods_per_year=252, - download_filename="quantstats-tearsheet.html", - figfmt="svg", - template_path=None, - match_dates=True, - **kwargs, - ): - return qs.reports.html( - returns=self.nav.pct_change().dropna(), - benchmark=benchmark, - rf=rf, - grayscale=grayscale, - title=title, - output=output, - compounded=compounded, - periods_per_year=periods_per_year, - download_filename=download_filename, - figfmt=figfmt, - template_path=template_path, - match_dates=match_dates, - **kwargs, - ) - - def snapshot( - self, - grayscale=False, - figsize=(10, 8), - title="Portfolio Summary", - fontname="Arial", - lw=1.5, - mode="comp", - subtitle=True, - savefig=None, - show=True, - log_scale=False, - **kwargs, - ): - """ - The snapshot method creates a snapshot of the performance of an EquityPortfolio object. - - :param grayscale: - :param figsize: - :param title: - :param fontname: - :param lw: - :param mode: - :param subtitle: - :param savefig: - :param show: - :param log_scale: - :param kwargs: - :return: - """ - return qs.plots.snapshot( - returns=self.nav.pct_change().dropna(), - grayscale=grayscale, - figsize=figsize, - title=title, - fontname=fontname, - lw=lw, - mode=mode, - subtitle=subtitle, - savefig=savefig, - show=show, - log_scale=log_scale, - **kwargs, - ) diff --git a/cvx/simulator/trading_costs.py b/cvx/simulator/trading_costs.py deleted file mode 100644 index cdefc20..0000000 --- a/cvx/simulator/trading_costs.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations - -import abc -from dataclasses import dataclass - - -@dataclass(frozen=True) -class TradingCostModel(abc.ABC): - @abc.abstractmethod - def eval(self, prices, trades, **kwargs): - """Evaluates the cost of a trade given the prices and the trades - - Arguments - prices: the price per asset - trades: the trade per asset, e.g. number of stocks traded - **kwargs: additional arguments, e.g. volatility, liquidity, spread, etc. - """ - - -@dataclass(frozen=True) -class LinearCostModel(TradingCostModel): - factor: float = 0.0 - bias: float = 0.0 - - def eval(self, prices, trades, **kwargs): - volume = prices * trades - return self.factor * volume.abs() + self.bias * trades.abs() diff --git a/tests/conftest.py b/tests/conftest.py index ce27bda..ab65d87 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,38 +4,9 @@ from pathlib import Path -import pandas as pd import pytest -from cvx.simulator.portfolio import EquityPortfolio - - @pytest.fixture(scope="session", name="resource_dir") def resource_fixture(): """resource fixture""" return Path(__file__).parent / "resources" - - -@pytest.fixture() -def prices(resource_dir): - """prices fixture""" - return pd.read_csv( - resource_dir / "price.csv", index_col=0, parse_dates=True, header=0 - ) - - -@pytest.fixture() -def portfolio(prices): - """portfolio fixture""" - positions = pd.DataFrame(index=prices.index, columns=prices.columns, data=1.0) - return EquityPortfolio(prices, stocks=positions, initial_cash=1e6) - - -@pytest.fixture() -def returns(resource_dir): - """returns fixture""" - return ( - pd.read_csv(resource_dir / "ts.csv", index_col=0, header=None, parse_dates=True) - .squeeze() - .pct_change() - ) diff --git a/tests/test_json.py b/tests/test_json.py index 35f1d79..d9be6c2 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1,4 +1,17 @@ +# -*- coding: utf-8 -*- import numpy as np -def test_json(): - a = np.random.rand(10,10) +from cvx.io.json import read_json, write_json + + +def test_read_and_write_json(tmp_path): + data = {"a": np.array([2.0, 3.0]), "b": 3.0, "c": "test"} + write_json(tmp_path / "test.json", data) + + recovered_data = dict(read_json(tmp_path / "test.json")) + + assert data["b"] == recovered_data["b"] + assert data["c"] == recovered_data["c"] + + # check numpy arrays are equal + np.testing.assert_array_equal(data["a"], recovered_data["a"])