From 51b25f8d3165bef6b7f59cb3c8258fe94273bdd3 Mon Sep 17 00:00:00 2001 From: bbalouki Date: Wed, 1 Apr 2026 01:49:20 +0100 Subject: [PATCH 1/4] updates --- src/bbstrader/btengine/performance.py | 76 +++++++++++++++++++++++- src/bbstrader/models/optimization.py | 84 +++++++++++++++++++++++++-- tests/btengine/test_performance.py | 58 ++++++++++++++++++ tests/models/test_optimization.py | 68 ++++++++++++++++++++++ 4 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 tests/btengine/test_performance.py create mode 100644 tests/models/test_optimization.py diff --git a/src/bbstrader/btengine/performance.py b/src/bbstrader/btengine/performance.py index 0200f07..469b1d6 100644 --- a/src/bbstrader/btengine/performance.py +++ b/src/bbstrader/btengine/performance.py @@ -17,6 +17,10 @@ "plot_performance", "create_sharpe_ratio", "create_sortino_ratio", + "create_omega_ratio", + "create_calmar_ratio", + "create_tail_ratio", + "calculate_risk_metrics", "plot_returns_and_dd", "plot_monthly_yearly_returns", "show_qs_stats", @@ -111,6 +115,72 @@ def create_sortino_ratio(returns: pd.Series, periods: int = 252) -> float: return qs.stats.sortino(returns, periods=periods) +def create_omega_ratio( + returns: pd.Series, periods: int = 252, rf: float = 0.0 +) -> float: + """ + Create the Omega ratio for the strategy. + + Args: + returns : A pandas Series representing period percentage returns. + periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. + rf (float): Risk-free rate. + + Returns: + float: Omega ratio + """ + return qs.stats.omega(returns, rf=rf) + + +def create_calmar_ratio(returns: pd.Series, periods: int = 252) -> float: + """ + Create the Calmar ratio for the strategy. + + Args: + returns : A pandas Series representing period percentage returns. + periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. + + Returns: + float: Calmar ratio + """ + return qs.stats.calmar(returns) + + +def create_tail_ratio(returns: pd.Series) -> float: + """ + Create the Tail ratio for the strategy. + + Args: + returns : A pandas Series representing period percentage returns. + + Returns: + float: Tail ratio + """ + return qs.stats.tail_ratio(returns) + + +def calculate_risk_metrics( + returns: pd.Series, benchmark_returns: pd.Series, periods: int = 252 +) -> Dict[str, float]: + """ + Calculate Alpha, Beta and Volatility for the strategy. + + Args: + returns : A pandas Series representing period percentage returns. + benchmark_returns : A pandas Series representing benchmark period percentage returns. + periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc. + + Returns: + Dict[str, float]: Alpha, Beta, Volatility + """ + g_beta = qs.stats.greeks(returns, benchmark_returns) + alpha = g_beta["alpha"] + beta = g_beta["beta"] + volatility = qs.stats.volatility(returns, periods=periods) + + return {"alpha": alpha, "beta": beta, "volatility": volatility} + + def create_drawdowns(pnl: pd.Series) -> Tuple[pd.Series, float, float]: """ Calculate the largest peak-to-trough drawdown of the PnL curve @@ -125,6 +195,8 @@ def create_drawdowns(pnl: pd.Series) -> Tuple[pd.Series, float, float]: """ # Calculate the cumulative returns curve # and set up the High Water Mark + if pnl.empty: + return pd.Series(dtype=float), 0.0, 0.0 hwm = pd.Series(index=pnl.index) hwm.iloc[0] = 0 @@ -139,7 +211,9 @@ def create_drawdowns(pnl: pd.Series) -> Tuple[pd.Series, float, float]: drawdown.iloc[t] = hwm.iloc[t] - pnl.iloc[t] duration.iloc[t] = 0 if drawdown.iloc[t] == 0 else duration.iloc[t - 1] + 1 - return drawdown, drawdown.max(), duration.max() + max_drawdown = drawdown.max() if not drawdown.empty else 0.0 + max_duration = duration.max() if not duration.empty else 0.0 + return drawdown, max_drawdown, max_duration def plot_performance(df: pd.DataFrame, title: str) -> None: diff --git a/src/bbstrader/models/optimization.py b/src/bbstrader/models/optimization.py index fc6610a..00040fc 100644 --- a/src/bbstrader/models/optimization.py +++ b/src/bbstrader/models/optimization.py @@ -3,30 +3,36 @@ from pypfopt import expected_returns, risk_models from pypfopt.efficient_frontier import EfficientFrontier from pypfopt.hierarchical_portfolio import HRPOpt +from pypfopt.black_litterman import BlackLittermanModel __all__ = [ "markowitz_weights", "hierarchical_risk_parity", + "black_litterman_weights", "equal_weighted", "optimized_weights", ] -def markowitz_weights(prices=None, rfr=0.0, freq=252): +def markowitz_weights(prices=None, rfr=0.0, freq=252, min_vol=False): """ - Calculates optimal portfolio weights using Markowitz's mean-variance optimization (Max Sharpe Ratio) with multiple solvers. + Calculates optimal portfolio weights using Markowitz's mean-variance optimization (Max Sharpe Ratio or Min Volatility) with multiple solvers. Parameters ---------- prices : pd.DataFrame, optional Price data for assets, where rows represent time periods and columns represent assets. + rfr : float, optional + Risk-free rate (default is 0.0). freq : int, optional Frequency of the data, such as 252 for daily returns in a year (default is 252). + min_vol : bool, optional + If True, optimizes for minimum volatility instead of maximum Sharpe ratio (default is False). Returns ------- dict - Dictionary containing the optimal asset weights for maximizing the Sharpe ratio, normalized to sum to 1. + Dictionary containing the optimal asset weights for maximizing the Sharpe ratio or minimizing volatility, normalized to sum to 1. Notes ----- @@ -53,10 +59,15 @@ def markowitz_weights(prices=None, rfr=0.0, freq=252): solver=solver, ) try: - ef.max_sharpe(risk_free_rate=rfr) + if min_vol: + ef.min_volatility() + else: + ef.max_sharpe(risk_free_rate=rfr) return ef.clean_weights() except Exception as e: print(f"Solver {solver} failed with error: {e}") + # Default to equal weighted if all solvers fail + return equal_weighted(prices=prices) def hierarchical_risk_parity(prices=None, returns=None, freq=252): @@ -140,7 +151,66 @@ def equal_weighted(prices=None, returns=None, round_digits=5): return {col: round(1 / n, round_digits) for col in columns} -def optimized_weights(prices=None, returns=None, rfr=0.0, freq=252, method="equal"): +def black_litterman_weights( + prices=None, + rfr=0.0, + freq=252, + views=None, + view_confidences=None, + pi=None, + market_caps=None, +): + """ + Computes portfolio weights using the Black-Litterman model. + + Parameters + ---------- + prices : pd.DataFrame + Price data for assets. + rfr : float, optional + Risk-free rate (default is 0.0). + freq : int, optional + Frequency of the data (default is 252). + views : dict, optional + Investor's views on asset returns. + view_confidences : list or np.array, optional + Confidence levels for each view. + pi : pd.Series, optional + Market-implied prior returns. + market_caps : pd.Series, optional + Market capitalization of assets. + + Returns + ------- + dict + Optimal asset weights based on the Black-Litterman model. + """ + cov_matrix = risk_models.sample_cov(prices, frequency=freq) + if pi is None: + if market_caps is not None: + # If market caps are provided, we can use them to compute the prior + # This requires a benchmark, which we don't have here easily. + # For simplicity, we use the mean historical return if pi is not provided. + pi = expected_returns.mean_historical_return(prices, frequency=freq) + else: + pi = expected_returns.mean_historical_return(prices, frequency=freq) + + bl = BlackLittermanModel( + cov_matrix, + pi=pi, + absolute_views=views, + omega=None, + view_confidences=view_confidences, + ) + ret_bl = bl.bl_returns() + ef = EfficientFrontier(ret_bl, cov_matrix) + ef.max_sharpe(risk_free_rate=rfr) + return ef.clean_weights() + + +def optimized_weights( + prices=None, returns=None, rfr=0.0, freq=252, method="equal", **kwargs +): """ Selects an optimization method to calculate portfolio weights based on user preference. @@ -174,8 +244,12 @@ def optimized_weights(prices=None, returns=None, rfr=0.0, freq=252, method="equa """ if method == "markowitz": return markowitz_weights(prices=prices, rfr=rfr, freq=freq) + elif method == "min_vol": + return markowitz_weights(prices=prices, rfr=rfr, freq=freq, min_vol=True) elif method == "hrp": return hierarchical_risk_parity(prices=prices, returns=returns, freq=freq) + elif method == "black_litterman": + return black_litterman_weights(prices=prices, rfr=rfr, freq=freq, **kwargs) elif method == "equal": return equal_weighted(prices=prices, returns=returns) else: diff --git a/tests/btengine/test_performance.py b/tests/btengine/test_performance.py new file mode 100644 index 0000000..1db32f5 --- /dev/null +++ b/tests/btengine/test_performance.py @@ -0,0 +1,58 @@ +import numpy as np +import pandas as pd +import pytest + +from bbstrader.btengine.performance import ( + calculate_risk_metrics, + create_calmar_ratio, + create_drawdowns, + create_omega_ratio, + create_sharpe_ratio, + create_sortino_ratio, + create_tail_ratio, +) + + +@pytest.fixture +def sample_returns(): + dates = pd.date_range("2020-01-01", periods=100) + returns = pd.Series(np.random.normal(0.001, 0.02, 100), index=dates) + return returns + + +@pytest.fixture +def sample_benchmark(): + dates = pd.date_range("2020-01-01", periods=100) + returns = pd.Series(np.random.normal(0.0005, 0.015, 100), index=dates) + return returns + + +def test_create_drawdowns(sample_returns): + drawdown, max_dd, max_duration = create_drawdowns(sample_returns) + assert isinstance(drawdown, pd.Series) + assert isinstance(max_dd, float) + assert isinstance(max_duration, (float, int)) + + +def test_create_drawdowns_empty(): + empty_returns = pd.Series([], dtype=float) + drawdown, max_dd, max_duration = create_drawdowns(empty_returns) + assert drawdown.empty + assert max_dd == 0.0 + assert max_duration == 0.0 + + +def test_ratios(sample_returns): + assert isinstance(create_sharpe_ratio(sample_returns), (float, np.float64)) + assert isinstance(create_sortino_ratio(sample_returns), (float, np.float64)) + assert isinstance(create_omega_ratio(sample_returns), (float, np.float64)) + assert isinstance(create_calmar_ratio(sample_returns), (float, np.float64)) + assert isinstance(create_tail_ratio(sample_returns), (float, np.float64)) + + +def test_risk_metrics(sample_returns, sample_benchmark): + metrics = calculate_risk_metrics(sample_returns, sample_benchmark) + assert isinstance(metrics, dict) + assert "alpha" in metrics + assert "beta" in metrics + assert "volatility" in metrics diff --git a/tests/models/test_optimization.py b/tests/models/test_optimization.py new file mode 100644 index 0000000..18bc4dd --- /dev/null +++ b/tests/models/test_optimization.py @@ -0,0 +1,68 @@ +import pandas as pd +import numpy as np +import pytest +from bbstrader.models.optimization import ( + markowitz_weights, + hierarchical_risk_parity, + equal_weighted, + optimized_weights, + black_litterman_weights, +) + + +@pytest.fixture +def sample_prices(): + dates = pd.date_range("2020-01-01", periods=100) + data = { + "AAPL": np.linspace(100, 150, 100) + np.random.normal(0, 2, 100), + "MSFT": np.linspace(200, 250, 100) + np.random.normal(0, 2, 100), + "GOOG": np.linspace(1000, 1100, 100) + np.random.normal(0, 5, 100), + } + return pd.DataFrame(data, index=dates) + + +def test_markowitz_weights(sample_prices): + weights = markowitz_weights(sample_prices) + assert isinstance(weights, dict) + assert len(weights) == 3 + assert np.isclose(sum(weights.values()), 1.0) + + +def test_markowitz_min_vol(sample_prices): + weights = markowitz_weights(sample_prices, min_vol=True) + assert isinstance(weights, dict) + assert len(weights) == 3 + assert np.isclose(sum(weights.values()), 1.0) + + +def test_hrp_weights(sample_prices): + weights = hierarchical_risk_parity(prices=sample_prices) + assert isinstance(weights, dict) + assert len(weights) == 3 + assert np.isclose(sum(weights.values()), 1.0) + + +def test_equal_weighted(sample_prices): + weights = equal_weighted(prices=sample_prices) + assert isinstance(weights, dict) + assert len(weights) == 3 + assert all(np.isclose(w, 1 / 3) for w in weights.values()) + + +def test_black_litterman_weights(sample_prices): + views = {"AAPL": 0.05, "MSFT": 0.02} + weights = black_litterman_weights(sample_prices, views=views) + assert isinstance(weights, dict) + assert len(weights) == 3 + assert np.isclose(sum(weights.values()), 1.0) + + +def test_optimized_weights_methods(sample_prices): + methods = ["markowitz", "min_vol", "hrp", "equal", "black_litterman"] + for method in methods: + kwargs = {} + if method == "black_litterman": + kwargs = {"views": {"AAPL": 0.05}} + weights = optimized_weights(prices=sample_prices, method=method, **kwargs) + assert isinstance(weights, dict) + assert np.isclose(sum(weights.values()), 1.0) From fc336d7c043e7e073e3f16bdd23cc58de048466c Mon Sep 17 00:00:00 2001 From: bbalouki Date: Wed, 1 Apr 2026 01:50:15 +0100 Subject: [PATCH 2/4] update version --- VERSION.txt | 2 +- tcopier.iss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION.txt b/VERSION.txt index f1547e6..815e68d 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -2.0.7 +2.0.8 diff --git a/tcopier.iss b/tcopier.iss index 0ba198a..70013f3 100644 --- a/tcopier.iss +++ b/tcopier.iss @@ -1,6 +1,6 @@ [Setup] AppName=TradeCopier -AppVersion=2.0.4 +AppVersion=2.0.8 AppPublisher=bbstrading DefaultDirName={pf}\TradeCopier DefaultGroupName=TradeCopier From 308a1a9603a5bc06c35c7a6da5b1e283be9b36ff Mon Sep 17 00:00:00 2001 From: bbalouki Date: Wed, 1 Apr 2026 02:11:01 +0100 Subject: [PATCH 3/4] updates --- pyproject.toml | 1 + src/bbstrader/api/handlers.py | 1 + src/bbstrader/metatrader/broker.py | 1 + src/bbstrader/metatrader/copier.py | 1 + src/bbstrader/metatrader/rates.py | 1 + src/bbstrader/metatrader/risk.py | 1 + src/bbstrader/metatrader/trade.py | 1 + src/bbstrader/metatrader/utils.py | 1 + src/bbstrader/trading/execution.py | 1 + tests/api/test_metatrader_client.py | 3 ++- 10 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7d58756..fa85c93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "nltk>=3.9.1", "notify_py>=0.3.43", "numpy>=2.2.6", + "plotly>=5.24.1" "praw>=7.8.1", "pybind11>=3.0.1", "pyfiglet>=1.0.4", diff --git a/src/bbstrader/api/handlers.py b/src/bbstrader/api/handlers.py index 08be946..29cd67c 100644 --- a/src/bbstrader/api/handlers.py +++ b/src/bbstrader/api/handlers.py @@ -21,6 +21,7 @@ import MetaTrader5 as mt5 except ImportError: import bbstrader.compat # noqa: F401 + import MetaTrader5 as mt5 def _convert_obj(obj, obj_type): diff --git a/src/bbstrader/metatrader/broker.py b/src/bbstrader/metatrader/broker.py index a58471d..db79623 100644 --- a/src/bbstrader/metatrader/broker.py +++ b/src/bbstrader/metatrader/broker.py @@ -11,6 +11,7 @@ import MetaTrader5 as mt5 except ImportError: import bbstrader.compat # noqa: F401 + import MetaTrader5 as mt5 COUNTRIES_STOCKS = { "USA": r"\b(US|USA)\b", diff --git a/src/bbstrader/metatrader/copier.py b/src/bbstrader/metatrader/copier.py index d43dfeb..540a0f0 100644 --- a/src/bbstrader/metatrader/copier.py +++ b/src/bbstrader/metatrader/copier.py @@ -22,6 +22,7 @@ import MetaTrader5 as Mt5 except ImportError: import bbstrader.compat # noqa: F401 + import MetaTrader5 as Mt5 __all__ = [ diff --git a/src/bbstrader/metatrader/rates.py b/src/bbstrader/metatrader/rates.py index 51b33f2..35a262c 100644 --- a/src/bbstrader/metatrader/rates.py +++ b/src/bbstrader/metatrader/rates.py @@ -12,6 +12,7 @@ import MetaTrader5 as Mt5 except ImportError: import bbstrader.compat # noqa: F401 + import MetaTrader5 as Mt5 __all__ = [ diff --git a/src/bbstrader/metatrader/risk.py b/src/bbstrader/metatrader/risk.py index d7ea96e..ed93434 100644 --- a/src/bbstrader/metatrader/risk.py +++ b/src/bbstrader/metatrader/risk.py @@ -14,6 +14,7 @@ import MetaTrader5 as mt5 except ImportError: import bbstrader.compat # noqa: F401 + import MetaTrader5 as mt5 logger.add( f"{BBSTRADER_DIR}/logs/trade.log", diff --git a/src/bbstrader/metatrader/trade.py b/src/bbstrader/metatrader/trade.py index 78abe55..2bd7ebc 100644 --- a/src/bbstrader/metatrader/trade.py +++ b/src/bbstrader/metatrader/trade.py @@ -22,6 +22,7 @@ import MetaTrader5 as Mt5 except ImportError: import bbstrader.compat # noqa: F401 + import MetaTrader5 as Mt5 __all__ = [ "Trade", diff --git a/src/bbstrader/metatrader/utils.py b/src/bbstrader/metatrader/utils.py index 6c22483..e8fd960 100644 --- a/src/bbstrader/metatrader/utils.py +++ b/src/bbstrader/metatrader/utils.py @@ -7,6 +7,7 @@ import MetaTrader5 as MT5 except ImportError: import bbstrader.compat # noqa: F401 + import MetaTrader5 as MT5 __all__ = [ diff --git a/src/bbstrader/trading/execution.py b/src/bbstrader/trading/execution.py index a84a8b1..82f72b6 100644 --- a/src/bbstrader/trading/execution.py +++ b/src/bbstrader/trading/execution.py @@ -21,6 +21,7 @@ import MetaTrader5 as MT5 except ImportError: import bbstrader.compat # noqa: F401 + import MetaTrader5 as MT5 __all__ = ["Mt5ExecutionEngine", "RunMt5Engine", "RunMt5Engines"] diff --git a/tests/api/test_metatrader_client.py b/tests/api/test_metatrader_client.py index a498ab5..47c5841 100644 --- a/tests/api/test_metatrader_client.py +++ b/tests/api/test_metatrader_client.py @@ -6,7 +6,8 @@ try: import MetaTrader5 as mt5 except ImportError: - import bbstrader.compat # noqa: F401 + import bbstrader.compat # noqa: F401 + import MetaTrader5 as mt5 from bbstrader.api.handlers import Mt5Handlers from bbstrader.api.client import MetaTraderClient # type: ignore From 782afecff41022a8f895cbce2cde8e78f495d741 Mon Sep 17 00:00:00 2001 From: bbalouki Date: Wed, 1 Apr 2026 02:18:34 +0100 Subject: [PATCH 4/4] updats --- pyproject.toml | 2 +- tests/api/test_metatrader_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fa85c93..d5325bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "nltk>=3.9.1", "notify_py>=0.3.43", "numpy>=2.2.6", - "plotly>=5.24.1" + "plotly>=5.24.1", "praw>=7.8.1", "pybind11>=3.0.1", "pyfiglet>=1.0.4", diff --git a/tests/api/test_metatrader_client.py b/tests/api/test_metatrader_client.py index 47c5841..74865b5 100644 --- a/tests/api/test_metatrader_client.py +++ b/tests/api/test_metatrader_client.py @@ -6,7 +6,7 @@ try: import MetaTrader5 as mt5 except ImportError: - import bbstrader.compat # noqa: F401 + import bbstrader.compat # noqa: F401 import MetaTrader5 as mt5 from bbstrader.api.handlers import Mt5Handlers