diff --git a/module_test/raw_code/DataManagers/DataManagers.py b/module_test/raw_code/DataManagers/DataManagers.py index 0a66f41..300ca00 100644 --- a/module_test/raw_code/DataManagers/DataManagers.py +++ b/module_test/raw_code/DataManagers/DataManagers.py @@ -347,10 +347,14 @@ def query_thetadata(self, data = pd.DataFrame(columns=THETA_DATA_COLUMNS) return data data = data[~data.index.duplicated(keep='first')] - open_interest = retrieve_openInterest(symbol=self.symbol, end_date=end, exp=exp, right=right, start_date=start, strike=strike, print_url=print_url).set_index('Datetime') - open_interest.drop_duplicates(inplace = True) - data['Open_interest'] = open_interest['Open_interest'] - data.index = default_timestamp(data.index) + try: + open_interest = retrieve_openInterest(symbol=self.symbol, end_date=end, exp=exp, right=right, start_date=start, strike=strike, print_url=print_url).set_index('Datetime') + open_interest.drop_duplicates(inplace = True) + data['Open_interest'] = open_interest['Open_interest'] + data.index = default_timestamp(data.index) + except Exception as e: + logger.error(f"Error retrieving open interest data for {self.symbol} from {start} to {end}: {e}. Filling Open_interest with NaN.") + data['Open_interest'] = np.nan return data else: diff --git a/trade/backtester_/_helper.py b/trade/backtester_/_helper.py index b26933d..c1a2525 100644 --- a/trade/backtester_/_helper.py +++ b/trade/backtester_/_helper.py @@ -1,3 +1,4 @@ + from typing import Any, Callable, Dict, Optional, TYPE_CHECKING import pandas as pd from .data import PTDataset @@ -86,7 +87,16 @@ def _next(self): if verbose: print(f"Opening position on {date} at price {self.data.Close[-1]}") print(f"Info: {self.brain.info_on_date(date=date)}") - self.buy() + if open_decision.side == 1: + if verbose: + print("Going LONG") + self.buy() + elif open_decision.side == -1: + if verbose: + print("Going SHORT") + self.sell() + else: + raise ValueError(f"Invalid side in open_decision: {open_decision.side}") self.brain.open_action( date=date, signal_id=open_decision.signal_id, diff --git a/trade/backtester_/_strategy.py b/trade/backtester_/_strategy.py index a5c53c2..a3b4bdf 100644 --- a/trade/backtester_/_strategy.py +++ b/trade/backtester_/_strategy.py @@ -14,6 +14,8 @@ from ._types import Side, SideInt # noqa from trade.backtester_.indicators import ( compute_atr_loss, + update_atr_trail_long, + update_atr_trail_short, ) @@ -270,7 +272,7 @@ def __init__( self.position_info: Optional[PositionInfo] = PositionInfo() self.stop: Optional[float] = None - self.indicators: Dict[str, Any] = {} + self.indicators: Dict[str, Indicator] = {} # Cache index + numpy views for speed and consistent date handling self._df = self.data.data.copy() # expects a DataFrame-like @@ -1179,7 +1181,7 @@ def plot_strategy_indicators(self, log_scale: bool = True, add_signal_marker: bo ) ## Indicators for ind_name, indicator in self.indicators.items(): - ind_values = indicator.values.loc[df.index] + ind_values = indicator.series[df.index] if indicator.overlay: fig.add_trace( go.Scatter( @@ -1281,6 +1283,8 @@ def __init__( average_type: str = "w", start_trading_date: Optional[str] = None, ticker: Optional[str] = None, + is_long: bool = True, + is_short: bool = False, **kwargs, ): super().__init__(data=data, **kwargs) @@ -1288,6 +1292,8 @@ def __init__( self.atr_factor = atr_factor self.trail_type = trail_type self.average_type = average_type + self.is_long = is_long + self.is_short = is_short self.loss_series: Optional[pd.Series] = None def setup(self) -> None: @@ -1298,4 +1304,22 @@ def setup(self) -> None: atr_factor=self.atr_factor, trail_type=self.trail_type, average_type=self.average_type, - ) \ No newline at end of file + ) + + def open_action(self, *, signal_id = None, entry_price = None, side = None, date = None, index = None): + idx, _ = self._resolve(date=date, index=index) + + if self.is_long: + self.stop = update_atr_trail_long( + close=float(self.close[idx]), + loss=float(self.loss_series[idx]), + prev_trail=self.stop, + reset=False, + ) + elif self.is_short: + self.stop = update_atr_trail_short( + close=float(self.close[idx]), + loss=float(self.loss_series[idx]), + prev_trail=self.stop, + reset=False, + ) \ No newline at end of file diff --git a/trade/backtester_/backtester_.py b/trade/backtester_/backtester_.py index f84a695..09796dd 100644 --- a/trade/backtester_/backtester_.py +++ b/trade/backtester_/backtester_.py @@ -11,7 +11,7 @@ from .data import PTDataset # noqa: F401 from ._helper import make_bt_wrapper # noqa: F401 from ._strategy import StrategyBase -logger = setup_logger('trade.backtester_.backtester_', stream_log_level="DEBUG") +logger = setup_logger('trade.backtester_.backtester_', stream_log_level="WARNING") ## TODO: Include Benchmark DD in Portfolio Plot ## FIX: After optimization, reset strategy settings to default. Currently, it is not resetting @@ -89,6 +89,11 @@ def __init__(self, This is useful for when you have a strategy that starts at a certain date, which is greater than the earliest date in the dataset. Typically to allow buffer to calculate indicators. **kwargs: Additional keyword arguments to be passed to the Backtest class in backtesting.py + which include: `trade_on_close` (bool): Whether to trade on close or not. Currently, PTBacktester only supports trade_on_close=True. trade_on_close=False is not supported. + `finalize_trades` (bool): Whether to finalize trades or not. This is useful for when you want to keep trades open after the backtest is done. Defaults to True. + `verbose` (bool): Whether to print verbose logs during strategy setup. Defaults to False. + `plot_indicators` (bool): Whether to plot indicators in the backtest plot. Defaults to True. + `commission` (float): Commission to be passed to the Backtest class in backtesting.py. Defaults to 0.0. Returns: None @@ -98,17 +103,20 @@ def __init__(self, - _runIndex: Index of the ticker in the dataset list """ + if kwargs.pop("reset", True): + for dataset in datalist: + dataset.reset() trade_on_close = kwargs.pop("trade_on_close", True) finalize_trades = kwargs.pop("finalize_trades", True) if not trade_on_close: raise ValueError("PTBacktester currently only supports trade_on_close=True. trade_on_close=False is not supported.") else: logger.info(f"PTBacktester initialized with trade_on_close={trade_on_close}, finalize_trades={finalize_trades}") - self.datasets = [] + self.datasets: List[PTDataset] = [] self.__strategy = deepcopy((_setup_strategy(strategy, start_date=start_overwrite, - verbose=kwargs.get('verbose', False), - plot_indicators=kwargs.get('plot_indicators', True)))) + verbose=kwargs.pop('verbose', False), + plot_indicators=kwargs.pop('plot_indicators', True)))) self.__port_stats = None self._trades = None self._equity = None @@ -161,7 +169,7 @@ def update_settings(self, datalist) -> None: raise ValueError(f'For datasets settings, please assign a dictionary containing parameters as key and values, got {type(param_setting)}') dataset_obj = [x for x in datalist if x.name == name][0] dataset_obj.param_settings = param_setting - except: + except: # noqa ## Use default settings as settings in not given names dataset_obj.param_settings = default_setting no_settings_names.append(name) @@ -190,7 +198,7 @@ def run(self) -> pd.DataFrame: """ results = [] for i, d in enumerate(self.datasets): - d.backtest._strategy._name = d.name + d.backtest._strategy._name = d.name d.backtest._strategy._runIndex = i if d.param_settings: # if d.param_settings: @@ -205,11 +213,11 @@ def run(self) -> pd.DataFrame: self.reset_settings() if d.param_settings else None try: del d.backtest._strategy._name - except: + except: # noqa pass try: del d.backtest._strategy._runIndex - except: + except: # noqa pass results.append(stats) self.__port_stats = {d.name: results[i] for i, d in enumerate(self.datasets)} @@ -228,6 +236,30 @@ def pf_value_ts(self) -> pd.DataFrame: """ Returns Timeseries of periodic portfolio value """ + + ## Initialize empty dataframe to hold equity curves for each ticker. + eq = pd.DataFrame() + + ## If cash is a single value, we create a dictionary with the same cash for each ticker to fill in missing values in the equity curve + if isinstance(self.cash, (float, int)): + cash_per_asset = {tick: self.cash for tick in self.get_port_stats().keys()} + + ## Loop through each ticker's stats, extract the equity curve, and fill in missing values with the initial cash + for tick, stats in self.get_port_stats().items(): + eq[tick] = stats["_equity_curve"]["Equity"] + + ## If cash is a dict, we use the specific cash for that ticker to fill in missing values. If not, we use the single cash value for all tickers. + if tick not in cash_per_asset: + raise ValueError(f"Cash value for ticker {tick} not found in cash_per_asset. Please provide a cash value for this ticker.") + eq[tick].fillna(cash_per_asset[tick], inplace=True) + eq["Total"] = eq.sum(axis=1) + eq.index = pd.to_datetime(eq.index) + if self.start_overwrite: + eq = eq[eq.index.date >= pd.to_datetime(self.start_overwrite).date()] + + return eq + + PortStats = self.__port_stats if self.start_overwrite: start = pd.to_datetime(self.start_overwrite).date() @@ -399,12 +431,21 @@ def position_optimize(self, default_params = {} for param in param_kwargs.keys(): default_params[param] = getattr(self.strategy, param) + ## Loop through each datasets backtest, optimize & append to optimized dataframe for dataset in self.datasets: name = dataset.name + + # If the dataset has specific settings for the strategy, we set the strategy settings. + if dataset.param_settings: + print(f"Optimizing for dataset: {name} with settings: {dataset.param_settings}") + for setting, value in dataset.param_settings.items(): + setattr(self.strategy, setting, value) ## Make sure strategy name is set for each optimize run dataset.backtest._strategy._name = name + + if return_heatmap: opt, hm = dataset.backtest.optimize(**param_kwargs, **kwargs) diff --git a/trade/backtester_/data.py b/trade/backtester_/data.py index 14a8437..9343779 100644 --- a/trade/backtester_/data.py +++ b/trade/backtester_/data.py @@ -1,5 +1,6 @@ import pandas as pd - +from backtesting import Backtest +from typing import Optional class PTDataset: """ Custom dataset holding ticker name, ticker timeseries & backtest object from backtesting.py @@ -14,13 +15,20 @@ def __init__(self, name: str, data: pd.DataFrame, param_settings: dict = None): self.__param_settings = param_settings ## Making param_settings private self.name = name self.data = data - self.backtest = None + self.backtest: Optional[Backtest] = None + self.data.columns = self.data.columns.str.capitalize() # Ensure columns are capitalized + def __repr__(self): return f"PTDataset({self.name})" def __str__(self): return f"PTDataset({self.name})" + + def reset(self): + """Reset the dataset's backtest to None""" + self.backtest = None + self.param_settings = None @property def param_settings(self): @@ -30,4 +38,6 @@ def param_settings(self): @param_settings.setter def param_settings(self, value: dict): """Setter for param_settings with type checking""" + if not isinstance(value, (dict, type(None))): + raise TypeError("param_settings must be a dictionary or None") self.__param_settings = value diff --git a/trade/backtester_/utils/aggregators.py b/trade/backtester_/utils/aggregators.py index a91067d..a700ba8 100644 --- a/trade/backtester_/utils/aggregators.py +++ b/trade/backtester_/utils/aggregators.py @@ -6,7 +6,7 @@ import sys import os -from trade.helpers.helper import copy_doc_from,filter_inf,filter_zeros +from trade.helpers.helper import copy_doc_from, filter_inf, filter_zeros from trade.assets.Stock import Stock from abc import ABC, abstractmethod import plotly.io as pio @@ -24,6 +24,7 @@ from backtesting import Backtest import pandas as pd + def pf_value_ts(port_stats: dict, cash: Union[dict, int, float]) -> pd.DataFrame: """ Parameters: @@ -35,12 +36,12 @@ def pf_value_ts(port_stats: dict, cash: Union[dict, int, float]) -> pd.DataFrame """ PortStats = port_stats - date_range = pd.date_range(start=dates_(True), end=dates_(False), freq='B') + date_range = pd.date_range(start=dates_(True), end=dates_(False), freq="B") start = dates_(True) end = dates_(False) port_equity_data = pd.DataFrame(index=date_range) for tick, data in PortStats.items(): - equity_curve = data['_equity_curve']['Equity'] + equity_curve = data["_equity_curve"]["Equity"].copy(deep=True) if isinstance(cash, dict): cash = cash[tick] elif isinstance(cash, int) or isinstance(cash, float): @@ -49,23 +50,23 @@ def pf_value_ts(port_stats: dict, cash: Union[dict, int, float]) -> pd.DataFrame equity_curve.name = tick tick_start = min(equity_curve.index) if tick_start > start: - temp = pd.DataFrame(index=pd.date_range( - start=start, end=equity_curve.index.min(), freq='B')) + temp = pd.DataFrame( + index=pd.date_range(start=start, end=equity_curve.index.min(), freq="B") + ) temp[tick] = cash equity_curve = pd.concat([equity_curve, temp], axis=0) port_equity_data = port_equity_data.join(equity_curve) - port_equity_data = port_equity_data.dropna(how='all') - port_equity_data = port_equity_data.fillna(method='ffill') - port_equity_data['Total'] = port_equity_data.sum(axis=1) + port_equity_data = port_equity_data.dropna(how="all") + port_equity_data = port_equity_data.fillna(method="ffill") + port_equity_data["Total"] = port_equity_data.sum(axis=1) port_equity_data.index = pd.DatetimeIndex(port_equity_data.index) return port_equity_data def short_returns(t0, t1): - return 1 - (t1/t0) - + return 1 - (t1 / t0) def dates_(port_stats: dict, start: bool = True) -> pd.Timestamp: @@ -85,14 +86,16 @@ def dates_(port_stats: dict, start: bool = True) -> pd.Timestamp: end_list = [] duration_list = [] for tick, data in port_stats.items(): - start_list.append(data['Start']) - end_list.append(data['End']) - duration_list.append(data['Duration']) + start_list.append(data["Start"]) + end_list.append(data["End"]) + duration_list.append(data["Duration"]) return min(start_list) if start else max(end_list) -def peak_value_func(equity_timeseries: pd.DataFrame, value: bool = True) -> Union[float, Dict]: +def peak_value_func( + equity_timeseries: pd.DataFrame, value: bool = True +) -> Union[float, Dict]: """ Returns the peak value of the portfolio and has the option to return corresponding date @@ -105,8 +108,8 @@ def peak_value_func(equity_timeseries: pd.DataFrame, value: bool = True) -> Unio """ ts = equity_timeseries - peak_value = ts['Total'].max() - peak_date = ts[ts['Total'] == peak_value].index[0] + peak_value = ts["Total"].max() + peak_date = ts[ts["Total"] == peak_value].index[0] peak_dict = {peak_date: round(peak_value, 2)} return peak_value if value else peak_dict @@ -122,11 +125,11 @@ def final_value_func(equity_timeseries: pd.DataFrame) -> float: """ ts = equity_timeseries - final_val = round(ts['Total'][-1], 2) + final_val = round(ts["Total"][-1], 2) return final_val -def rtrn(equity_timeseries: pd.DataFrame, use_col = 'Total', long = True) -> float: +def rtrn(equity_timeseries: pd.DataFrame, use_col="Total", long=True) -> float: """ Parameters: @@ -136,8 +139,12 @@ def rtrn(equity_timeseries: pd.DataFrame, use_col = 'Total', long = True) -> flo float: Returns returns of portfolio from initial date to final date """ ts = equity_timeseries - rtrn = (ts[use_col][-1]/ts[use_col][0])-1 if long else 1 - (ts[use_col][-1]/ts[use_col][0]) - return rtrn*100 + rtrn = ( + (ts[use_col][-1] / ts[use_col][0]) - 1 + if long + else 1 - (ts[use_col][-1] / ts[use_col][0]) + ) + return rtrn * 100 def buyNhold(port_stats: dict) -> float: @@ -145,9 +152,9 @@ def buyNhold(port_stats: dict) -> float: initial_val = np.ones(len(PortStats)).sum() return_vals = np.zeros(len(PortStats)) for i, (k, v) in enumerate(PortStats.items()): - rtrn = v['Buy & Hold Return [%]']/100 - return_vals[i] = (1+rtrn) - bNh_rtrn = round(((return_vals.sum()/initial_val)-1)*100, 2) + rtrn = v["Buy & Hold Return [%]"] / 100 + return_vals[i] = 1 + rtrn + bNh_rtrn = round(((return_vals.sum() / initial_val) - 1) * 100, 2) return bNh_rtrn @@ -161,20 +168,24 @@ def cagr(equity_timeseries: pd.DataFrame) -> float: float: Returns average annualize retruns for the portfolio. Cumulative Annual Growth Rate """ ts = equity_timeseries - begin_val = ts['Total'].iloc[0] - end_val = ts['Total'].iloc[-1] + begin_val = ts["Total"].iloc[0] + end_val = ts["Total"].iloc[-1] if isinstance(ts.index, pd.DatetimeIndex): days = (ts.index.max() - ts.index.min()).days elif isinstance(ts.index, pd.RangeIndex): - days = (ts.index.max() - ts.index.min()) - return ((end_val/begin_val)**(365/days) - 1)*100 + days = ts.index.max() - ts.index.min() + return ((end_val / begin_val) ** (252 / days) - 1) * 100 -def vol_annualized(equity_timeseries: pd.DataFrame, downside: Optional[bool] = False, MAR: Optional[Union[int, float]] = 0) -> float: +def vol_annualized( + equity_timeseries: pd.DataFrame, + downside: Optional[bool] = False, + MAR: Optional[Union[int, float]] = 0, +) -> float: """ Returns the annualized volatility of the portfolio, which is calculated from the Portfolio Timeseries Value - Parameters: + Parameters: equity_timeseries (pd.DataFrame): Timeseries of the periodic equity values downside (Optional[bool]): False for regular volatility, True to calculate downside volatility MAR (Optional[Union[int, float]]): Minimum Acceptable Return @@ -186,17 +197,22 @@ def vol_annualized(equity_timeseries: pd.DataFrame, downside: Optional[bool] = F annual_trading_days = 365 ts_date_width = (ts.index.to_series().diff()).dt.days.mean() if not downside: - return round(np.std(filter_zeros(ts['Total']).pct_change(), ddof=1) * np.sqrt(annual_trading_days/ts_date_width) * 100, 6) + return round( + np.std(filter_zeros(ts["Total"]).pct_change(), ddof=1) + * np.sqrt(annual_trading_days / ts_date_width) + * 100, + 6, + ) else: if not MAR: MAR = 0 - ts = ts['Total'].pct_change() - MAR + ts = ts["Total"].pct_change() - MAR ts_d = ts[ts < 0] return round(np.std(ts_d, ddof=1) * np.sqrt(252) * 100, 6) -def daily_rtrns(equity_timeseries: pd.DataFrame, long = True) -> pd.Series: +def daily_rtrns(equity_timeseries: pd.DataFrame, long=True) -> pd.Series: """ Parameters: equity_timeseries (pd.DataFrame): This is the timeseries of the periodic equity values @@ -204,14 +220,16 @@ def daily_rtrns(equity_timeseries: pd.DataFrame, long = True) -> pd.Series: Returns: pd.Series: Utility method. Returns timeseries of daily portfolio returns """ - ts = filter_zeros(equity_timeseries['Total']).pct_change() + ts = filter_zeros(equity_timeseries["Total"]).pct_change() return ts if long else -ts -def sharpe(equity_timeseries: pd.DataFrame, risk_free_rate: float = 0.055, long = True) -> float: +def sharpe( + equity_timeseries: pd.DataFrame, risk_free_rate: float = 0.055, long=True +) -> float: """ Returns the Sharpe ratio of the portfolio - Parameters: + Parameters: risk_free_rate (float): A single value representing the risk free rate. This should be an annualized value equity_timeseries (pd.DataFrame): This is the timeseries of the periodic equity values @@ -222,19 +240,23 @@ def sharpe(equity_timeseries: pd.DataFrame, risk_free_rate: float = 0.055, long # ANNUALIZED MEAN EXCESS RETURN / ANNUALIZED VOLATILITY annual_trading_days = 365 ts_date_width = (equity_timeseries.index.to_series().diff()).dt.days.mean() - annual_period = annual_trading_days/ts_date_width + annual_period = annual_trading_days / ts_date_width equity_timeseries = filter_zeros(equity_timeseries) - daily_rfrate = (1+risk_free_rate)**(1/252) - 1 - annualized_vol = vol_annualized(equity_timeseries)/100 - excess_retrns = np.mean(daily_rtrns(equity_timeseries, long) - daily_rfrate)*annual_period - return excess_retrns/annualized_vol + daily_rfrate = (1 + risk_free_rate) ** (1 / 252) - 1 + annualized_vol = vol_annualized(equity_timeseries) / 100 + excess_retrns = ( + np.mean(daily_rtrns(equity_timeseries, long) - daily_rfrate) * annual_period + ) + return excess_retrns / annualized_vol -def sortino(equity_timeseries: pd.DataFrame, risk_free_rate: float, MAR: Optional[float] = None) -> float: +def sortino( + equity_timeseries: pd.DataFrame, risk_free_rate: float, MAR: Optional[float] = None +) -> float: """ Returns the Sortino ratio of the portfolio - Parameters: + Parameters: risk_free_rate (float): A single value representing the risk free rate. This should be an annualized value MAR: Minimum Acceptable Return. A Value to compare with returns to ascertain true downside returns. Eg can be inflation rate, to show that real returns can be negative even though nominal is positive equity_timeseries (pd.DataFrame): Timeseries of the periodic equity values @@ -245,14 +267,16 @@ def sortino(equity_timeseries: pd.DataFrame, risk_free_rate: float, MAR: Optiona # ANNUALIZED MEAN EXCESS RETURN / ANNUALIZED VOLATILITY if not MAR: - MAR = (1+risk_free_rate)**(1/252) - 1 - daily_rfrate = (1+risk_free_rate)**(1/252) - 1 - annualized_vol = vol_annualized(equity_timeseries, True, MAR)/100 - excess_retrns = np.mean(daily_rtrns(equity_timeseries) - daily_rfrate)*252 - return excess_retrns/annualized_vol + MAR = (1 + risk_free_rate) ** (1 / 252) - 1 + daily_rfrate = (1 + risk_free_rate) ** (1 / 252) - 1 + annualized_vol = vol_annualized(equity_timeseries, True, MAR) / 100 + excess_retrns = np.mean(daily_rtrns(equity_timeseries) - daily_rfrate) * 252 + return excess_retrns / annualized_vol -def dd(equity_timeseries: pd.DataFrame, full: bool = False) -> Union[pd.DataFrame, pd.Series]: +def dd( + equity_timeseries: pd.DataFrame, full: bool = False +) -> Union[pd.DataFrame, pd.Series]: """ Returns portfolio DrawDrown timeseires @@ -262,13 +286,13 @@ def dd(equity_timeseries: pd.DataFrame, full: bool = False) -> Union[pd.DataFram """ ts = equity_timeseries data = pd.DataFrame() - data['Total'] = ts['Total'] - data['Running_max'] = data.Total.cummax() - data['dd'] = (data.Total/data.Running_max)-1 + data["Total"] = ts["Total"] + data["Running_max"] = data.Total.cummax() + data["dd"] = (data.Total / data.Running_max) - 1 if full: return data else: - return data['dd'] + return data["dd"] def mdd(equity_timeseries: pd.DataFrame) -> float: @@ -279,7 +303,7 @@ def mdd(equity_timeseries: pd.DataFrame) -> float: Returns Max Drawdown """ dd_ = dd(equity_timeseries) - return dd_.min()*100 + return dd_.min() * 100 def calmar(equity_timeseries: pd.DataFrame) -> float: @@ -290,7 +314,7 @@ def calmar(equity_timeseries: pd.DataFrame) -> float: Returns calmar Ratio """ - return abs(cagr(equity_timeseries)/mdd(equity_timeseries)) + return abs(cagr(equity_timeseries) / mdd(equity_timeseries)) def avg_dd_percent(equity_timeseries: pd.DataFrame) -> float: @@ -300,7 +324,7 @@ def avg_dd_percent(equity_timeseries: pd.DataFrame) -> float: Returns avg Drawdown % """ - return round(dd(equity_timeseries).mean()*100, 6) + return round(dd(equity_timeseries).mean() * 100, 6) def mdd_value(equity_timeseries: pd.DataFrame) -> float: @@ -311,7 +335,7 @@ def mdd_value(equity_timeseries: pd.DataFrame) -> float: Returns Maximum Drawdown value """ dd_ = dd(equity_timeseries, True) - return round((dd_['Total'] - dd_['Running_max']).min(), 2) + return round((dd_["Total"] - dd_["Running_max"]).min(), 2) def mdd_duration(equity_timeseries: pd.DataFrame, full: bool = False) -> pd.Timedelta: @@ -326,12 +350,13 @@ def mdd_duration(equity_timeseries: pd.DataFrame, full: bool = False) -> pd.Time maximum drawdown duration """ from datetime import timedelta + dd_ = dd(equity_timeseries, True) for i, (index, row) in enumerate(dd_.iterrows()): - total, running_max, date = row['Total'], row['Running_max'], index - running_max_date = dd_[dd_['Total'] == running_max].index[0] - dd_.at[index, 'timedelta'] = (date - running_max_date) + total, running_max, date = row["Total"], row["Running_max"], index + running_max_date = dd_[dd_["Total"] == running_max].index[0] + dd_.at[index, "timedelta"] = date - running_max_date if full: return dd_ @@ -358,10 +383,10 @@ def trades(port_stats) -> pd.DataFrame: """ trades_df = pd.DataFrame() for k, v in port_stats.items(): - holder = v['_trades'] - holder['Ticker'] = k + holder = v["_trades"].copy(deep=True) + holder["Ticker"] = k trades_df = pd.concat([trades_df, holder]) - return trades_df.sort_values(['EntryTime', 'ExitTime']).reset_index(drop = True) + return trades_df.sort_values(["EntryTime", "ExitTime"]).reset_index(drop=True) def numOfTrades(trades_df) -> int: @@ -378,7 +403,7 @@ def winRate(trades_df: pd.DataFrame) -> float: trades_df (pd.DataFrame): DataFrame Contatining Trades """ trades_ = trades_df - return round(((trades_.ReturnPct > 0).sum()/(trades_.ReturnPct).count())*100, 2) + return round(((trades_.ReturnPct > 0).sum() / (trades_.ReturnPct).count()) * 100, 2) def lossRate(trades_df: pd.DataFrame) -> float: @@ -395,27 +420,61 @@ def avgPnL(trades_df: pd.DataFrame, Type_: str, value=True) -> float: trades_df (pd.DataFrame): DataFrame Contatining Trades & PnL or ReturnPct column Type_ (str): 'W', 'L', 'A'. Win, Loss or All - value (bool): True to return + value (bool): True to return """ - assert Type_.upper() in [ - 'W', 'L', 'A'], f"Invalid Type_: '{Type_}'. Must be 'L', 'W' or 'A." - assert 'PnL' in trades_df.columns or 'ReturnPct' in trades_df.columns, f"Please pass a dataframe holding trades and ensure it has either 'PnL' or 'ReturnPct' in the columns. Current Columns {trades_df.columns}" + assert Type_.upper() in ["W", "L", "A"], ( + f"Invalid Type_: '{Type_}'. Must be 'L', 'W' or 'A." + ) + assert "PnL" in trades_df.columns or "ReturnPct" in trades_df.columns, ( + f"Please pass a dataframe holding trades and ensure it has either 'PnL' or 'ReturnPct' in the columns. Current Columns {trades_df.columns}" + ) trades_ = trades_df PnL = (trades_.PnL if value else trades_.ReturnPct).astype(float) - WPnL = PnL[PnL > 0] if Type_.upper() == 'W' else PnL[PnL <= - 0] if Type_.upper() == 'L' else PnL + WPnL = ( + PnL[PnL > 0] + if Type_.upper() == "W" + else PnL[PnL <= 0] + if Type_.upper() == "L" + else PnL + ) return WPnL.mean() * 100 if not WPnL.empty else 0 def bestTrade(trades_df: pd.DataFrame) -> float: - return trades_df.ReturnPct.max()*100 + return trades_df.ReturnPct.max() * 100 def worstTrade(trades_df) -> float: - return trades_df.ReturnPct.min()*100 + return trades_df.ReturnPct.min() * 100 + + +def trade_percentile(trades_df: pd.DataFrame, percentile: float) -> float: + """ + Returns the PnL or ReturnPct value at a given percentile. Eg: 5th percentile would give the value at which 5% of the trades are worse than that value + + Parameters: + trades_df (pd.DataFrame): DataFrame Contatining Trades & PnL or ReturnPct column + percentile (float): A value between 0 and 100 representing the desired percentile + + Returns: + float: Corresponding PnL or ReturnPct value at the given percentile + """ + if trades_df.empty: + return 0.0 + assert "PnL" in trades_df.columns or "ReturnPct" in trades_df.columns, ( + f"Please pass a dataframe holding trades and ensure it has either 'PnL' or 'ReturnPct' in the columns. Current Columns {trades_df.columns}" + ) + assert 0 <= percentile <= 100, ( + f"Percentile must be between 0 and 100. Current Value: {percentile}" + ) + return ( + np.percentile(trades_df.ReturnPct, percentile) * 100 + if "ReturnPct" in trades_df.columns + else np.percentile(trades_df.PnL, percentile) + ) def profitFactor(trades_df: pd.DataFrame) -> float: @@ -426,9 +485,9 @@ def profitFactor(trades_df: pd.DataFrame) -> float: Returns the profit factor of the strategy. Synonymous to R/R """ tr = trades_df - tot_loss = tr[tr['ReturnPct'] <= 0]['PnL'].sum() - tot_gain = tr[tr['ReturnPct'] > 0]['PnL'].sum() - return round(abs(tot_gain/tot_loss), 6) + tot_loss = tr[tr["ReturnPct"] <= 0]["PnL"].sum() + tot_gain = tr[tr["ReturnPct"] > 0]["PnL"].sum() + return round(abs(tot_gain / tot_loss), 6) def Expectancy(trades_df: pd.DataFrame, cash_expectancy: bool = False) -> float: @@ -436,14 +495,18 @@ def Expectancy(trades_df: pd.DataFrame, cash_expectancy: bool = False) -> float: Returns the expected %pnl based on portfolio data """ tr = trades_df - avg_win_pnl = avgPnL(tr, 'W', True) + avg_win_pnl = avgPnL(tr, "W", True) avg_win_rate = winRate(tr) avg_loss_rate = lossRate(tr) - avg_loss_pnl = avgPnL(tr, 'L', True) + avg_loss_pnl = avgPnL(tr, "L", True) if cash_expectancy: - return ((avg_win_pnl * (avg_win_rate/100)) + (avg_loss_pnl * (avg_loss_rate/100))) + return (avg_win_pnl * (avg_win_rate / 100)) + ( + avg_loss_pnl * (avg_loss_rate / 100) + ) else: - return (avgPnL(tr, 'W', False) * (winRate(tr)/100)) + (avgPnL(tr, 'L', False) * (lossRate(tr)/100)) + return (avgPnL(tr, "W", False) * (winRate(tr) / 100)) + ( + avgPnL(tr, "L", False) * (lossRate(tr) / 100) + ) def SQN(trades_df: pd.DataFrame) -> float: @@ -454,7 +517,9 @@ def SQN(trades_df: pd.DataFrame) -> float: System Quality Number. Used to guage how good a system is """ trades_ = trades_df - return ((trades_.ReturnPct.mean() * np.sqrt(len(trades_)))/np.std(trades_.ReturnPct)) + return (trades_.ReturnPct.mean() * np.sqrt(len(trades_))) / np.std( + trades_.ReturnPct + ) def ExposureDays(equity_timeseries: pd.DataFrame, trades_df: pd.DataFrame) -> float: @@ -466,18 +531,18 @@ def ExposureDays(equity_timeseries: pd.DataFrame, trades_df: pd.DataFrame) -> fl Returns the percent of days the portfolio had exposure """ - time_in = pd.DataFrame(index=equity_timeseries.index) - time_in['position'] = 0 - tr = trades_df - tr.dropna(subset=['EntryTime', 'ExitTime'], inplace=True) + time_in = pd.DataFrame(index=equity_timeseries.copy(deep=True).index) + time_in["position"] = 0 + tr = trades_df.copy(deep=True) + tr = tr.dropna(subset=["EntryTime", "ExitTime"]) for index, row in tr.iterrows(): - entry = pd.to_datetime(row['EntryTime']).date() - exit_ = pd.to_datetime(row['ExitTime']).date() - time_in['position'].loc[(time_in.index.date >= entry) - & (time_in.index.date <= exit_)] = 1 + entry = pd.to_datetime(row["EntryTime"]).date() + exit_ = pd.to_datetime(row["ExitTime"]).date() + time_in.loc[ + (time_in.index.date >= entry) & (time_in.index.date <= exit_), "position" + ] = 1 - - return round((time_in['position'] == 1).sum()/len(time_in)*100, 2) + return round((time_in["position"] == 1).sum() / len(time_in) * 100, 2) def yearly_retrns(equity_timeseries: pd.DataFrame) -> dict: @@ -488,26 +553,30 @@ def yearly_retrns(equity_timeseries: pd.DataFrame) -> dict: Returns yearly returns as a dict """ - - ts = equity_timeseries - ts['Year'] = ts.index.year - ts.drop_duplicates(inplace=True) + ts = equity_timeseries.copy(deep=True) + ts["Year"] = ts.index.year + ## dropping duplicate years if there are multiple + # ts = ts[~ts.index.duplicated(keep="first")] unq_year = ts.Year.unique() rtrn_d = {} for year in unq_year: - data = ts[ts['Year'] == year].sort_index() - ret = ((data.loc[data.index.max(), 'Total'] / - data.loc[data.index.min(), 'Total'])-1)*100 + data = ts[ts["Year"] == year].sort_index() + ret = ( + (data.loc[data.index.max(), "Total"] / data.loc[data.index.min(), "Total"]) + - 1 + ) * 100 rtrn_d[year] = ret return rtrn_d -def holding_period(trades_df: pd.DataFrame, aggfunc: Callable, Type_: str = 'W') -> pd.Timedelta: - """ +def holding_period( + trades_df: pd.DataFrame, aggfunc: Callable, Type_: str = "W" +) -> pd.Timedelta: + """ Returns the average or max holding period of the portfolio based on Trades data Parameters: - aggfunc (Callable): A callable that performs an arithmetic calculation. Eg: Mean, Median, Mode, Max, Min + aggfunc (Callable): A callable that performs an arithmetic calculation. Eg: Mean, Median, Mode, Max, Min trades_df (pd.DataFrame): DataFrame Contatining Trades & PnL or ReturnPct column Type_ (float): Which holding period are we looking for. Available options are @@ -519,23 +588,25 @@ def holding_period(trades_df: pd.DataFrame, aggfunc: Callable, Type_: str = 'W') Returns: pd.Timedelta: Corresponding Value """ - assert aggfunc.__name__.lower() in ['amax', - 'mean', 'max'], f"Function of type '{aggfunc.__name__}' cannot be used for this method. Please use a mean or max function" - assert Type_.upper() in [ - 'A', 'W', 'L'], f"Invalid type: {Type_}. Must be 'L', 'W' or 'A." + assert aggfunc.__name__.lower() in ["amax", "mean", "max"], ( + f"Function of type '{aggfunc.__name__}' cannot be used for this method. Please use a mean or max function" + ) + assert Type_.upper() in ["A", "W", "L"], ( + f"Invalid type: {Type_}. Must be 'L', 'W' or 'A." + ) # assert aggfunc.upper() in ['A', 'M'], f"Invalid type: {aggfunc}. Must be 'M' for Max or 'A' for Avg." trades_ = trades_df - if Type_.upper() == 'W': - trades_ = trades_[trades_['ReturnPct'] > 0] - elif Type_.upper() == 'L': - trades_ = trades_[trades_['ReturnPct'] <= 0] + if Type_.upper() == "W": + trades_ = trades_[trades_["ReturnPct"] > 0] + elif Type_.upper() == "L": + trades_ = trades_[trades_["ReturnPct"] <= 0] # return trades_.Duration.mean() if aggfunc.upper() == 'A' else trades_.Duration.max() return aggfunc(trades_.Duration) -def streak(trades_df: pd.DataFrame, Type_: str = 'W') -> int: - """ +def streak(trades_df: pd.DataFrame, Type_: str = "W") -> int: + """ Returns the Losing/Winning Streak based on Trades data Parameters: @@ -549,18 +620,15 @@ def streak(trades_df: pd.DataFrame, Type_: str = 'W') -> int: Returns: int: Corresponding Value """ - assert Type_.upper() in [ - 'L', 'W'], f"Invalid type: {Type_}. Must be 'L' or 'W'." - t = trades_df - t['Is_Loss'] = int - t['Is_Loss'] = t['ReturnPct'] <= 0 if Type_.upper() == 'L' else t['ReturnPct'] > 0 - t['Loss_Streak'] = (t['Is_Loss'] != t['Is_Loss'].shift()).cumsum() - streak_lengths = t.groupby('Loss_Streak')['Is_Loss'].sum() - return streak_lengths[t.groupby('Loss_Streak')['Is_Loss'].first()].max() + assert Type_.upper() in ["L", "W"], f"Invalid type: {Type_}. Must be 'L' or 'W'." + t = trades_df.copy(deep=True) + t["Is_Loss"] = t["ReturnPct"] <= 0 if Type_.upper() == "L" else t["ReturnPct"] > 0 + t["Loss_Streak"] = (t["Is_Loss"] != t["Is_Loss"].shift()).cumsum() + streak_lengths = t.groupby("Loss_Streak")["Is_Loss"].sum() + return streak_lengths[t.groupby("Loss_Streak")["Is_Loss"].first()].max() class AggregatorParent(ABC): - def __init__(self): self._equity = None self.__port_stats = None @@ -569,15 +637,24 @@ def __init__(self): @copy_doc_from(dates_) def dates_(self, start: bool): try: - overwrite = pd.to_datetime(getattr(self, 'start_overwrite')).date() + overwrite = pd.to_datetime(getattr(self, "start_overwrite")).date() except AttributeError: overwrite = None + port_stats = self.get_port_stats() + if port_stats is None: + raise NotImplementedError( + "Subclasses must implement the dates_ method when port_stats is not available." + ) + if overwrite: - return overwrite if start else dates_(self.get_port_stats(), start).date() + return overwrite if start else dates_(port_stats, start).date() else: - return dates_(self.get_port_stats(), start).date() if start else dates_(self.get_port_stats(), start).date() - + return ( + dates_(port_stats, start).date() + if start + else dates_(port_stats, start).date() + ) @abstractmethod def get_port_stats(self): @@ -616,11 +693,16 @@ def rtrn(self) -> float: Returns: float: Returns returns of portfolio from initial date to final date """ - assert self._equity is not None, f'Portfolio Equity is empty' + assert self._equity is not None, f"Portfolio Equity is empty" return rtrn(self._equity) def buyNhold(self) -> float: - return buyNhold(self.get_port_stats()) + port_stats = self.get_port_stats() + if port_stats is None: + raise NotImplementedError( + "Subclasses must implement the buyNhold method when port_stats is not available." + ) + return buyNhold(port_stats) def cagr(self) -> float: """ @@ -629,11 +711,13 @@ def cagr(self) -> float: """ return cagr(self._equity) - def vol_annualized(self, downside: Optional[bool] = False, MAR: Optional[Union[int, float]] = 0) -> float: + def vol_annualized( + self, downside: Optional[bool] = False, MAR: Optional[Union[int, float]] = 0 + ) -> float: """ Returns the annualized volatility of the portfolio, which is calculated from the Portfolio Timeseries Value - Parameters: + Parameters: downside (Optional[bool]): False for regular volatility, True to calculate downside volatility MAR (Optional[Union[int, float]]): Minimum Acceptable Return @@ -654,7 +738,7 @@ def daily_rtrns(self) -> pd.Series: def sharpe(self, risk_free_rate: float = 0.055) -> float: """ Returns the Sharpe ratio of the portfolio - Parameters: + Parameters: risk_free_rate (float): A single value representing the risk free rate. This should be an annualized value equity_timeseries (pd.DataFrame): This is the timeseries of the periodic equity values @@ -669,7 +753,7 @@ def sortino(self, risk_free_rate: float, MAR: Optional[float] = None) -> float: """ Returns the Sortino ratio of the portfolio - Parameters: + Parameters: risk_free_rate (float): A single value representing the risk free rate. This should be an annualized value MAR: Minimum Acceptable Return. A Value to compare with returns to ascertain true downside returns. Eg can be inflation rate, to show that real returns can be negative even though nominal is positive @@ -728,6 +812,7 @@ def mdd_duration(self, full: bool = False) -> pd.Timedelta: maximum drawdown duration """ from datetime import timedelta + return mdd_duration(self._equity, full) def avg_dd_duration(self) -> pd.Timedelta: @@ -741,7 +826,12 @@ def trades(self) -> pd.DataFrame: Returns a dataframe containing all trades taken """ - return trades(self.get_port_stats()) + port_stats = self.get_port_stats() + if port_stats is None: + raise NotImplementedError( + "Subclasses must implement the trades method when port_stats is not available." + ) + return trades(port_stats) def numOfTrades(self) -> int: """ @@ -755,12 +845,24 @@ def winRate(self) -> float: def lossRate(self) -> float: return round((100 - winRate(self._trades)), 2) + def trade_percentile(self, percentile: float) -> float: + """ + Returns the PnL or ReturnPct value at a given percentile. Eg: 5th percentile would give the value at which 5% of the trades are worse than that value + + Parameters: + percentile (float): A value between 0 and 100 representing the desired percentile + + Returns: + float: Corresponding PnL or ReturnPct value at the given percentile + """ + return trade_percentile(self._trades, percentile) + def avgPnL(self, Type_: str, value=True) -> float: """ params: Type_ (str): 'W', 'L', 'A'. Win, Loss or All - value (bool): True to return + value (bool): True to return """ return avgPnL(self._trades, Type_, value) @@ -792,8 +894,8 @@ def Expectancy(self) -> float: # total_cash = cash # else: # raise TypeError(f"Cash attribute must be of type dict, int or float. Current type is {type(cash)}") - - pct_expectancy = Expectancy(self._trades, cash_expectancy = False) + + pct_expectancy = Expectancy(self._trades, cash_expectancy=False) return pct_expectancy def SQN(self) -> float: @@ -816,12 +918,12 @@ def yearly_retrns(self) -> dict: return yearly_retrns(self._equity) - def holding_period(self, aggfunc: Callable, Type_: str = 'W') -> pd.Timedelta: - """ + def holding_period(self, aggfunc: Callable, Type_: str = "W") -> pd.Timedelta: + """ Returns the average or max holding period of the portfolio based on Trades data Parameters: - aggfunc (Callable): A callable that performs an arithmetic calculation. Eg: Mean, Median, Mode, Max, Min + aggfunc (Callable): A callable that performs an arithmetic calculation. Eg: Mean, Median, Mode, Max, Min Type_ (float): Which holding period are we looking for. Available options are 'W': For winning holding period @@ -835,8 +937,8 @@ def holding_period(self, aggfunc: Callable, Type_: str = 'W') -> pd.Timedelta: return holding_period(self._trades, aggfunc, Type_) - def streak(self, Type_: str = 'W') -> int: - """ + def streak(self, Type_: str = "W") -> int: + """ Returns the Losing/Winning Streak based on Trades data Parameters: @@ -849,11 +951,23 @@ def streak(self, Type_: str = 'W') -> int: """ return streak(self._trades, Type_) -## FIXME: This is ridiculously slow. - def aggregate(self, - risk_free_rate: float = 0.0, - MAR: float = 0) -> pd.Series: - """ + def get_strategy_class(self): + port_stats = self.get_port_stats() + if port_stats is None: + raise NotImplementedError( + "Subclasses must implement the get_strategy_class method when port_stats is not available." + ) + try: + strategy = list(port_stats.values())[0]["_strategy"] + return strategy + except AttributeError: + raise AttributeError( + "Port Stats must have a '_strategy' key to obtain strategy class. Please ensure your backtesting implementation includes this in the port stats output." + ) + + ## FIXME: This is ridiculously slow. + def aggregate(self, risk_free_rate: float = 0.0, MAR: float = 0) -> pd.Series: + """ Returns aggregated Data for the Porftolio @@ -864,107 +978,117 @@ def aggregate(self, Returns: int: Corresponding Value """ - assert self.get_port_stats(), f"Run Portfolio Backtest before aggregating" + is_run = self._equity is not None and self._trades is not None + if not is_run: + raise Exception( + "Portfolio must be run before aggregation. Please run the backtest before calling aggregate." + ) + # assert port_stats, "Run Portfolio Backtest before aggregating" MAR = 0.0 if not MAR else MAR - + ## Extending to ensure useability in other places. It was originally designed for PTBacktest, ## But can be used in other places ## Re-implement dates_. This is very specific to PTBacktest + assert isinstance(MAR, float), ( + f"Recieved MAR of type {type(MAR)} instead of Type float" + ) - assert isinstance( - MAR, float), f"Recieved MAR of type {type(MAR)} instead of Type float" - - try: - start_overwrite = getattr(self, 'start_overwrite') - except AttributeError: - start_overwrite = None + start_overwrite = getattr(self, "start_overwrite", None) + strategy = self.get_strategy_class() try: - strategy = list(self.get_port_stats().values())[0]['_strategy'] - except AttributeError: - strategy = None - - try: equity = self.pf_value_ts() except AttributeError: equity = self._equity except Exception: - raise Exception('Either implement pf_value_ts method or self._equity') - + raise Exception("Either implement pf_value_ts method or self._equity") try: ## Function call for trades trades = self.trades() - except TypeError: - ## If trades is not implemented, we can use the self._trades attribute - trades = self._trades except Exception: - raise Exception('Either implement trades method or self._trades') + ## If trades is not implemented, we can use the self._trades attribute + try: + trades = self._trades + except Exception: + raise Exception("Either implement trades method or self._trades") try: tickers = [dataset.name for dataset in self.datasets] except AttributeError: tickers = self.symbol_list except Exception: - raise Exception('Either implement datasets attribute with PTDataset or self.symbol_list') + raise Exception( + "Either implement datasets attribute with PTDataset or self.symbol_list" + ) rtrn_ = self.rtrn() - series1 = pd.Series({ - 'Start': start_overwrite if start_overwrite else self.dates_(True), - 'End': self.dates_(False), - 'Duration': self.dates_(False) - self.dates_(True), - 'Exposure Time [%]': self.ExposureDays(), - 'Equity Final [$]': self.final_value_func(), - 'Equity Peak [$]': self.peak_value_func(), - 'Return [%]': rtrn_, - 'Buy & Hold Return [%]': self.buyNhold(), - 'Median Daily Return [%]': f"{self.daily_rtrns().median(): .4%}", - 'VaR 95% [%]': f"{self.daily_rtrns().quantile(0.05): .2%}", - 'VaR 05% [%]': f"{self.daily_rtrns().quantile(0.95): .2%}", - 'CAGR [%]': self.cagr(), - 'Volatility Ann. [%]': self.vol_annualized(), - 'Sharpe Ratio': self.sharpe(risk_free_rate), - 'Sortino Ratio': self.sortino(risk_free_rate, MAR), - 'Skew': self._equity.Total.pct_change().skew(), - 'Log Return Skew': np.log(self._equity.Total/self._equity.Total.shift(1)).skew(), - 'Calmar Ratio': self.calmar(), - 'Max. Drawdown [%]': self.mdd(), - 'Max. Drawdown Value [$]': self.mdd_value(), - 'Avg. Drawdown [%]': self.avg_dd_percent(), - 'Max. Drawdown Duration': self.mdd_duration(), - 'Avg Dradown Duration': self.avg_dd_duration(), - '# Trades': self.numOfTrades(), - 'Win Rate [%]': self.winRate(), - 'Lose Rate [%]': self.lossRate(), - 'Avg. Trade [%]': self.avgPnL('A', False), - 'Avg. Winning Trade [%]': self.avgPnL('W', False), - 'Avg. Losing Trade [%]': self.avgPnL('L', False), - 'Best Trade [%]': self.bestTrade(), - 'Worst Trade [%]': self.worstTrade(), - 'Avg Trade Duration': self.holding_period(np.mean, 'A'), - 'Avg Win Trade Duration': self.holding_period(np.mean, 'W'), - 'Avg Lose Duration': self.holding_period(np.mean, 'L'), - 'Max Trade Duration': self.holding_period(np.max, 'A'), - 'Max Win Trade Duration': self.holding_period(np.max, 'W'), - 'Max Lose Duration': self.holding_period(np.max, 'L'), - 'Profit Factor': self.profitFactor(), - 'Expectancy [%]': self.Expectancy(), - 'SQN': self.SQN() - - }) + series1 = pd.Series( + { + "Start": start_overwrite if start_overwrite else self.dates_(True), + "End": self.dates_(False), + "Duration": self.dates_(False) - self.dates_(True), + "Exposure Time [%]": self.ExposureDays(), + "Equity Final [$]": self.final_value_func(), + "Equity Peak [$]": self.peak_value_func(), + "Return [%]": rtrn_, + "Buy & Hold Return [%]": self.buyNhold(), + "Median Daily Return [%]": f"{self.daily_rtrns().median(): .4%}", + "VaR 95% [%]": f"{self.daily_rtrns().quantile(0.05): .2%}", + "VaR 05% [%]": f"{self.daily_rtrns().quantile(0.95): .2%}", + "CAGR [%]": self.cagr(), + "Volatility Ann. [%]": self.vol_annualized(), + "Sharpe Ratio": self.sharpe(risk_free_rate), + "Sortino Ratio": self.sortino(risk_free_rate, MAR), + "Skew": self._equity.Total.pct_change().skew(), + "Log Return Skew": np.log( + self._equity.Total / self._equity.Total.shift(1) + ).skew(), + "Calmar Ratio": self.calmar(), + "Max. Drawdown [%]": self.mdd(), + "Max. Drawdown Value [$]": self.mdd_value(), + "Avg. Drawdown [%]": self.avg_dd_percent(), + "Max. Drawdown Duration": self.mdd_duration(), + "Avg Dradown Duration": self.avg_dd_duration(), + "# Trades": self.numOfTrades(), + "Win Rate [%]": self.winRate(), + "Lose Rate [%]": self.lossRate(), + "Avg. Trade [%]": self.avgPnL("A", False), + "Avg. Winning Trade [%]": self.avgPnL("W", False), + "Avg. Losing Trade [%]": self.avgPnL("L", False), + "Best Trade [%]": self.bestTrade(), + "Worst Trade [%]": self.worstTrade(), + "5th Percentile Trade [%]": self.trade_percentile(5), + "25th Percentile Trade [%]": self.trade_percentile(25), + "50th Percentile Trade [%]": self.trade_percentile(50), + "75th Percentile Trade [%]": self.trade_percentile(75), + "95th Percentile Trade [%]": self.trade_percentile(95), + "Avg Trade Duration": self.holding_period(np.mean, "A"), + "Avg Win Trade Duration": self.holding_period(np.mean, "W"), + "Avg Lose Duration": self.holding_period(np.mean, "L"), + "Max Trade Duration": self.holding_period(np.max, "A"), + "Max Win Trade Duration": self.holding_period(np.max, "W"), + "Max Lose Duration": self.holding_period(np.max, "L"), + "Profit Factor": self.profitFactor(), + "Expectancy [%]": self.Expectancy(), + "SQN": self.SQN(), + } + ) rtrn_dict = self.yearly_retrns() rtrn_series = pd.Series( - {f"{year} Return [%]": value for year, value in rtrn_dict.items()}) - - series3 = pd.Series({ - 'Winning Streak': self.streak('W'), - 'Losing Streak': self.streak('L'), - '_strategy': strategy, - 'equity_curve': equity, - '_trades': trades, - '_tickers': tickers - - }) - return pd.concat([series1, rtrn_series, series3]) \ No newline at end of file + {f"{year} Return [%]": value for year, value in rtrn_dict.items()} + ) + + series3 = pd.Series( + { + "Winning Streak": self.streak("W"), + "Losing Streak": self.streak("L"), + "_strategy": strategy, + "equity_curve": equity, + "_trades": trades, + "_tickers": tickers, + } + ) + return pd.concat([series1, rtrn_series, series3]) diff --git a/trade/helpers/helper_types.py b/trade/helpers/helper_types.py index 9cc6350..56c7012 100644 --- a/trade/helpers/helper_types.py +++ b/trade/helpers/helper_types.py @@ -1,4 +1,4 @@ -from dataclasses import fields + from typing import Iterable, TypedDict, Any from enum import Enum from datetime import datetime @@ -6,13 +6,55 @@ from typing import ClassVar from weakref import WeakSet from trade.helpers.exception import SymbolChangeError -from typing import get_origin, get_args, Union, get_type_hints, Literal +from typing import get_origin, get_args, Union, get_type_hints, Literal, Type, Dict import types from trade.helpers.Logging import setup_logger +from dataclasses import dataclass, fields +from functools import lru_cache +from typeguard import check_type + logger = setup_logger(__name__) DATE_HINT = Union[datetime, str] + + +@lru_cache(maxsize=None) +def _hints(cls: Type[Any]) -> Dict[str, Any]: + return get_type_hints(cls) + + +class TypeValidatedMixin: + def _validate_field(self, name: str, value: Any) -> None: + hint = _hints(type(self)).get(name) + if hint is not None: + check_type(value, hint) + + def _validate_all_fields(self) -> None: + for f in fields(self): + self._validate_field(f.name, getattr(self, f.name)) + + def __post_init__(self) -> None: + self._validate_all_fields() + + +@dataclass +class MutableValidated(TypeValidatedMixin): + def __setattr__(self, name: str, value: Any) -> None: + self._validate_field(name, value) + super().__setattr__(name, value) + + +@dataclass(frozen=True) +class FrozenValidated(TypeValidatedMixin): + pass + + +# frozen update pattern: +# new_obj = replace(old_obj, some_field=new_value) + + + class IncorrectTypeError(Exception): """Custom exception for incorrect type errors in configuration validation."""