riskoptima 2.3.0__tar.gz → 2.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {riskoptima-2.3.0 → riskoptima-2.3.2}/PKG-INFO +20 -1
  2. {riskoptima-2.3.0 → riskoptima-2.3.2}/README.md +19 -0
  3. {riskoptima-2.3.0 → riskoptima-2.3.2}/pyproject.toml +1 -1
  4. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/__init__.py +4 -1
  5. riskoptima-2.3.2/riskoptima/backtest/__init__.py +34 -0
  6. riskoptima-2.3.2/riskoptima/backtest/sma.py +302 -0
  7. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/optim/mean_variance.py +13 -9
  8. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/options/binomial.py +17 -0
  9. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/options/black_scholes.py +8 -2
  10. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/reporting/market_risk.py +25 -8
  11. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/riskoptima.py +36 -218
  12. riskoptima-2.3.0/riskoptima/backtest/__init__.py +0 -13
  13. {riskoptima-2.3.0 → riskoptima-2.3.2}/LICENSE +0 -0
  14. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/backtest/engine.py +0 -0
  15. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/backtest/portfolio.py +0 -0
  16. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/backtest/strategy.py +0 -0
  17. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/core/__init__.py +0 -0
  18. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/core/types.py +0 -0
  19. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/credit/__init__.py +0 -0
  20. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/credit/metrics.py +0 -0
  21. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/credit/models.py +0 -0
  22. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/credit/portfolio.py +0 -0
  23. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/credit/simulation.py +0 -0
  24. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/data/__init__.py +0 -0
  25. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/optim/__init__.py +0 -0
  26. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/optim/constraints.py +0 -0
  27. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/optim/costs.py +0 -0
  28. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/options/__init__.py +0 -0
  29. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/options/greeks.py +0 -0
  30. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/options/implied_vol.py +0 -0
  31. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/options/monte_carlo.py +0 -0
  32. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/reporting/__init__.py +0 -0
  33. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/risk/__init__.py +0 -0
  34. {riskoptima-2.3.0 → riskoptima-2.3.2}/riskoptima/risk/factor_model.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: riskoptima
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: RiskOptima is a powerful Python toolkit for financial risk analysis, portfolio optimization, and advanced quantitative modeling. It integrates state-of-the-art methodologies, including Monte Carlo simulations, Value at Risk (VaR), Conditional VaR (CVaR), Black-Scholes, Heston, and Merton Jump Diffusion models, to aid investors in making data-driven investment decisions.
5
5
  Home-page: https://github.com/JordiCorbilla/RiskOptima
6
6
  License: MIT
