Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.7
2.0.8
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/bbstrader/api/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
76 changes: 75 additions & 1 deletion src/bbstrader/btengine/performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/bbstrader/metatrader/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/bbstrader/metatrader/copier.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import MetaTrader5 as Mt5
except ImportError:
import bbstrader.compat # noqa: F401
import MetaTrader5 as Mt5


__all__ = [
Expand Down
1 change: 1 addition & 0 deletions src/bbstrader/metatrader/rates.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import MetaTrader5 as Mt5
except ImportError:
import bbstrader.compat # noqa: F401
import MetaTrader5 as Mt5


__all__ = [
Expand Down
1 change: 1 addition & 0 deletions src/bbstrader/metatrader/risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/bbstrader/metatrader/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import MetaTrader5 as Mt5
except ImportError:
import bbstrader.compat # noqa: F401
import MetaTrader5 as Mt5

__all__ = [
"Trade",
Expand Down
1 change: 1 addition & 0 deletions src/bbstrader/metatrader/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import MetaTrader5 as MT5
except ImportError:
import bbstrader.compat # noqa: F401
import MetaTrader5 as MT5


__all__ = [
Expand Down
84 changes: 79 additions & 5 deletions src/bbstrader/models/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand All @@ -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):
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/bbstrader/trading/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import MetaTrader5 as MT5
except ImportError:
import bbstrader.compat # noqa: F401
import MetaTrader5 as MT5


__all__ = ["Mt5ExecutionEngine", "RunMt5Engine", "RunMt5Engines"]
Expand Down
2 changes: 1 addition & 1 deletion tcopier.iss
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[Setup]
AppName=TradeCopier
AppVersion=2.0.4
AppVersion=2.0.8
AppPublisher=bbstrading
DefaultDirName={pf}\TradeCopier
DefaultGroupName=TradeCopier
Expand Down
1 change: 1 addition & 0 deletions tests/api/test_metatrader_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import MetaTrader5 as mt5
except ImportError:
import bbstrader.compat # noqa: F401
import MetaTrader5 as mt5

from bbstrader.api.handlers import Mt5Handlers
from bbstrader.api.client import MetaTraderClient # type: ignore
Expand Down
58 changes: 58 additions & 0 deletions tests/btengine/test_performance.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading