diff --git a/experiments/backtest.py b/experiments/backtest.py index e7ad2b5..253651f 100644 --- a/experiments/backtest.py +++ b/experiments/backtest.py @@ -47,7 +47,7 @@ def n_assets(self) -> int: def run_backtest( strategy: Callable, risk_target: float, verbose: bool = False -) -> tuple[pd.Series, pd.DataFrame]: +) -> BacktestResult: """ Run a simplified backtest for a given strategy. At time t we use data from t-lookback to t to compute the optimal portfolio @@ -55,6 +55,13 @@ def run_backtest( """ prices, spread, rf = load_data() + training_length = 1250 + prices, spread, rf = ( + prices.iloc[training_length:], + spread.iloc[training_length:], + rf.iloc[training_length:], + ) + n_assets = prices.shape[1] lookback = 500 diff --git a/experiments/markowitz.py b/experiments/markowitz.py index 76c4fd2..8a9d726 100644 --- a/experiments/markowitz.py +++ b/experiments/markowitz.py @@ -20,7 +20,6 @@ @dataclass class Data: w_prev: np.ndarray # (n_assets,) array of previous asset weights - c_prev: float # previous cash weight idio_mean: np.ndarray # (n_assets,) array of idiosyncratic mean returns factor_mean: np.ndarray # (n_factors,) array of factor mean returns risk_free: float # risk-free rate @@ -36,17 +35,23 @@ class Data: def n_assets(self) -> int: return self.w_prev.size + @property + def volas(self) -> np.ndarray: + return self.idio_volas + np.linalg.norm( + self.F @ self.factor_covariance_chol, axis=1 + ) + @dataclass class Parameters: - w_lower: np.ndarray # (n_assets,) array of lower bounds on asset weights - w_upper: np.ndarray # (n_assets,) array of upper bounds on asset weights - c_lower: float # lower bound on cash weight - c_upper: float # upper bound on cash weight - z_lower: np.ndarray # (n_assets,) array of lower bounds on trades - z_upper: np.ndarray # (n_assets,) array of upper bounds on trades - T_max: float # turnover target - L_max: float # leverage limit + w_min: np.ndarray # (n_assets,) array of lower bounds on asset weights + w_max: np.ndarray # (n_assets,) array of upper bounds on asset weights + c_min: float # lower bound on cash weight + c_max: float # upper bound on cash weight + z_min: np.ndarray # (n_assets,) array of lower bounds on trades + z_max: np.ndarray # (n_assets,) array of upper bounds on trades + T_tar: float # turnover target + L_tar: float # leverage target rho_mean: np.ndarray # (n_assets,) array of mean returns for rho rho_covariance: float # uncertainty in covariance matrix gamma_hold: float # holding cost @@ -75,17 +80,11 @@ def markowitz(data: Data, param: Parameters) -> tuple[np.ndarray, float, cp.Prob return_uncertainty = param.rho_mean @ cp.abs(w) return_wc = mean_return - return_uncertainty - # asset volatilities - factor_volas = cp.norm2(data.F @ data.factor_covariance_chol, axis=1) - volas = factor_volas + data.idio_volas - - # portfolio risk + # worst-case (robust) risk factor_risk = cp.norm2((data.F @ data.factor_covariance_chol).T @ w) idio_risk = cp.norm2(cp.multiply(data.idio_volas, w)) risk = cp.norm2(cp.hstack([factor_risk, idio_risk])) - - # worst-case (robust) risk - risk_uncertainty = param.rho_covariance**0.5 * volas @ cp.abs(w) + risk_uncertainty = param.rho_covariance**0.5 * data.volas @ cp.abs(w) risk_wc = cp.norm2(cp.hstack([risk, risk_uncertainty])) asset_holding_cost = data.kappa_short @ cp.pos(-w) @@ -97,24 +96,20 @@ def markowitz(data: Data, param: Parameters) -> tuple[np.ndarray, float, cp.Prob trading_cost = spread_cost + impact_cost objective = ( - return_wc - - param.gamma_risk * cp.pos(risk_wc - param.risk_target) - - param.gamma_hold * holding_cost - - param.gamma_trade * trading_cost - - param.gamma_turn * cp.pos(T - param.T_max) + return_wc - param.gamma_hold * holding_cost - param.gamma_trade * trading_cost ) constraints = [ cp.sum(w) + c == 1, - c == data.c_prev - cp.sum(z), - param.c_lower <= c, - c <= param.c_upper, - param.w_lower <= w, - w <= param.w_upper, - param.z_lower <= z, - z <= param.z_upper, - L <= param.L_max, - T <= param.T_max, + param.w_min <= w, + w <= param.w_max, + L <= param.L_tar, + param.c_min <= c, + c <= param.c_max, + param.z_min <= z, + z <= param.z_max, + T <= param.T_tar, + risk_wc <= param.risk_target, ] problem = cp.Problem(cp.Maximize(objective), constraints) @@ -130,7 +125,6 @@ def markowitz(data: Data, param: Parameters) -> tuple[np.ndarray, float, cp.Prob n_assets = 10 data = Data( w_prev=np.ones(n_assets) / n_assets, - c_prev=0.0, idio_mean=np.zeros(n_assets), factor_mean=np.zeros(n_assets), risk_free=0.0, @@ -144,14 +138,14 @@ def markowitz(data: Data, param: Parameters) -> tuple[np.ndarray, float, cp.Prob ) param = Parameters( - w_lower=np.zeros(n_assets), - w_upper=np.ones(n_assets), - c_lower=0.0, - c_upper=1.0, - z_lower=-np.ones(n_assets), - z_upper=np.ones(n_assets), - T_max=1.0, - L_max=1.0, + w_min=np.zeros(n_assets), + w_max=np.ones(n_assets), + c_min=0.0, + c_max=1.0, + z_min=-np.ones(n_assets), + z_max=np.ones(n_assets), + T_tar=1.0, + L_tar=1.0, rho_mean=np.zeros(n_assets), rho_covariance=0.0, gamma_hold=0.0, @@ -161,5 +155,5 @@ def markowitz(data: Data, param: Parameters) -> tuple[np.ndarray, float, cp.Prob risk_target=0.0, ) - w, c = markowitz(data, param) + w, c, _ = markowitz(data, param) print(w, c) diff --git a/experiments/taming.py b/experiments/taming.py index 9175037..bfb200e 100644 --- a/experiments/taming.py +++ b/experiments/taming.py @@ -41,12 +41,11 @@ def weight_limits_markowitz( data, param = get_basic_data_and_parameters(inputs) - param.w_lower = lb - param.w_upper = ub - param.c_lower = -0.05 - param.c_upper = 1.0 + param.w_min = lb + param.w_max = ub + param.c_min = -0.05 + param.c_max = 1.0 param.risk_target = inputs.risk_target - param.gamma_risk = 5.0 return markowitz(data, param) @@ -55,7 +54,7 @@ def leverage_limit_markowitz( ) -> tuple[np.ndarray, float, cp.Problem]: data, param = get_basic_data_and_parameters(inputs) - param.L_max = 1.6 + param.L_tar = 1.6 return markowitz(data, param) @@ -64,7 +63,7 @@ def turnover_limit_markowitz( ) -> tuple[np.ndarray, float, cp.Problem]: data, param = get_basic_data_and_parameters(inputs) - param.T_max = 50 / 252 # Maximum TO per year + param.T_tar = 50 / 252 # Maximum TO per year return markowitz(data, param) @@ -84,14 +83,8 @@ def get_basic_data_and_parameters( latest_prices = inputs.prices.iloc[-1] portfolio_value = inputs.cash + inputs.quantities @ latest_prices - # The risk constraint is soft. - # For each percentage point of risk, we need to compensate with - # 5 percentage points of return. - gamma_risk = 5.0 - data = Data( w_prev=(inputs.quantities * latest_prices / portfolio_value).values, - c_prev=(inputs.cash / portfolio_value), idio_mean=np.zeros(n_assets), factor_mean=inputs.mean.values, risk_free=0, @@ -104,20 +97,20 @@ def get_basic_data_and_parameters( kappa_impact=np.zeros(n_assets), ) param = Parameters( - w_lower=-np.ones(data.n_assets) * 1e3, - w_upper=np.ones(data.n_assets) * 1e3, - c_lower=-1e3, - c_upper=1e3, - z_lower=-np.ones(data.n_assets) * 1e3, - z_upper=np.ones(data.n_assets) * 1e3, - T_max=1e3, - L_max=1e3, + w_min=-np.ones(data.n_assets) * 1e3, + w_max=np.ones(data.n_assets) * 1e3, + c_min=-1e3, + c_max=1e3, + z_min=-np.ones(data.n_assets) * 1e3, + z_max=np.ones(data.n_assets) * 1e3, + T_tar=1e3, + L_tar=1e3, rho_mean=np.zeros(data.n_assets), rho_covariance=0.0, gamma_hold=0.0, gamma_trade=0.0, gamma_turn=0.0, - gamma_risk=gamma_risk, + gamma_risk=0.0, risk_target=inputs.risk_target, ) return data, param @@ -155,6 +148,15 @@ def main(from_checkpoint: bool = True) -> None: robust_result, ) + generate_per_year_tables( + equal_weights_results, + basic_result, + weight_limited_result, + leverage_limit_result, + turnover_limit_result, + robust_result, + ) + def run_all_strategies(annualized_target: float) -> None: equal_weights_results = run_backtest(equal_weights, 0.0, verbose=True) @@ -163,9 +165,9 @@ def run_all_strategies(annualized_target: float) -> None: adjustment_factor = np.sqrt(equal_weights_results.periods_per_year) sigma_target = annualized_target / adjustment_factor - print("Running basic Markowitz") - basic_result = run_backtest(basic_markowitz, sigma_target, verbose=True) - basic_result.save(f"checkpoints/basic_{annualized_target}.pickle") + # print("Running basic Markowitz") + # basic_result = run_backtest(basic_markowitz, sigma_target, verbose=True) + # basic_result.save(f"checkpoints/basic_{annualized_target}.pickle") print("Running weight-limited Markowitz") weight_limited_result = run_backtest( @@ -239,7 +241,7 @@ def generate_table( "Sharpe": lambda x: f"{x:.2f}", "Turnover": lambda x: f"{x:.1f}", "Leverage": lambda x: f"{x:.1f}", - "Drawdown": lambda x: rf"{100 * x:.1f}\%", + "Drawdown": lambda x: rf"{-100 * x:.1f}\%", } print( @@ -249,5 +251,252 @@ def generate_table( ) +def generate_per_year_tables( + equal_weights_results: BacktestResult, + basic_results: BacktestResult, + weight_limited_result: BacktestResult, + leverage_limit_result: BacktestResult, + turnover_limit_result: BacktestResult, + robust_result: BacktestResult, +) -> None: + # TODO: DRY + + years = sorted(equal_weights_results.history.year.unique()) + + equal_subs = [] + basic_subs = [] + weight_limited_subs = [] + leverage_limit_subs = [] + turnover_limit_subs = [] + robust_subs = [] + + for year in years: + sub_index = equal_weights_results.history.year == year + + equal_subs.append(get_sub_result(equal_weights_results, sub_index)) + basic_subs.append(get_sub_result(basic_results, sub_index)) + weight_limited_subs.append(get_sub_result(weight_limited_result, sub_index)) + leverage_limit_subs.append(get_sub_result(leverage_limit_result, sub_index)) + turnover_limit_subs.append(get_sub_result(turnover_limit_result, sub_index)) + robust_subs.append(get_sub_result(robust_result, sub_index)) + + mean_df = pd.concat( + [ + pd.Series( + [result.mean_return for result in equal_subs], index=years, name="Equal" + ), + pd.Series( + [result.mean_return for result in basic_subs], index=years, name="Basic" + ), + pd.Series( + [result.mean_return for result in weight_limited_subs], + index=years, + name="Weight-limited", + ), + pd.Series( + [result.mean_return for result in leverage_limit_subs], + index=years, + name="Leverage-limited", + ), + pd.Series( + [result.mean_return for result in turnover_limit_subs], + index=years, + name="Turnover-limited", + ), + pd.Series( + [result.mean_return for result in robust_subs], + index=years, + name="Robust", + ), + ], + axis=1, + ) + + print(mean_df.to_latex(float_format=lambda x: rf"{100 * x:.1f}\%")) + + volatility_df = pd.concat( + [ + pd.Series( + [result.volatility for result in equal_subs], index=years, name="Equal" + ), + pd.Series( + [result.volatility for result in basic_subs], index=years, name="Basic" + ), + pd.Series( + [result.volatility for result in weight_limited_subs], + index=years, + name="Weight-limited", + ), + pd.Series( + [result.volatility for result in leverage_limit_subs], + index=years, + name="Leverage-limited", + ), + pd.Series( + [result.volatility for result in turnover_limit_subs], + index=years, + name="Turnover-limited", + ), + pd.Series( + [result.volatility for result in robust_subs], + index=years, + name="Robust", + ), + ], + axis=1, + ) + + print(volatility_df.to_latex(float_format=lambda x: rf"{100 * x:.1f}\%")) + + sharpe_df = pd.concat( + [ + pd.Series( + [result.sharpe for result in equal_subs], index=years, name="Equal" + ), + pd.Series( + [result.sharpe for result in basic_subs], index=years, name="Basic" + ), + pd.Series( + [result.sharpe for result in weight_limited_subs], + index=years, + name="Weight-limited", + ), + pd.Series( + [result.sharpe for result in leverage_limit_subs], + index=years, + name="Leverage-limited", + ), + pd.Series( + [result.sharpe for result in turnover_limit_subs], + index=years, + name="Turnover-limited", + ), + pd.Series( + [result.sharpe for result in robust_subs], index=years, name="Robust" + ), + ], + axis=1, + ) + + print(sharpe_df.to_latex(float_format=lambda x: f"{x:.2f}")) + + turnover_df = pd.concat( + [ + pd.Series( + [result.turnover for result in equal_subs], index=years, name="Equal" + ), + pd.Series( + [result.turnover for result in basic_subs], index=years, name="Basic" + ), + pd.Series( + [result.turnover for result in weight_limited_subs], + index=years, + name="Weight-limited", + ), + pd.Series( + [result.turnover for result in leverage_limit_subs], + index=years, + name="Leverage-limited", + ), + pd.Series( + [result.turnover for result in turnover_limit_subs], + index=years, + name="Turnover-limited", + ), + pd.Series( + [result.turnover for result in robust_subs], index=years, name="Robust" + ), + ], + axis=1, + ) + + print(turnover_df.to_latex(float_format=lambda x: f"{x:.1f}")) + + leverage_df = pd.concat( + [ + pd.Series( + [result.max_leverage for result in equal_subs], + index=years, + name="Equal", + ), + pd.Series( + [result.max_leverage for result in basic_subs], + index=years, + name="Basic", + ), + pd.Series( + [result.max_leverage for result in weight_limited_subs], + index=years, + name="Weight-limited", + ), + pd.Series( + [result.max_leverage for result in leverage_limit_subs], + index=years, + name="Leverage-limited", + ), + pd.Series( + [result.max_leverage for result in turnover_limit_subs], + index=years, + name="Turnover-limited", + ), + pd.Series( + [result.max_leverage for result in robust_subs], + index=years, + name="Robust", + ), + ], + axis=1, + ) + + print(leverage_df.to_latex(float_format=lambda x: f"{x:.1f}")) + + drawdown_df = pd.concat( + [ + pd.Series( + [result.max_drawdown for result in equal_subs], + index=years, + name="Equal", + ), + pd.Series( + [result.max_drawdown for result in basic_subs], + index=years, + name="Basic", + ), + pd.Series( + [result.max_drawdown for result in weight_limited_subs], + index=years, + name="Weight-limited", + ), + pd.Series( + [result.max_drawdown for result in leverage_limit_subs], + index=years, + name="Leverage-limited", + ), + pd.Series( + [result.max_drawdown for result in turnover_limit_subs], + index=years, + name="Turnover-limited", + ), + pd.Series( + [result.max_drawdown for result in robust_subs], + index=years, + name="Robust", + ), + ], + axis=1, + ) + + print(drawdown_df.to_latex(float_format=lambda x: rf"{-100 * x:.1f}\%")) + + +def get_sub_result(result: BacktestResult, sub_index: pd.Series) -> BacktestResult: + return BacktestResult( + cash=result.cash.loc[sub_index], + quantities=result.quantities.loc[sub_index], + risk_target=result.risk_target, + timings=None, + ) + + if __name__ == "__main__": main()