def _add_performance_statistics(self): """ For each ticker computes its overall performance (PnL of short positions, PnL of long positions, total PnL). It generates a table containing final PnL values for each of the ticker nad optionally plots the performance throughout the backtest. """ closed_positions = self.backtest_result.portfolio.closed_positions() closed_positions_pnl = QFDataFrame.from_records( data=[(self._ticker_name(p.contract()), p.end_time, p.direction(), p.total_pnl) for p in closed_positions], columns=["Tickers name", "Time", "Direction", "Realised PnL"] ) closed_positions_pnl = closed_positions_pnl.sort_values(by="Time") # Get all open positions history open_positions_history = self.backtest_result.portfolio.positions_history() open_positions_history = open_positions_history.reset_index().melt( id_vars='index', value_vars=open_positions_history.columns, var_name='Contract', value_name='Position summary') open_positions_pnl = QFDataFrame(data={ "Tickers name": open_positions_history["Contract"].apply(lambda contract: self._ticker_name(contract)), "Time": open_positions_history["index"], "Direction": open_positions_history["Position summary"].apply( lambda p: p.direction if isinstance(p, BacktestPositionSummary) else 0), "Total PnL of open position": open_positions_history["Position summary"].apply( lambda p: p.total_pnl if isinstance(p, BacktestPositionSummary) else 0) }) all_positions_pnl = pd.concat([closed_positions_pnl, open_positions_pnl], sort=False) performance_dicts_series = all_positions_pnl.groupby(by=["Tickers name"]).apply( self._performance_series_for_ticker) performance_df = QFDataFrame(performance_dicts_series.tolist(), index=performance_dicts_series.index) self.document.add_element(NewPageElement()) self.document.add_element(HeadingElement(level=2, text="Performance of each asset")) final_performance = performance_df. \ applymap(lambda pnl_series: pnl_series.iloc[-1] if not pnl_series.empty else 0.0). \ sort_values(by="Overall performance", ascending=False). \ applymap(lambda p: '{:,.2f}'.format(p)). \ reset_index() table = DFTable(final_performance, css_classes=['table', 'left-align']) table.add_columns_classes(["Tickers name"], 'wide-column') self.document.add_element(table) # Add performance plots if self.generate_pnl_chart_per_ticker: self.document.add_element(NewPageElement()) self.document.add_element( HeadingElement(level=2, text="Performance of each asset during the whole backtest")) for ticker_name, performance in performance_df.iterrows(): self._plot_ticker_performance(ticker_name, performance)
def _add_avg_time_in_the_market_per_ticker(self): """ Compute the total time in the market per ticker (separately for long and short positions) in minutes and divide it by the total duration of the backtest in minutes. """ self.document.add_element(NewPageElement()) self.document.add_element(HeadingElement(level=2, text="Average time in the market per asset")) start_time = self.backtest_result.start_date end_time = self.backtest_result.portfolio.timer.now() backtest_duration = pd.Timedelta(end_time - start_time) / pd.Timedelta(minutes=1) # backtest duration in min closed_positions_time = [ (self._ticker_name(position.contract()), position.start_time, position.end_time, position.direction()) for position in self.backtest_result.portfolio.closed_positions() ] open_positions_time = [ (self._ticker_name(c), position.start_time, end_time, position.direction()) for c, position in self.backtest_result.portfolio.open_positions_dict.items() ] positions = QFDataFrame(data=closed_positions_time + open_positions_time, columns=["Tickers name", "Start time", "End time", "Position direction"]) def compute_duration(grouped_rows): return pd.DatetimeIndex([]).union_many( [pd.date_range(row["Start time"], row["End time"], freq='T', closed='left') for _, row in grouped_rows.iterrows()]).size positions = positions.groupby(by=["Tickers name", "Position direction"]).apply(compute_duration) \ .rename("Duration (minutes)").reset_index() positions["Duration"] = positions["Duration (minutes)"] / backtest_duration positions = positions.pivot_table(index="Tickers name", columns="Position direction", values="Duration").reset_index() positions = positions.rename(columns={-1: "Short", 1: "Long"}) # Add default 0 column in case if only short / long positions occurred in the backtest for column in ["Short", "Long"]: if column not in positions.columns: positions[column] = 0.0 positions["Out"] = 1.0 - positions["Long"] - positions["Short"] positions[["Long", "Short", "Out"]] = positions[["Long", "Short", "Out"]].applymap(lambda x: '{:.2%}'.format(x)) positions = positions.fillna(0.0) table = DFTable(positions, css_classes=['table', 'left-align']) table.add_columns_classes(["Tickers name"], 'wide-column') self.document.add_element(table)
def _create_performance_tables( self, performance_df: QFDataFrame) -> List[DFTable]: """ Create a formatted DFTable out of the performance_df data frame. """ numeric_columns = [ col for col in performance_df.columns if is_numeric_dtype(performance_df[col]) ] performance_df[numeric_columns] = performance_df[ numeric_columns].applymap(lambda x: '{:,.0f}'.format(x)) performance_df = performance_df.set_index("Asset").sort_index() # Divide the performance df into a number of data frames, so that each of them contains up to # self.max_col_per_page columns, but keep the first column of the original df in all of them split_dfs = np.array_split(performance_df, np.ceil(performance_df.num_of_columns / self._max_columns_per_page), axis=1) df_tables = [ DFTable(df.reset_index(), css_classes=[ 'table', 'shrink-font', 'right-align', 'wide-first-column' ]) for df in split_dfs ] return df_tables
def _get_distribution_summary_table(self, scenarios_results: SimpleReturnsSeries) -> DFTable: rows = [] percentage_list = [0.05, 0.1, 0.2, 0.3] for percentage in percentage_list: rows.append(("{:.0%} Tail".format(percentage), "{:.2%}".format(np.quantile(scenarios_results, percentage)))) rows.append(("50%", "{:.2%}".format(np.quantile(scenarios_results, 0.5)))) for percentage in reversed(percentage_list): rows.append(("{:.0%} Top".format(percentage), "{:.2%}".format(np.quantile(scenarios_results, (1.0 - percentage))))) table = DFTable(data=QFDataFrame.from_records(rows, columns=["Measure", "Value"]), css_classes=['table', 'left-align']) table.add_columns_classes(["Measure"], 'wide-column') return table
def _add_open_positions_table(self): open_positions_dict = self._portfolio.open_positions_dict contracts = open_positions_dict.keys() # Return a readable name for each ticker (name property for FutureTickers and ticker for Tickers) tickers = [ self._portfolio.contract_ticker_mapper.contract_to_ticker( contract, strictly_to_specific_ticker=False) for contract in contracts ] tickers = [ticker.name for ticker in tickers] specific_tickers = [ self._portfolio.contract_ticker_mapper.contract_to_ticker( contract).ticker for contract in contracts ] # Get the information whether it is a long or short position directions = [ open_positions_dict[contract].direction() for contract in contracts ] directions = [ "LONG" if direction == 1 else "SHORT" for direction in directions ] # Get the total exposure and market value for each open position total_exposures = [ "{:,.2f}".format(open_positions_dict[contract].total_exposure()) for contract in contracts ] pnls = [ "{:,.2f}".format(open_positions_dict[contract].unrealised_pnl) for contract in contracts ] # Get the time of opening the positions start_time = [ open_positions_dict[contract].start_time.date() for contract in contracts ] data = { "Tickers name": tickers, "Specific ticker": specific_tickers, "Direction": directions, "Total Exposure": total_exposures, "PnL": pnls, "Position Creation": start_time } table = DFTable(QFDataFrame.from_dict(data), css_classes=['table', 'left-align']) self.document.add_element(table)
def _get_monte_carlos_simulator_outputs(self, scenarios_df: PricesDataFrame, total_returns: SimpleReturnsSeries) \ -> DFTable: _, all_scenarios_number = scenarios_df.shape rows = [] # Add the Median Return value median_return = np.median(total_returns) rows.append(("Median Return", "{:.2%}".format(median_return))) # Add the Mean Return value mean_return = total_returns.mean() rows.append(("Mean Return", "{:.2%}".format(mean_return))) trade_returns = QFSeries(data=[trade.percentage_pnl for trade in self.trades]) sample_len = int(self._average_number_of_trades_per_year()) std = trade_returns.std() expectation_adj_series = np.ones(sample_len) * (trade_returns.mean() - 0.5 * std * std) expectation_adj_series = SimpleReturnsSeries(data=expectation_adj_series) expectation_adj_series = expectation_adj_series.to_prices(suggested_initial_date=0) mean_volatility_adjusted_return = expectation_adj_series.iloc[-1] / expectation_adj_series.iloc[0] - 1.0 rows.append(("Mean Volatility Adjusted Return", "{:.2%}".format(mean_volatility_adjusted_return))) # Add the Median Drawdown max_drawdowns = max_drawdown(scenarios_df) median_drawdown = np.median(max_drawdowns) rows.append(("Median Maximum Drawdown", "{:.2%}".format(median_drawdown))) # Add the Median Return / Median Drawdown rows.append(("Return / Drawdown", "{:.2f}".format(median_return / median_drawdown))) # Probability, that the return will be > 0 scenarios_with_positive_result = total_returns[total_returns > 0.0].count() probability = scenarios_with_positive_result / all_scenarios_number rows.append(("Probability of positive return", "{:.2%}".format(probability))) table = DFTable(data=QFDataFrame.from_records(rows, columns=["Measure", "Value"]), css_classes=['table', 'left-align']) table.add_columns_classes(["Measure"], 'wide-column') return table
def _get_chances_of_dropping_below_table(self, scenarios_df: PricesDataFrame) -> DFTable: _, all_scenarios_number = scenarios_df.shape rows = [] crop_table = False for percentage in np.linspace(0.1, 0.9, 9): # Count number of scenarios, whose returns at some point of time dropped below the percentage * initial # value _, scenarios_above_percentage = scenarios_df.where(scenarios_df > (1.0 - percentage)).dropna(axis=1).shape probability = (all_scenarios_number - scenarios_above_percentage) / all_scenarios_number rows.append(("{:.0%}".format(percentage), "{:.2%}".format(probability))) if crop_table is True: break elif probability < 0.1: crop_table = True table = DFTable(QFDataFrame.from_records(rows, columns=["Chances of dropping below", "Probability"]), css_classes=['table', 'left-align']) table.add_columns_classes(["Chances of dropping below"], 'wide-column') return table
def _add_open_positions_table(self): open_positions_dict = self._portfolio.open_positions_dict tickers = open_positions_dict.keys() # Get the information whether it is a long or short position directions = [open_positions_dict[t].direction() for t in tickers] directions = [ "LONG" if direction == 1 else "SHORT" for direction in directions ] # Get the total exposure and market value for each open position total_exposures = [ "{:,.2f}".format(open_positions_dict[t].total_exposure()) for t in tickers ] pnls = [ "{:,.2f}".format(open_positions_dict[t].unrealised_pnl) for t in tickers ] # Get the time of opening the positions start_time = [ open_positions_dict[t].start_time.date() for t in tickers ] data = { "Tickers name": [t.name for t in tickers], "Specific ticker": tickers, "Direction": directions, "Total Exposure": total_exposures, "PnL": pnls, "Position Creation": start_time } table = DFTable(QFDataFrame.from_dict(data), css_classes=['table', 'left-align']) self.document.add_element(table)
def _create_performance_contribution_tables( self, performance_df: QFDataFrame) -> List[DFTable]: """ Create a list of DFTables with assets names in the index and different years / months in columns, which contains details on the performance contribution for each asset. """ # Create a QFSeries which contains the initial amount of cash in the portfolio for each year / month numeric_columns = [ col for col in performance_df.columns if is_numeric_dtype(performance_df[col]) ] portfolio_values = performance_df[numeric_columns].sum().shift( fill_value=self._initial_cash).cumsum() performance_df[numeric_columns] = performance_df[ numeric_columns] / portfolio_values[numeric_columns] # Add category column and aggregate data accordingly ticker_name_to_category = { t.name: category for t, category in self._ticker_to_category.items() } performance_df["Category"] = performance_df["Asset"].apply( lambda t: ticker_name_to_category[t]) all_categories = list(set(ticker_name_to_category.values())) performance_df = performance_df.sort_values(by=["Category", "Asset"]) performance_df = performance_df.groupby("Category").apply( lambda d: pd.concat([ PricesDataFrame({ **{ "Asset": [d.name], "Category": [d.name] }, **{c: [d[c].sum()] for c in numeric_columns} }), d ], ignore_index=True)).drop(columns=["Category"]) # Add the Total Performance row (divide by 2 as the df contains already aggregated data for each group) total_sum_row = performance_df[numeric_columns].sum() / 2 total_sum_row["Asset"] = "Total Performance" performance_df = performance_df.append(total_sum_row, ignore_index=True) # Format the rows using the percentage formatter performance_df[numeric_columns] = performance_df[ numeric_columns].applymap(lambda x: '{:.2%}'.format(x)) # Divide the performance dataframe into a number of dataframes, so that each of them contains up to # self._max_columns_per_page columns split_dfs = np.array_split(performance_df.set_index("Asset"), np.ceil( (performance_df.num_of_columns - 1) / self._max_columns_per_page), axis=1) df_tables = [ DFTable(df.reset_index(), css_classes=[ 'table', 'shrink-font', 'right-align', 'wide-first-column' ]) for df in split_dfs ] # Get the indices of rows, which contain category info category_indices = performance_df[performance_df["Asset"].isin( all_categories)].index for df_table in df_tables: # Add table formatting, highlight rows showing the total contribution of the given category df_table.add_rows_styles( category_indices, { "font-weight": "bold", "font-size": "0.95em", "background-color": "#cbd0d2" }) df_table.add_rows_styles( [performance_df.index[-1]], { "font-weight": "bold", "font-size": "0.95em", "background-color": "#b9bcbd" }) return df_tables
def _add_stats_table(self): statistics = [] # type: List[Tuple] def append_to_statistics(measure_description: str, function: Callable, trades_containers, percentage_style: bool = False): style_format = "{:.2%}" if percentage_style else "{:.2f}" returned_values = (function(tc) for tc in trades_containers) returned_values = (value if is_finite_number(value) else 0.0 for value in returned_values) statistics.append((measure_description, *(style_format.format(val) for val in returned_values))) # Prepare trades data frame, used to generate all statistics trades_df = QFDataFrame.from_records( data=[(t.start_time, t.end_time, t.percentage_pnl, t.direction) for t in self.trades], columns=["start time", "end time", "percentage pnl", "direction"] ) # In case if the initial risk is not set all the return statistic will be computed using the percentage pnl, # otherwise the r_multiply = percentage pnl / initial risk is used unit = "%" if self.initial_risk is None else "R" trades_df["returns"] = trades_df["percentage pnl"] if self.initial_risk is None \ else trades_df["percentage pnl"] / self.initial_risk # Filter out only long and only long_trades_df = trades_df[trades_df["direction"] > 0] short_trades_df = trades_df[trades_df["direction"] < 0] all_dfs = [trades_df, long_trades_df, short_trades_df] append_to_statistics("Number of trades", len, all_dfs) append_to_statistics("% of trades number", lambda df: len(df) / len(trades_df) if len(trades_df) > 0 else 0, all_dfs, percentage_style=True) period_length_in_years = Timedelta(self.end_date - self.start_date) / Timedelta(days=1) / DAYS_PER_YEAR_AVG append_to_statistics("Avg number of trades per year", lambda df: len(df) / period_length_in_years, all_dfs) append_to_statistics("Avg number of trades per year per asset", lambda df: len(df) / period_length_in_years / self.nr_of_assets_traded, all_dfs) def percentage_of_positive_trades(df: QFDataFrame): return len(df[df["returns"] > 0]) / len(df) if len(df) > 0 else 0.0 append_to_statistics("% of positive trades", percentage_of_positive_trades, all_dfs, percentage_style=True) def percentage_of_negative_trades(df: QFDataFrame): return len(df[df["returns"] < 0]) / len(df) if len(df) > 0 else 0.0 append_to_statistics("% of negative trades", percentage_of_negative_trades, all_dfs, percentage_style=True) def avg_trade_duration(df: QFDataFrame): trades_duration = (df["end time"] - df["start time"]) / Timedelta(days=1) return trades_duration.mean() append_to_statistics("Average trade duration [days]", avg_trade_duration, all_dfs) append_to_statistics("Average trade return [{}]".format(unit), lambda df: df["returns"].mean(), all_dfs, percentage_style=(self.initial_risk is None)) append_to_statistics("Std trade return [{}]".format(unit), lambda df: df["returns"].std(), all_dfs, percentage_style=(self.initial_risk is None)) def avg_positive_trade_return(df: QFDataFrame): positive_trades = df[df["returns"] > 0] return positive_trades["returns"].mean() append_to_statistics("Average positive return [{}]".format(unit), avg_positive_trade_return, all_dfs, percentage_style=(self.initial_risk is None)) def avg_negative_trade_return(df: QFDataFrame): negative_trades = df[df["returns"] < 0] return negative_trades["returns"].mean() append_to_statistics("Average negative return [{}]".format(unit), avg_negative_trade_return, all_dfs, percentage_style=(self.initial_risk is None)) append_to_statistics("Best trade return [{}]".format(unit), lambda df: df["returns"].max(), all_dfs, percentage_style=(self.initial_risk is None)) append_to_statistics("Worst trade return [{}]".format(unit), lambda df: df["returns"].min(), all_dfs, percentage_style=(self.initial_risk is None)) append_to_statistics("SQN (per trade) [{}]".format(unit), lambda df: sqn(df["returns"]), all_dfs, percentage_style=(self.initial_risk is None)) append_to_statistics("SQN (per 100 trades) [{}]".format(unit), lambda df: sqn_for100trades(df["returns"]), all_dfs, percentage_style=(self.initial_risk is None)) def sqn_per_year(returns: QFSeries): sqn_per_year_value = sqn(returns) * sqrt(avg_nr_of_trades_per1y(returns, self.start_date, self.end_date)) return sqn_per_year_value append_to_statistics("SQN (per year) [{}]".format(unit), lambda df: sqn_per_year(df["returns"]), all_dfs, percentage_style=(self.initial_risk is None)) statistics_df = QFDataFrame.from_records(statistics, columns=["Measure", "All trades", "Long trades", "Short trades"]) table = DFTable(statistics_df, css_classes=['table', 'left-align']) table.add_columns_classes(["Measure"], 'wide-column') self.document.add_element(table)