diff --git a/README.md b/README.md index 70f692ff..78dd97bd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Supported statistics/indicators are: **Momentum**: RSI, StochRSI, MACD, PPO, KDJ, ROC, CMO, KST, Coppock, AO, BOP, CTI, Inertia, PSL -**Trend**: Supertrend, Aroon, Ichimoku, CR, DMI (+DI/-DI/ADX/ADXR), TRIX, WT +**Trend**: Supertrend, Aroon, Ichimoku, CR, DMI (+DI/-DI/ADX/ADXR), TRIX, WT, SSL Channel **Volatility**: Bollinger Bands, ATR, TR, CCI, WR, CHOP, KER, Z-Score, MAD, PGO @@ -174,6 +174,7 @@ will be re-evaluated the next time you access them. | DMI | `pdi`, `ndi`, `adx` | 14 | Directional Movement Index | | TRIX | `trix` | 12 | Triple Exponential Average | | WT | `wt1`, `wt2` | 10, 21 | Wave Trend | +| SSL | `ssl`, `ssl_up`, `ssl_down` | 10 | SSL Channel | | Bollinger | `boll` | 20 | Bollinger Bands | | ATR | `atr` | 14 | Average True Range | | TR | `tr` | - | True Range | @@ -661,6 +662,26 @@ Retrieve the LazyBear's Wave Trend with `df['wt1']` and `df['wt2']`. Wave trend uses two parameters. You can tune them with `set_dft_window('wt', (10, 21))`. +#### [SSL Channel](https://www.tradingview.com/script/xzIoaIJC-SSL-channel/) + +The SSL Channel (Securement Stop Loss Channel), created by Erwin Beckers, is a +trend-following indicator that calculates dynamic support and resistance bands +using the simple moving averages of High and Low prices. The channel lines flip +positions based on whether the closing price crosses above the SMA of High +(bullish) or below the SMA of Low (bearish). + +It has a parameter for window size. The default window is 10. Change it with +`set_dft_window('ssl', n)`. + +Examples: +* `df['ssl']` retrieves the 10-period SSL Channel line (defaults to SSL Up) +* `df['ssl_up']` retrieves the 10-period SSL Up line +* `df['ssl_down']` retrieves the 10-period SSL Down line +* `df['ssl_up_5']` retrieves the 5-period SSL Up line +* `df['ssl_down_5']` retrieves the 5-period SSL Down line +* `df['ssl_up_xu_ssl_down']` detects bullish crossover (Buy signal) +* `df['ssl_up_xd_ssl_down']` detects bearish crossunder (Sell signal) + --- ### Volatility diff --git a/stockstats.py b/stockstats.py index 3b20acbf..5bdc58a1 100644 --- a/stockstats.py +++ b/stockstats.py @@ -82,6 +82,9 @@ class StockStatsError(Exception): "rsv": 9, "rvgi": 14, "stochrsi": 14, + "ssl": 10, + "ssl_down": 10, + "ssl_up": 10, "supertrend": 14, "tema": 5, "trix": 12, @@ -739,6 +742,30 @@ def _get_supertrend(self, meta: _Meta): self[f"{meta.name}_ub"] = final_ub self[f"{meta.name}_lb"] = final_lb + def _get_ssl(self, meta: _Meta): + """SSL Channel (Securement Stop Loss Channel) + + https://www.tradingview.com/script/xzIoaIJC-SSL-channel/ + Created by Erwin Beckers. + + Calculates dynamic support and resistance bands based on the + SMA of High and Low prices and the close price relative position. + """ + window = meta.as_int + ssl_up, ssl_down = self.ssl_channel( + self.high, self.low, self.close, window) + + suffix = f"_{meta.windows}" if meta._windows is not None else "" + self[f"ssl{suffix}"] = ssl_up + self[f"ssl_up{suffix}"] = ssl_up + self[f"ssl_down{suffix}"] = ssl_down + + def _get_ssl_up(self, meta: _Meta): + self._get_ssl(meta) + + def _get_ssl_down(self, meta: _Meta): + self._get_ssl(meta) + @staticmethod def _rolling_arg_index(arr, window, mode="max"): n = len(arr) @@ -1219,6 +1246,56 @@ def hma(cls, series: pd.Series, window: int) -> pd.Series: return pd.Series(np.zeros(n), index=series.index, name=series.name) + @classmethod + def ssl_channel(cls, high: pd.Series, low: pd.Series, close: pd.Series, + window: int = 10) -> tuple[pd.Series, pd.Series]: + """SSL Channel (Securement Stop Loss Channel) + + Created by Erwin Beckers. Uses SMA of High and Low prices to track + trend direction and dynamic support/resistance channel bands. + + Formula: + sma_high = SMA(high, window) + sma_low = SMA(low, window) + hlv[i] = 1 if close[i] > sma_high[i] else ( + -1 if close[i] < sma_low[i] else hlv[i-1]) + ssl_down = sma_high if hlv < 0 else sma_low + ssl_up = sma_low if hlv < 0 else sma_high + + :param high: series of high prices + :param low: series of low prices + :param close: series of close prices + :param window: lookback period (default 10) + :return: (ssl_up, ssl_down) tuple of Series + """ + window = cls.get_int_positive(window) + n = len(close) + if n == 0: + empty = pd.Series(dtype=float, index=close.index) + return empty.copy(), empty.copy() + + sma_high = cls.sma(high, window).values + sma_low = cls.sma(low, window).values + close_arr = close.values + + hlv = np.zeros(n, dtype=int) + hlv[0] = 1 if close_arr[0] >= sma_high[0] else ( + -1 if close_arr[0] < sma_low[0] else 1) + + for i in range(1, n): + if close_arr[i] > sma_high[i]: + hlv[i] = 1 + elif close_arr[i] < sma_low[i]: + hlv[i] = -1 + else: + hlv[i] = hlv[i - 1] + + ssl_up = np.where(hlv < 0, sma_low, sma_high) + ssl_down = np.where(hlv < 0, sma_high, sma_low) + + return (pd.Series(ssl_up, index=close.index, name="ssl_up"), + pd.Series(ssl_down, index=close.index, name="ssl_down")) + @classmethod def linear_reg(cls, series, window, correlation=False): window = cls.get_int_positive(window) @@ -2143,6 +2220,7 @@ def handler(self): ("wt1", "wt2"): self._get_wt, ("supertrend", "supertrend_lb", "supertrend_ub"): self._get_supertrend, + ("ssl", "ssl_up", "ssl_down"): self._get_ssl, ("bop",): self._get_bop, ("cti",): self._get_cti, ("eribull", "eribear"): self._get_eri, diff --git a/test.py b/test.py index 5c4b1afe..b9b70aae 100644 --- a/test.py +++ b/test.py @@ -1143,6 +1143,58 @@ def test_hma_edge_cases(): h_large = StockDataFrame.hma(series, 10) assert_that(list(h_large), equal_to([0.0] * 6)) + def test_ssl_channel(self): + stock = self.get_stock_30days() + ssl = stock["ssl"] + ssl_up = stock["ssl_up"] + ssl_down = stock["ssl_down"] + assert_that(list(ssl), equal_to(list(ssl_up))) + + # Initial bullish state + assert_that(ssl_up[20110104], near_to(12.6800)) + assert_that(ssl_down[20110104], near_to(12.3000)) + assert_that(ssl_up[20110114], near_to(13.2322)) + assert_that(ssl_down[20110114], near_to(12.8500)) + + # Bearish flip on 20110117 (close drops below sma_low) + assert_that(ssl_up[20110117], near_to(12.8060)) + assert_that(ssl_down[20110117], near_to(13.2190)) + assert_that(ssl_up[20110128], near_to(12.5660)) + assert_that(ssl_down[20110128], near_to(12.9140)) + + # Crossunder detection (sell signal) + sell_cross = stock["ssl_up_xd_ssl_down"] + assert_that(sell_cross[20110117], equal_to(True)) + + # Default window equivalence + assert_that(list(stock["ssl_up_10"]), equal_to(list(ssl_up))) + assert_that(list(stock["ssl_down_10"]), equal_to(list(ssl_down))) + + # Custom window + u5 = stock["ssl_up_5"] + d5 = stock["ssl_down_5"] + assert_that(u5[20110104], near_to(12.6800)) + assert_that(d5[20110104], near_to(12.3000)) + assert_that(u5[20110114], near_to(13.4200)) + assert_that(d5[20110114], near_to(13.1080)) + assert_that(u5[20110117], near_to(13.0040)) + assert_that(d5[20110117], near_to(13.3720)) + + @staticmethod + def test_ssl_channel_edge_cases(): + # Empty series + empty_up, empty_down = StockDataFrame.ssl_channel( + pd.Series(dtype=float), pd.Series(dtype=float), + pd.Series(dtype=float), 5) + assert_that(len(empty_up), equal_to(0)) + assert_that(len(empty_down), equal_to(0)) + + # Single-row series + s_up, s_down = StockDataFrame.ssl_channel( + pd.Series([10.5]), pd.Series([9.5]), pd.Series([10.0]), 3) + assert_that(list(s_up), equal_to([10.5])) + assert_that(list(s_down), equal_to([9.5])) + def test_coppock(self): stock = self.get_stock_90days() c0 = stock["coppock"]