@@ -108,6 +108,25 @@ equity_curve, weights_history = run_backtest(prices, strategy, config, cost_mode
108
108
 
109
109
  See `examples/example_factor_backtest.py` for a runnable end-to-end example.
110
110
 
111
+ ### SMA Crossover Strategy
112
+
113
+ RiskOptima includes reusable SMA crossover helpers for a simple long-only trend-following workflow. The short moving average crossing above the long moving average creates an entry signal; a bearish cross, stop loss, or take profit closes the trade.
114
+
115
+ ```python
116
+ from riskoptima.backtest import build_sma_signal_frame, run_sma_strategy_with_risk
117
+
118
+ signals = build_sma_signal_frame(prices[["Close"]], short_window=20, long_window=50)
119
+ trades = run_sma_strategy_with_risk(
120
+ "SPY",
121
+ start="2024-01-01",
122
+ end="2025-01-01",
123
+ stop_loss=0.05,
124
+ take_profit=0.10,
125
+ )
126
+ ```
127
+
128
+ The notebook `05-portfolio_sma_strategy.ipynb` shows single-asset, equal-weight multi-asset, and custom-weight portfolio runs.
129
+
111
130
  ### Offline sample datasets
112
131
 
113
132
  RiskOptima includes small synthetic datasets for deterministic examples:
@@ -80,6 +80,25 @@ equity_curve, weights_history = run_backtest(prices, strategy, config, cost_mode
80
80
 
81
81
  See `examples/example_factor_backtest.py` for a runnable end-to-end example.
82
82
 
83
+ ### SMA Crossover Strategy
84
+
85
+ RiskOptima includes reusable SMA crossover helpers for a simple long-only trend-following workflow. The short moving average crossing above the long moving average creates an entry signal; a bearish cross, stop loss, or take profit closes the trade.
86
+
87
+ ```python
88
+ from riskoptima.backtest import build_sma_signal_frame, run_sma_strategy_with_risk
89
+
90
+ signals = build_sma_signal_frame(prices[["Close"]], short_window=20, long_window=50)
91
+ trades = run_sma_strategy_with_risk(
92
+ "SPY",
93
+ start="2024-01-01",
94
+ end="2025-01-01",
95
+ stop_loss=0.05,
96
+ take_profit=0.10,
97
+ )
98
+ ```
99
+
100
+ The notebook `05-portfolio_sma_strategy.ipynb` shows single-asset, equal-weight multi-asset, and custom-weight portfolio runs.
101
+
83
102
  ### Offline sample datasets
84
103
 
85
104
  RiskOptima includes small synthetic datasets for deterministic examples:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "riskoptima"
3
- version = "2.3.0"
3
+ version = "2.3.2"
4
4
  description = "RiskOptima is a powerful Python toolkit for financial risk analysis, portfolio optimization, and advanced quantitative modeling. It integrates state-of-the-art methodologies, including Monte Carlo simulations, Value at Risk (VaR), Conditional VaR (CVaR), Black-Scholes, Heston, and Merton Jump Diffusion models, to aid investors in making data-driven investment decisions."
5
5
  authors = ["Jordi Corbilla <jordi.coll.corbilla@gmail.com>"]
6
6
  license = "MIT"
@@ -11,7 +11,7 @@
11
11
  #----------------------------------------------------------------------------
12
12
  # Created By : Jordi Corbilla
13
13
  # Created Date: 2025
14
- # version ='2.3.0'
14
+ # version ='2.3.2'
15
15
  # ---------------------------------------------------------------------------
16
16
 
17
17
  from .riskoptima import RiskOptima
@@ -19,6 +19,7 @@ from .core import MarketData, Portfolio, BacktestConfig, RiskReport
19
19
  from .risk import FactorRiskModel
20
20
  from .optim import Constraints, optimize_max_sharpe, optimize_min_variance, SimpleCostModel
21
21
  from .backtest import run_backtest, Strategy, SMACrossStrategy, PortfolioState
22
+ from .backtest import build_sma_signal_frame, run_sma_strategy_with_risk
22
23
  from .credit import expected_loss, credit_var, merton_pd
23
24
  from .reporting import build_market_risk_report
24
25
  from .options import black_scholes_price, black_scholes_greeks, implied_volatility
@@ -38,6 +39,8 @@ __all__ = [
38
39
  "Strategy",
39
40
  "SMACrossStrategy",
40
41
  "PortfolioState",
42
+ "build_sma_signal_frame",
43
+ "run_sma_strategy_with_risk",
41
44
  "expected_loss",
42
45
  "credit_var",
43
46
  "merton_pd",
@@ -0,0 +1,34 @@
1
+ ###############################################################################
2
+ # __init__.py
3
+ ###############################################################################
4
+ # Product: RiskOptima
5
+ # Author: Jordi Corbilla
6
+ # Description: RiskOptima module
7
+ ###############################################################################
8
+
9
+ from .engine import run_backtest
10
+ from .strategy import Strategy, SMACrossStrategy
11
+ from .portfolio import PortfolioState
12
+ from .sma import (
13
+ build_sma_signal_frame,
14
+ trades_from_sma_signals,
15
+ run_sma_strategy_with_risk,
16
+ run_strategy_on_portfolio,
17
+ run_and_plot_sma_strategy,
18
+ plot_sma_strategy_cumulative_return,
19
+ plot_sma_strategy_trades,
20
+ )
21
+
22
+ __all__ = [
23
+ "run_backtest",
24
+ "Strategy",
25
+ "SMACrossStrategy",
26
+ "PortfolioState",
27
+ "build_sma_signal_frame",
28
+ "trades_from_sma_signals",
29
+ "run_sma_strategy_with_risk",
30
+ "run_strategy_on_portfolio",
31
+ "run_and_plot_sma_strategy",
32
+ "plot_sma_strategy_cumulative_return",
33
+ "plot_sma_strategy_trades",
34
+ ]
@@ -0,0 +1,302 @@
1
+ ###############################################################################
2
+ # sma.py
3
+ ###############################################################################
4
+ # Product: RiskOptima
5
+ # Author: Jordi Corbilla
6
+ # Description: SMA crossover helpers
7
+ ###############################################################################
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime
12
+ import os
13
+ from typing import Iterable
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+
19
+ TRADE_COLUMNS = [
20
+ "Ticker",
21
+ "Entry Date",
22
+ "Exit Date",
23
+ "Entry Price",
24
+ "Exit Price",
25
+ "Return",
26
+ "Exit Reason",
27
+ ]
28
+
29
+ PORTFOLIO_TRADE_COLUMNS = TRADE_COLUMNS + ["Weight", "Weighted Return"]
30
+
31
+
32
+ def _close_frame(prices) -> pd.DataFrame:
33
+ data = pd.DataFrame(prices).copy()
34
+ if isinstance(data.columns, pd.MultiIndex):
35
+ if "Close" in data.columns.get_level_values(0):
36
+ close_data = data.xs("Close", axis=1, level=0)
37
+ elif "Close" in data.columns.get_level_values(-1):
38
+ close_data = data.xs("Close", axis=1, level=-1)
39
+ else:
40
+ raise ValueError("prices must contain a Close column or a single price column")
41
+ data = close_data.iloc[:, [0]].copy()
42
+ data.columns = ["Close"]
43
+ elif "Close" in data.columns:
44
+ data = data[["Close"]].copy()
45
+ elif data.shape[1] == 1:
46
+ data.columns = ["Close"]
47
+ else:
48
+ raise ValueError("prices must contain a Close column or a single price column")
49
+ data["Close"] = pd.to_numeric(data["Close"], errors="coerce")
50
+ return data.dropna(subset=["Close"])
51
+
52
+
53
+ def download_close_prices(ticker: str, start: str, end: str) -> pd.DataFrame:
54
+ """
55
+ Downloads close prices with yfinance.
56
+ """
57
+ import yfinance as yf
58
+
59
+ return yf.download(ticker, start=start, end=end, progress=False, auto_adjust=False)[["Close"]].copy()
60
+
61
+
62
+ def build_sma_signal_frame(prices, short_window: int = 20, long_window: int = 50) -> pd.DataFrame:
63
+ """
64
+ Builds close, SMA, and crossover signal columns.
65
+
66
+ Signal is +1 on bullish short-over-long crosses, -1 on bearish crosses, and 0 otherwise.
67
+ """
68
+ if short_window <= 0 or long_window <= 0:
69
+ raise ValueError("short_window and long_window must be positive")
70
+ if short_window >= long_window:
71
+ raise ValueError("short_window must be smaller than long_window")
72
+
73
+ df = _close_frame(prices)
74
+ df[f"SMA{short_window}"] = df["Close"].rolling(short_window).mean()
75
+ df[f"SMA{long_window}"] = df["Close"].rolling(long_window).mean()
76
+
77
+ short_ma = df[f"SMA{short_window}"]
78
+ long_ma = df[f"SMA{long_window}"]
79
+ bullish = (short_ma > long_ma) & (short_ma.shift(1) <= long_ma.shift(1))
80
+ bearish = (short_ma < long_ma) & (short_ma.shift(1) >= long_ma.shift(1))
81
+
82
+ df["Signal"] = 0
83
+ df.loc[bullish, "Signal"] = 1
84
+ df.loc[bearish, "Signal"] = -1
85
+ return df
86
+
87
+
88
+ def trades_from_sma_signals(
89
+ signal_frame: pd.DataFrame,
90
+ ticker: str,
91
+ stop_loss: float = None,
92
+ take_profit: float = None,
93
+ ) -> pd.DataFrame:
94
+ """
95
+ Converts SMA entry/exit signals into a long-only trade log.
96
+ """
97
+ required = {"Close", "Signal"}
98
+ missing = required - set(signal_frame.columns)
99
+ if missing:
100
+ raise ValueError(f"signal_frame is missing required columns: {sorted(missing)}")
101
+
102
+ trades = []
103
+ position = None
104
+ entry_price = None
105
+ entry_date = None
106
+
107
+ for exit_date, row in signal_frame.iterrows():
108
+ price = float(row["Close"])
109
+ signal = int(row["Signal"])
110
+
111
+ if position is None and signal == 1:
112
+ position = "long"
113
+ entry_price = price
114
+ entry_date = exit_date
115
+ elif position == "long":
116
+ pnl = (price - entry_price) / entry_price
117
+ hit_stop = stop_loss is not None and pnl <= -stop_loss
118
+ hit_take = take_profit is not None and pnl >= take_profit
119
+
120
+ if signal == -1 or hit_stop or hit_take:
121
+ trades.append(
122
+ {
123
+ "Ticker": ticker,
124
+ "Entry Date": entry_date,
125
+ "Exit Date": exit_date,
126
+ "Entry Price": entry_price,
127
+ "Exit Price": price,
128
+ "Return": pnl,
129
+ "Exit Reason": (
130
+ "Sell Signal" if signal == -1 else "Stop Loss" if hit_stop else "Take Profit"
131
+ ),
132
+ }
133
+ )
134
+ position = None
135
+
136
+ return pd.DataFrame(trades, columns=TRADE_COLUMNS)
137
+
138
+
139
+ def run_sma_strategy_with_risk(
140
+ ticker: str,
141
+ start: str,
142
+ end: str,
143
+ stop_loss: float = None,
144
+ take_profit: float = None,
145
+ short_window: int = 20,
146
+ long_window: int = 50,
147
+ prices: pd.DataFrame = None,
148
+ ) -> pd.DataFrame:
149
+ """
150
+ Runs an SMA crossover strategy for one ticker and returns a trade log.
151
+ """
152
+ price_data = prices if prices is not None else download_close_prices(ticker, start, end)
153
+ signals = build_sma_signal_frame(price_data, short_window=short_window, long_window=long_window)
154
+ return trades_from_sma_signals(signals, ticker=ticker, stop_loss=stop_loss, take_profit=take_profit)
155
+
156
+
157
+ def run_strategy_on_portfolio(
158
+ asset_table: pd.DataFrame,
159
+ start: str,
160
+ end: str,
161
+ stop_loss: float = None,
162
+ take_profit: float = None,
163
+ short_window: int = 20,
164
+ long_window: int = 50,
165
+ ) -> pd.DataFrame:
166
+ """
167
+ Runs the SMA strategy across an asset table with Asset and Weight columns.
168
+ """
169
+ if not {"Asset", "Weight"}.issubset(asset_table.columns):
170
+ raise ValueError("asset_table must contain Asset and Weight columns")
171
+
172
+ results = []
173
+ for _, row in asset_table.iterrows():
174
+ trades_df = run_sma_strategy_with_risk(
175
+ row["Asset"],
176
+ start,
177
+ end,
178
+ stop_loss=stop_loss,
179
+ take_profit=take_profit,
180
+ short_window=short_window,
181
+ long_window=long_window,
182
+ )
183
+ if trades_df.empty:
184
+ continue
185
+ trades_df["Weight"] = row["Weight"]
186
+ trades_df["Weighted Return"] = trades_df["Return"] * row["Weight"]
187
+ results.append(trades_df)
188
+
189
+ if not results:
190
+ return pd.DataFrame(columns=PORTFOLIO_TRADE_COLUMNS)
191
+
192
+ all_trades = pd.concat(results, ignore_index=True)
193
+ return all_trades.sort_values(by="Entry Date")
194
+
195
+
196
+ def plot_sma_strategy_cumulative_return(trade_log: pd.DataFrame, title="Portfolio Return"):
197
+ """
198
+ Plots cumulative weighted returns from an SMA trade log.
199
+ """
200
+ import matplotlib.pyplot as plt
201
+
202
+ fig, ax = plt.subplots(figsize=(20, 12))
203
+
204
+ if not trade_log.empty:
205
+ trade_log = trade_log.sort_values("Exit Date").copy()
206
+ trade_log["Cumulative Return"] = (1 + trade_log["Weighted Return"]).cumprod()
207
+ plt.plot(trade_log["Exit Date"], trade_log["Cumulative Return"], marker="o")
208
+
209
+ plt.title(title)
210
+ plt.xlabel("Date")
211
+ plt.ylabel("Cumulative Return")
212
+ plt.grid(alpha=0.3)
213
+ plt.text(
214
+ 0.995,
215
+ -0.20,
216
+ "Created by RiskOptima",
217
+ fontsize=12,
218
+ color="gray",
219
+ alpha=0.7,
220
+ transform=ax.transAxes,
221
+ ha="right",
222
+ )
223
+ plt.tight_layout()
224
+ plt.show()
225
+
226
+
227
+ def plot_sma_strategy_trades(signal_frame: pd.DataFrame, ticker: str):
228
+ """
229
+ Plots close prices, SMA bands, and buy/sell markers.
230
+ """
231
+ import matplotlib.pyplot as plt
232
+
233
+ df = signal_frame.copy()
234
+ sma_cols = [col for col in df.columns if col.startswith("SMA")]
235
+
236
+ fig, ax = plt.subplots(figsize=(20, 12))
237
+ plt.plot(df.index, df["Close"], label="Close Price", alpha=0.5)
238
+ for col in sma_cols:
239
+ plt.plot(df.index, df[col], label=col, alpha=0.8)
240
+
241
+ plt.scatter(df.index[df["Signal"] == 1], df["Close"][df["Signal"] == 1], marker="^", color="green", s=100, label="Buy Signal")
242
+ plt.scatter(df.index[df["Signal"] == -1], df["Close"][df["Signal"] == -1], marker="v", color="red", s=100, label="Sell Signal")
243
+ plt.title(f"{ticker} - SMA Strategy with Signals")
244
+ plt.xlabel("Date")
245
+ plt.ylabel("Price")
246
+ plt.legend()
247
+ plt.grid(alpha=0.3)
248
+ plt.tight_layout()
249
+
250
+ plots_folder = "plots"
251
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
252
+ if not os.path.exists(plots_folder):
253
+ os.makedirs(plots_folder)
254
+ plot_path = os.path.join(plots_folder, f"riskoptima_sma_strategy_{timestamp}.png")
255
+ plt.savefig(plot_path, dpi=150, bbox_inches="tight")
256
+ plt.show()
257
+
258
+
259
+ def normalize_asset_table(tickers) -> pd.DataFrame:
260
+ """
261
+ Normalizes a ticker string, ticker list, or asset table into Asset/Weight rows.
262
+ """
263
+ if isinstance(tickers, str):
264
+ return pd.DataFrame([{"Asset": tickers, "Weight": 1.0}])
265
+ if isinstance(tickers, Iterable) and not isinstance(tickers, pd.DataFrame):
266
+ tickers = list(tickers)
267
+ return pd.DataFrame([{"Asset": ticker, "Weight": 1.0 / len(tickers)} for ticker in tickers])
268
+ if isinstance(tickers, pd.DataFrame):
269
+ return tickers.copy()
270
+ raise ValueError("Tickers must be a string, list, or DataFrame.")
271
+
272
+
273
+ def run_and_plot_sma_strategy(
274
+ tickers,
275
+ start_date,
276
+ end_date,
277
+ stop_loss=None,
278
+ take_profit=None,
279
+ short_window: int = 20,
280
+ long_window: int = 50,
281
+ ):
282
+ """
283
+ Runs and plots SMA signals plus cumulative weighted trade returns.
284
+ """
285
+ asset_table = normalize_asset_table(tickers)
286
+ portfolio_trades = run_strategy_on_portfolio(
287
+ asset_table,
288
+ start=start_date,
289
+ end=end_date,
290
+ stop_loss=stop_loss,
291
+ take_profit=take_profit,
292
+ short_window=short_window,
293
+ long_window=long_window,
294
+ )
295
+
296
+ for ticker in asset_table["Asset"]:
297
+ prices = download_close_prices(ticker, start_date, end_date)
298
+ signals = build_sma_signal_frame(prices, short_window=short_window, long_window=long_window)
299
+ plot_sma_strategy_trades(signals, ticker)
300
+
301
+ plot_sma_strategy_cumulative_return(portfolio_trades, title="SMA Strategy - Cumulative Return")
302
+ return portfolio_trades
@@ -41,10 +41,12 @@ def optimize_max_sharpe(
41
41
  return -(port_ret - risk_free_rate) / port_vol
42
42
 
43
43
  cons = [_sum_to_one_constraint()]
44
- cons += factor_constraint_func(init_guess, factor_exposures, constraints.factor_bounds)
45
-
46
- result = minimize(neg_sharpe, init_guess, method="SLSQP", bounds=bounds, constraints=cons)
47
- return pd.Series(result.x, index=expected_returns.index)
44
+ cons += factor_constraint_func(init_guess, factor_exposures, constraints.factor_bounds)
45
+
46
+ result = minimize(neg_sharpe, init_guess, method="SLSQP", bounds=bounds, constraints=cons)
47
+ if not result.success:
48
+ raise ValueError(f"Max Sharpe optimization failed: {result.message}")
49
+ return pd.Series(result.x, index=expected_returns.index)
48
50
 
49
51
 
50
52
  def optimize_min_variance(
@@ -67,8 +69,10 @@ def optimize_min_variance(
67
69
  cons = [_sum_to_one_constraint()]
68
70
  if target_return is not None and expected_returns is not None:
69
71
  cons.append({"type": "eq", "fun": lambda w: np.dot(w, expected_returns.values) - target_return})
70
- cons += factor_constraint_func(init_guess, factor_exposures, constraints.factor_bounds)
71
-
72
- result = minimize(portfolio_vol, init_guess, method="SLSQP", bounds=bounds, constraints=cons)
73
- index = expected_returns.index if expected_returns is not None else cov.index
74
- return pd.Series(result.x, index=index)
72
+ cons += factor_constraint_func(init_guess, factor_exposures, constraints.factor_bounds)
73
+
74
+ result = minimize(portfolio_vol, init_guess, method="SLSQP", bounds=bounds, constraints=cons)
75
+ if not result.success:
76
+ raise ValueError(f"Minimum variance optimization failed: {result.message}")
77
+ index = expected_returns.index if expected_returns is not None else cov.index
78
+ return pd.Series(result.x, index=index)
@@ -18,8 +18,25 @@ def binomial_tree_price(S, K, T, r, sigma, steps=100, option_type="call", q=0.0,
18
18
  option_type = option_type.lower()
19
19
  if option_type not in {"call", "put"}:
20
20
  raise ValueError("option_type must be 'call' or 'put'")
21
+ if S <= 0 or K <= 0:
22
+ raise ValueError("S and K must be positive")
23
+ if T < 0:
24
+ raise ValueError("T must be non-negative")
25
+ if sigma < 0:
26
+ raise ValueError("sigma must be non-negative")
21
27
  if steps <= 0:
22
28
  raise ValueError("steps must be positive")
29
+ if T == 0:
30
+ intrinsic = max(S - K, 0.0) if option_type == "call" else max(K - S, 0.0)
31
+ return float(intrinsic)
32
+ if sigma == 0:
33
+ discounted_strike = K * np.exp(-r * T)
34
+ deterministic = (
35
+ max(S * np.exp(-q * T) - discounted_strike, 0.0)
36
+ if option_type == "call"
37
+ else max(discounted_strike - S * np.exp(-q * T), 0.0)
38
+ )
39
+ return float(deterministic)
23
40
 
24
41
  dt = T / steps
25
42
  u = np.exp(sigma * np.sqrt(dt))
@@ -24,10 +24,16 @@ def black_scholes_price(S, K, T, r, sigma, option_type="call", q=0.0):
24
24
  Prices a European option using the Black-Scholes-Merton formula.
25
25
  """
26
26
  option_type = _option_type(option_type)
27
- if T <= 0:
27
+ if S <= 0 or K <= 0:
28
+ raise ValueError("S and K must be positive")
29
+ if T < 0:
30
+ raise ValueError("T must be non-negative")
31
+ if sigma < 0:
32
+ raise ValueError("sigma must be non-negative")
33
+ if T == 0:
28
34
  intrinsic = max(S - K, 0.0) if option_type == "call" else max(K - S, 0.0)
29
35
  return float(intrinsic)
30
- if sigma <= 0:
36
+ if sigma == 0:
31
37
  forward_intrinsic = (
32
38
  max(S * np.exp(-q * T) - K * np.exp(-r * T), 0.0)
33
39
  if option_type == "call"
@@ -15,6 +15,28 @@ from scipy.stats import norm
15
15
  from riskoptima.core import RiskReport
16
16
 
17
17
 
18
+ def _validated_weights(weights, columns) -> pd.Series:
19
+ if weights is None:
20
+ return pd.Series(np.repeat(1.0 / len(columns), len(columns)), index=columns, dtype=float)
21
+
22
+ if isinstance(weights, pd.Series):
23
+ missing = [col for col in columns if col not in weights.index]
24
+ if missing:
25
+ raise ValueError(f"weights are missing assets: {missing}")
26
+ w = weights.reindex(columns).astype(float)
27
+ else:
28
+ values = np.asarray(weights, dtype=float)
29
+ if values.ndim != 1 or len(values) != len(columns):
30
+ raise ValueError("weights must be a 1D array-like with one value per return column")
31
+ w = pd.Series(values, index=columns, dtype=float)
32
+
33
+ if not np.isfinite(w).all():
34
+ raise ValueError("weights must be finite")
35
+ if np.isclose(w.sum(), 0.0):
36
+ raise ValueError("weights must not sum to zero")
37
+ return w / w.sum()
38
+
39
+
18
40
  def _portfolio_returns(returns, weights=None) -> pd.Series:
19
41
  data = pd.DataFrame(returns).copy() if not isinstance(returns, pd.Series) else returns.to_frame("portfolio")
20
42
  data = data.apply(pd.to_numeric, errors="coerce").dropna(how="all")
@@ -23,14 +45,9 @@ def _portfolio_returns(returns, weights=None) -> pd.Series:
23
45
  return data.iloc[:, 0].dropna().rename("portfolio")
24
46
 
25
47
  clean = data.dropna(how="any")
26
- if weights is None:
27
- w = np.repeat(1.0 / clean.shape[1], clean.shape[1])
28
- else:
29
- w = pd.Series(weights, index=clean.columns if len(weights) == clean.shape[1] else None, dtype=float)
30
- if list(w.index) != list(clean.columns):
31
- w = pd.Series(np.asarray(weights, dtype=float), index=clean.columns)
32
- if not np.isclose(w.sum(), 1.0):
33
- w = w / w.sum()
48
+ if clean.empty:
49
+ raise ValueError("returns must contain at least one complete finite row")
50
+ w = _validated_weights(weights, clean.columns)
34
51
  return clean.dot(w).rename("portfolio")
35
52
 
36
53
 
@@ -79,7 +79,7 @@ warnings.filterwarnings(
79
79
 
80
80
  class RiskOptima:
81
81
  TRADING_DAYS = 260 # default is 260, though 252 is also common
82
- VERSION = '2.3.0'
82
+ VERSION = '2.3.2'
83
83
 
84
84
  @staticmethod
85
85
  def get_trading_days():
@@ -3479,234 +3479,52 @@ class RiskOptima:
3479
3479
 
3480
3480
  @staticmethod
3481
3481
  def run_sma_strategy_with_risk(ticker: str, start: str, end: str, stop_loss: float = None, take_profit: float = None):
3482
- trade_columns = [
3483
- 'Ticker',
3484
- 'Entry Date',
3485
- 'Exit Date',
3486
- 'Entry Price',
3487
- 'Exit Price',
3488
- 'Return',
3489
- 'Exit Reason',
3490
- ]
3491
-
3492
- df = yf.download(ticker, start=start, end=end, progress=False, auto_adjust=False)[['Close']].copy()
3493
-
3494
- df['SMA20'] = df['Close'].rolling(20).mean()
3495
- df['SMA50'] = df['Close'].rolling(50).mean()
3496
-
3497
- df['Signal'] = 0
3498
- df.loc[df.index[50]:, 'Signal'] = (
3499
- (df['SMA20'][50:] > df['SMA50'][50:]) &
3500
- (df['SMA20'].shift(1)[50:] <= df['SMA50'].shift(1)[50:])
3501
- ).astype(int) - (
3502
- (df['SMA20'][50:] < df['SMA50'][50:]) &
3503
- (df['SMA20'].shift(1)[50:] >= df['SMA50'].shift(1)[50:])
3504
- ).astype(int)
3505
-
3506
- trades = []
3507
- position = None
3508
- entry_price = None
3509
- entry_date = None
3510
-
3511
- for exit_date, row in df.iterrows():
3512
- price = row['Close'].item() if hasattr(row['Close'], 'item') else float(row['Close'])
3513
- signal = row['Signal'].item() if hasattr(row['Signal'], 'item') else int(row['Signal'])
3514
-
3515
- if position is None and signal == 1:
3516
- position = 'long'
3517
- entry_price = price
3518
- entry_date = exit_date
3519
-
3520
- elif position == 'long':
3521
- pnl = (price - entry_price) / entry_price
3522
- hit_stop = stop_loss is not None and pnl <= -stop_loss
3523
- hit_take = take_profit is not None and pnl >= take_profit
3524
-
3525
- if signal == -1 or hit_stop or hit_take:
3526
- trades.append({
3527
- 'Ticker': ticker,
3528
- 'Entry Date': entry_date,
3529
- 'Exit Date': exit_date,
3530
- 'Entry Price': entry_price,
3531
- 'Exit Price': price,
3532
- 'Return': pnl,
3533
- 'Exit Reason': (
3534
- 'Sell Signal' if signal == -1 else
3535
- 'Stop Loss' if hit_stop else
3536
- 'Take Profit'
3537
- )
3538
- })
3539
- position = None
3540
-
3541
- return pd.DataFrame(trades, columns=trade_columns)
3482
+ from riskoptima.backtest.sma import run_sma_strategy_with_risk
3483
+
3484
+ return run_sma_strategy_with_risk(
3485
+ ticker=ticker,
3486
+ start=start,
3487
+ end=end,
3488
+ stop_loss=stop_loss,
3489
+ take_profit=take_profit,
3490
+ )
3542
3491
 
3543
3492
  @staticmethod
3544
- def run_strategy_on_portfolio(asset_table: pd.DataFrame, start: str, end: str,
3545
- stop_loss: float = None, take_profit: float = None):
3546
- results = []
3547
- print(asset_table)
3548
- for _, row in asset_table.iterrows():
3549
- ticker = row['Asset']
3550
- weight = row['Weight']
3551
- trades_df = RiskOptima.run_sma_strategy_with_risk(
3552
- ticker, start, end,
3553
- stop_loss=stop_loss, take_profit=take_profit
3554
- )
3555
- trades_df['Weight'] = weight
3556
- trades_df['Weighted Return'] = trades_df['Return'] * weight if 'Return' in trades_df else pd.Series(dtype=float)
3557
- results.append(trades_df)
3558
-
3559
- if not results:
3560
- return pd.DataFrame(columns=[
3561
- 'Ticker', 'Entry Date', 'Exit Date', 'Entry Price', 'Exit Price',
3562
- 'Return', 'Exit Reason', 'Weight', 'Weighted Return'
3563
- ])
3493
+ def run_strategy_on_portfolio(asset_table: pd.DataFrame, start: str, end: str,
3494
+ stop_loss: float = None, take_profit: float = None):
3495
+ from riskoptima.backtest.sma import run_strategy_on_portfolio
3564
3496
 
3565
- all_trades = pd.concat(results, ignore_index=True)
3566
- if all_trades.empty:
3567
- return all_trades
3568
- all_trades = all_trades.sort_values(by='Entry Date')
3569
- return all_trades
3497
+ return run_strategy_on_portfolio(
3498
+ asset_table=asset_table,
3499
+ start=start,
3500
+ end=end,
3501
+ stop_loss=stop_loss,
3502
+ take_profit=take_profit,
3503
+ )
3570
3504
 
3571
3505
  @staticmethod
3572
3506
  def plot_sma_strategy_cumulative_return(trade_log: pd.DataFrame, title="Portfolio Return"):
3573
- if trade_log.empty:
3574
- fig, ax = plt.subplots(figsize=(20, 12))
3575
- plt.title(title)
3576
- plt.xlabel("Date")
3577
- plt.ylabel("Cumulative Return")
3578
- plt.grid(alpha=0.3)
3579
- plt.text(
3580
- 0.995, -0.20, f"Created by RiskOptima v{RiskOptima.VERSION}",
3581
- fontsize=12, color='gray', alpha=0.7, transform=ax.transAxes, ha='right'
3582
- )
3583
- plt.tight_layout()
3584
- plt.show()
3585
- return
3507
+ from riskoptima.backtest.sma import plot_sma_strategy_cumulative_return
3586
3508
 
3587
- trade_log = trade_log.sort_values('Exit Date').copy()
3588
- trade_log['Cumulative Return'] = (1 + trade_log['Weighted Return']).cumprod()
3589
-
3590
- fig, ax = plt.subplots(figsize=(20, 12))
3591
-
3592
- plt.plot(trade_log['Exit Date'], trade_log['Cumulative Return'], marker='o')
3593
- plt.title(title)
3594
- plt.xlabel("Date")
3595
- plt.ylabel("Cumulative Return")
3596
- plt.grid(alpha=0.3)
3597
-
3598
- plt.text(
3599
- 0.995, -0.20, f"Created by RiskOptima v{RiskOptima.VERSION}",
3600
- fontsize=12, color='gray', alpha=0.7, transform=ax.transAxes, ha='right'
3601
- )
3602
-
3603
- plt.tight_layout()
3604
-
3605
- plots_folder = "plots"
3606
-
3607
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
3608
-
3609
- if not os.path.exists(plots_folder):
3610
- os.makedirs(plots_folder)
3611
-
3612
- plot_path = os.path.join(plots_folder, f"riskoptima_sma_strategy_cum_ret_{timestamp}.png")
3613
-
3614
- plt.savefig(plot_path, dpi=150, bbox_inches='tight')
3615
- plt.show()
3509
+ return plot_sma_strategy_cumulative_return(trade_log=trade_log, title=title)
3616
3510
 
3617
3511
  @staticmethod
3618
- def plot_sma_strategy_trades(df: pd.DataFrame, ticker: str):
3619
- fig, ax = plt.subplots(figsize=(20, 12))
3620
- plt.plot(df.index, df['Close'], label='Close Price', alpha=0.5)
3621
- plt.plot(df.index, df['SMA20'], label='SMA20', alpha=0.8)
3622
- plt.plot(df.index, df['SMA50'], label='SMA50', alpha=0.8)
3623
-
3624
- plt.scatter(df.index[df['Signal'] == 1], df['Close'][df['Signal'] == 1],
3625
- marker='^', color='green', s=100, label='Buy Signal')
3626
- plt.scatter(df.index[df['Signal'] == -1], df['Close'][df['Signal'] == -1],
3627
- marker='v', color='red', s=100, label='Sell Signal')
3628
-
3629
- plt.title(f"{ticker} - SMA Strategy with Signals")
3630
- plt.xlabel("Date")
3631
- plt.ylabel("Price")
3632
- plt.legend()
3633
- plt.grid(alpha=0.3)
3634
-
3635
- plt.text(
3636
- 0.995, -0.20, f"Created by RiskOptima v{RiskOptima.VERSION}",
3637
- fontsize=12, color='gray', alpha=0.7, transform=ax.transAxes, ha='right'
3638
- )
3639
-
3640
- plt.tight_layout()
3641
-
3642
- plots_folder = "plots"
3643
-
3644
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
3645
-
3646
- if not os.path.exists(plots_folder):
3647
- os.makedirs(plots_folder)
3648
-
3649
- plot_path = os.path.join(plots_folder, f"riskoptima_sma_strategy_{timestamp}.png")
3650
-
3651
- plt.savefig(plot_path, dpi=150, bbox_inches='tight')
3652
-
3653
- plt.show()
3512
+ def plot_sma_strategy_trades(df: pd.DataFrame, ticker: str):
3513
+ from riskoptima.backtest.sma import plot_sma_strategy_trades
3514
+
3515
+ return plot_sma_strategy_trades(signal_frame=df, ticker=ticker)
3654
3516
 
3655
3517
  @staticmethod
3656
- def run_and_plot_sma_strategy(tickers, start_date, end_date, stop_loss=None, take_profit=None):
3657
- # Normalize input
3658
- if isinstance(tickers, str):
3659
- asset_table = pd.DataFrame([{"Asset": tickers, "Weight": 1.0}])
3660
- elif isinstance(tickers, list):
3661
- asset_table = pd.DataFrame([{"Asset": t, "Weight": 1.0 / len(tickers)} for t in tickers])
3662
- elif isinstance(tickers, pd.DataFrame):
3663
- asset_table = tickers.copy()
3664
- else:
3665
- raise ValueError("Tickers must be a string, list, or DataFrame.")
3666
-
3667
- # Run portfolio strategy
3668
- portfolio_trades = RiskOptima.run_strategy_on_portfolio(
3669
- asset_table, start=start_date, end=end_date,
3670
- stop_loss=stop_loss, take_profit=take_profit
3671
- )
3672
-
3673
- # If only one ticker, also show price chart with signals
3674
- if len(asset_table) == 1:
3675
- ticker = asset_table.iloc[0]['Asset']
3676
- df = yf.download(ticker, start=start_date, end=end_date, progress=False, auto_adjust=False)[['Close']]
3677
- df['SMA20'] = df['Close'].rolling(20).mean()
3678
- df['SMA50'] = df['Close'].rolling(50).mean()
3679
- df['Signal'] = 0
3680
- df.loc[df.index[50]:, 'Signal'] = (
3681
- (df['SMA20'][50:] > df['SMA50'][50:]) &
3682
- (df['SMA20'].shift(1)[50:] <= df['SMA50'].shift(1)[50:])
3683
- ).astype(int) - (
3684
- (df['SMA20'][50:] < df['SMA50'][50:]) &
3685
- (df['SMA20'].shift(1)[50:] >= df['SMA50'].shift(1)[50:])
3686
- ).astype(int)
3687
-
3688
- RiskOptima.plot_sma_strategy_trades(df, ticker)
3689
-
3690
- else:
3691
- for ticker in asset_table['Asset']:
3692
- df = yf.download(ticker, start=start_date, end=end_date, progress=False, auto_adjust=False)[['Close']]
3693
- df['SMA20'] = df['Close'].rolling(20).mean()
3694
- df['SMA50'] = df['Close'].rolling(50).mean()
3695
- df['Signal'] = 0
3696
- df.loc[df.index[50]:, 'Signal'] = (
3697
- (df['SMA20'][50:] > df['SMA50'][50:]) &
3698
- (df['SMA20'].shift(1)[50:] <= df['SMA50'].shift(1)[50:])
3699
- ).astype(int) - (
3700
- (df['SMA20'][50:] < df['SMA50'][50:]) &
3701
- (df['SMA20'].shift(1)[50:] >= df['SMA50'].shift(1)[50:])
3702
- ).astype(int)
3703
-
3704
- RiskOptima.plot_sma_strategy_trades(df, ticker)
3705
-
3706
- # Always plot cumulative return
3707
- RiskOptima.plot_sma_strategy_cumulative_return(portfolio_trades, title="SMA Strategy - Cumulative Return")
3708
-
3709
- return portfolio_trades
3518
+ def run_and_plot_sma_strategy(tickers, start_date, end_date, stop_loss=None, take_profit=None):
3519
+ from riskoptima.backtest.sma import run_and_plot_sma_strategy
3520
+
3521
+ return run_and_plot_sma_strategy(
3522
+ tickers=tickers,
3523
+ start_date=start_date,
3524
+ end_date=end_date,
3525
+ stop_loss=stop_loss,
3526
+ take_profit=take_profit,
3527
+ )
3710
3528
 
3711
3529
  @staticmethod
3712
3530
  def implied_volatility_screener(symbol='AMZN', lookback_days=30):
@@ -1,13 +0,0 @@
1
- ###############################################################################
2
- # __init__.py
3
- ###############################################################################
4
- # Product: RiskOptima
5
- # Author: Jordi Corbilla
6
- # Description: RiskOptima module
7
- ###############################################################################
8
-
9
- from .engine import run_backtest
10
- from .strategy import Strategy, SMACrossStrategy
11
- from .portfolio import PortfolioState
12
-
13
- __all__ = ["run_backtest", "Strategy", "SMACrossStrategy", "PortfolioState"]
File without changes