""" Custom indicator implementations for TradingView indicators not in TA-Lib. These indicators follow TA-Lib style conventions and integrate seamlessly with the indicator framework. All implementations are based on well-known, publicly documented formulas. """ import logging from typing import List, Optional import numpy as np from datasource.schema import ColumnInfo from .base import Indicator from .schema import ( ComputeContext, ComputeResult, IndicatorMetadata, IndicatorParameter, InputSchema, OutputSchema, ) logger = logging.getLogger(__name__) class VWAP(Indicator): """Volume Weighted Average Price - Most widely used institutional indicator.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="VWAP", display_name="VWAP", description="Volume Weighted Average Price - Average price weighted by volume", category="volume", parameters=[], use_cases=[ "Institutional reference price", "Support/resistance levels", "Mean reversion trading" ], references=["https://www.investopedia.com/terms/v/vwap.asp"], tags=["vwap", "volume", "institutional"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ColumnInfo(name="close", type="float", description="Close price"), ColumnInfo(name="volume", type="float", description="Volume"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="vwap", type="float", description="Volume Weighted Average Price", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) close = np.array([float(v) if v is not None else np.nan for v in context.get_column("close")]) volume = np.array([float(v) if v is not None else np.nan for v in context.get_column("volume")]) # Typical price typical_price = (high + low + close) / 3.0 # VWAP = cumsum(typical_price * volume) / cumsum(volume) cumulative_tp_vol = np.nancumsum(typical_price * volume) cumulative_vol = np.nancumsum(volume) vwap = cumulative_tp_vol / cumulative_vol times = context.get_times() result_data = [ {"time": times[i], "vwap": float(vwap[i]) if not np.isnan(vwap[i]) else None} for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class VWMA(Indicator): """Volume Weighted Moving Average.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="VWMA", display_name="VWMA", description="Volume Weighted Moving Average - Moving average weighted by volume", category="overlap", parameters=[ IndicatorParameter( name="length", type="int", description="Period length", default=20, min_value=1, required=False ) ], use_cases=["Volume-aware trend following", "Dynamic support/resistance"], references=["https://www.investopedia.com/articles/trading/11/trading-with-vwap-mvwap.asp"], tags=["vwma", "volume", "moving average"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="close", type="float", description="Close price"), ColumnInfo(name="volume", type="float", description="Volume"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="vwma", type="float", description="Volume Weighted Moving Average", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: close = np.array([float(v) if v is not None else np.nan for v in context.get_column("close")]) volume = np.array([float(v) if v is not None else np.nan for v in context.get_column("volume")]) length = self.params.get("length", 20) vwma = np.full_like(close, np.nan) for i in range(length - 1, len(close)): window_close = close[i - length + 1:i + 1] window_volume = volume[i - length + 1:i + 1] vwma[i] = np.sum(window_close * window_volume) / np.sum(window_volume) times = context.get_times() result_data = [ {"time": times[i], "vwma": float(vwma[i]) if not np.isnan(vwma[i]) else None} for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class HullMA(Indicator): """Hull Moving Average - Fast and smooth moving average.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="HMA", display_name="Hull Moving Average", description="Hull Moving Average - Reduces lag while maintaining smoothness", category="overlap", parameters=[ IndicatorParameter( name="length", type="int", description="Period length", default=9, min_value=1, required=False ) ], use_cases=["Low-lag trend following", "Quick trend reversal detection"], references=["https://alanhull.com/hull-moving-average"], tags=["hma", "hull", "moving average", "low-lag"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="close", type="float", description="Close price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="hma", type="float", description="Hull Moving Average", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: close = np.array([float(v) if v is not None else np.nan for v in context.get_column("close")]) length = self.params.get("length", 9) def wma(data, period): """Weighted Moving Average.""" weights = np.arange(1, period + 1) result = np.full_like(data, np.nan) for i in range(period - 1, len(data)): window = data[i - period + 1:i + 1] result[i] = np.sum(weights * window) / np.sum(weights) return result # HMA = WMA(2 * WMA(n/2) - WMA(n)), sqrt(n)) half_length = length // 2 sqrt_length = int(np.sqrt(length)) wma_half = wma(close, half_length) wma_full = wma(close, length) raw_hma = 2 * wma_half - wma_full hma = wma(raw_hma, sqrt_length) times = context.get_times() result_data = [ {"time": times[i], "hma": float(hma[i]) if not np.isnan(hma[i]) else None} for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class SuperTrend(Indicator): """SuperTrend - Popular trend following indicator.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="SUPERTREND", display_name="SuperTrend", description="SuperTrend - Volatility-based trend indicator", category="overlap", parameters=[ IndicatorParameter( name="length", type="int", description="ATR period", default=10, min_value=1, required=False ), IndicatorParameter( name="multiplier", type="float", description="ATR multiplier", default=3.0, min_value=0.1, required=False ) ], use_cases=["Trend identification", "Stop loss placement", "Trend reversal signals"], references=["https://www.investopedia.com/articles/trading/08/supertrend-indicator.asp"], tags=["supertrend", "trend", "volatility"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ColumnInfo(name="close", type="float", description="Close price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="supertrend", type="float", description="SuperTrend value", nullable=True), ColumnInfo(name="direction", type="int", description="Trend direction (1=up, -1=down)", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) close = np.array([float(v) if v is not None else np.nan for v in context.get_column("close")]) length = self.params.get("length", 10) multiplier = self.params.get("multiplier", 3.0) # Calculate ATR tr = np.maximum(high - low, np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1)))) tr[0] = high[0] - low[0] atr = np.full_like(close, np.nan) atr[length - 1] = np.mean(tr[:length]) for i in range(length, len(tr)): atr[i] = (atr[i - 1] * (length - 1) + tr[i]) / length # Calculate basic bands hl2 = (high + low) / 2 basic_upper = hl2 + multiplier * atr basic_lower = hl2 - multiplier * atr # Calculate final bands final_upper = np.full_like(close, np.nan) final_lower = np.full_like(close, np.nan) supertrend = np.full_like(close, np.nan) direction = np.full_like(close, np.nan) for i in range(length, len(close)): if i == length: final_upper[i] = basic_upper[i] final_lower[i] = basic_lower[i] else: final_upper[i] = basic_upper[i] if basic_upper[i] < final_upper[i - 1] or close[i - 1] > final_upper[i - 1] else final_upper[i - 1] final_lower[i] = basic_lower[i] if basic_lower[i] > final_lower[i - 1] or close[i - 1] < final_lower[i - 1] else final_lower[i - 1] if i == length: supertrend[i] = final_upper[i] if close[i] <= hl2[i] else final_lower[i] direction[i] = -1 if close[i] <= hl2[i] else 1 else: if supertrend[i - 1] == final_upper[i - 1] and close[i] <= final_upper[i]: supertrend[i] = final_upper[i] direction[i] = -1 elif supertrend[i - 1] == final_upper[i - 1] and close[i] > final_upper[i]: supertrend[i] = final_lower[i] direction[i] = 1 elif supertrend[i - 1] == final_lower[i - 1] and close[i] >= final_lower[i]: supertrend[i] = final_lower[i] direction[i] = 1 else: supertrend[i] = final_upper[i] direction[i] = -1 times = context.get_times() result_data = [ { "time": times[i], "supertrend": float(supertrend[i]) if not np.isnan(supertrend[i]) else None, "direction": int(direction[i]) if not np.isnan(direction[i]) else None } for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class DonchianChannels(Indicator): """Donchian Channels - Breakout indicator using highest high and lowest low.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="DONCHIAN", display_name="Donchian Channels", description="Donchian Channels - Highest high and lowest low over period", category="overlap", parameters=[ IndicatorParameter( name="length", type="int", description="Period length", default=20, min_value=1, required=False ) ], use_cases=["Breakout trading", "Volatility bands", "Support/resistance"], references=["https://www.investopedia.com/terms/d/donchianchannels.asp"], tags=["donchian", "channels", "breakout"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="upper", type="float", description="Upper channel", nullable=True), ColumnInfo(name="middle", type="float", description="Middle line", nullable=True), ColumnInfo(name="lower", type="float", description="Lower channel", nullable=True), ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) length = self.params.get("length", 20) upper = np.full_like(high, np.nan) lower = np.full_like(low, np.nan) for i in range(length - 1, len(high)): upper[i] = np.nanmax(high[i - length + 1:i + 1]) lower[i] = np.nanmin(low[i - length + 1:i + 1]) middle = (upper + lower) / 2 times = context.get_times() result_data = [ { "time": times[i], "upper": float(upper[i]) if not np.isnan(upper[i]) else None, "middle": float(middle[i]) if not np.isnan(middle[i]) else None, "lower": float(lower[i]) if not np.isnan(lower[i]) else None, } for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class KeltnerChannels(Indicator): """Keltner Channels - ATR-based volatility bands.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="KELTNER", display_name="Keltner Channels", description="Keltner Channels - EMA with ATR-based bands", category="volatility", parameters=[ IndicatorParameter( name="length", type="int", description="EMA period", default=20, min_value=1, required=False ), IndicatorParameter( name="multiplier", type="float", description="ATR multiplier", default=2.0, min_value=0.1, required=False ), IndicatorParameter( name="atr_length", type="int", description="ATR period", default=10, min_value=1, required=False ) ], use_cases=["Volatility bands", "Overbought/oversold", "Trend strength"], references=["https://www.investopedia.com/terms/k/keltnerchannel.asp"], tags=["keltner", "channels", "volatility", "atr"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ColumnInfo(name="close", type="float", description="Close price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="upper", type="float", description="Upper band", nullable=True), ColumnInfo(name="middle", type="float", description="Middle line (EMA)", nullable=True), ColumnInfo(name="lower", type="float", description="Lower band", nullable=True), ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) close = np.array([float(v) if v is not None else np.nan for v in context.get_column("close")]) length = self.params.get("length", 20) multiplier = self.params.get("multiplier", 2.0) atr_length = self.params.get("atr_length", 10) # Calculate EMA alpha = 2.0 / (length + 1) ema = np.full_like(close, np.nan) ema[0] = close[0] for i in range(1, len(close)): ema[i] = alpha * close[i] + (1 - alpha) * ema[i - 1] # Calculate ATR tr = np.maximum(high - low, np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1)))) tr[0] = high[0] - low[0] atr = np.full_like(close, np.nan) atr[atr_length - 1] = np.mean(tr[:atr_length]) for i in range(atr_length, len(tr)): atr[i] = (atr[i - 1] * (atr_length - 1) + tr[i]) / atr_length upper = ema + multiplier * atr lower = ema - multiplier * atr times = context.get_times() result_data = [ { "time": times[i], "upper": float(upper[i]) if not np.isnan(upper[i]) else None, "middle": float(ema[i]) if not np.isnan(ema[i]) else None, "lower": float(lower[i]) if not np.isnan(lower[i]) else None, } for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class ChaikinMoneyFlow(Indicator): """Chaikin Money Flow - Volume-weighted accumulation/distribution.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="CMF", display_name="Chaikin Money Flow", description="Chaikin Money Flow - Measures buying and selling pressure", category="volume", parameters=[ IndicatorParameter( name="length", type="int", description="Period length", default=20, min_value=1, required=False ) ], use_cases=["Buying/selling pressure", "Trend confirmation", "Divergence analysis"], references=["https://www.investopedia.com/terms/c/chaikinoscillator.asp"], tags=["cmf", "chaikin", "volume", "money flow"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ColumnInfo(name="close", type="float", description="Close price"), ColumnInfo(name="volume", type="float", description="Volume"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="cmf", type="float", description="Chaikin Money Flow", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) close = np.array([float(v) if v is not None else np.nan for v in context.get_column("close")]) volume = np.array([float(v) if v is not None else np.nan for v in context.get_column("volume")]) length = self.params.get("length", 20) # Money Flow Multiplier mfm = ((close - low) - (high - close)) / (high - low) mfm = np.where(high == low, 0, mfm) # Money Flow Volume mfv = mfm * volume # CMF cmf = np.full_like(close, np.nan) for i in range(length - 1, len(close)): cmf[i] = np.nansum(mfv[i - length + 1:i + 1]) / np.nansum(volume[i - length + 1:i + 1]) times = context.get_times() result_data = [ {"time": times[i], "cmf": float(cmf[i]) if not np.isnan(cmf[i]) else None} for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class VortexIndicator(Indicator): """Vortex Indicator - Identifies trend direction and strength.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="VORTEX", display_name="Vortex Indicator", description="Vortex Indicator - Trend direction and strength", category="momentum", parameters=[ IndicatorParameter( name="length", type="int", description="Period length", default=14, min_value=1, required=False ) ], use_cases=["Trend identification", "Trend reversals", "Trend strength"], references=["https://www.investopedia.com/terms/v/vortex-indicator-vi.asp"], tags=["vortex", "trend", "momentum"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ColumnInfo(name="close", type="float", description="Close price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="vi_plus", type="float", description="Positive Vortex", nullable=True), ColumnInfo(name="vi_minus", type="float", description="Negative Vortex", nullable=True), ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) close = np.array([float(v) if v is not None else np.nan for v in context.get_column("close")]) length = self.params.get("length", 14) # Vortex Movement vm_plus = np.abs(high - np.roll(low, 1)) vm_minus = np.abs(low - np.roll(high, 1)) vm_plus[0] = 0 vm_minus[0] = 0 # True Range tr = np.maximum(high - low, np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1)))) tr[0] = high[0] - low[0] # Vortex Indicator vi_plus = np.full_like(close, np.nan) vi_minus = np.full_like(close, np.nan) for i in range(length - 1, len(close)): sum_vm_plus = np.sum(vm_plus[i - length + 1:i + 1]) sum_vm_minus = np.sum(vm_minus[i - length + 1:i + 1]) sum_tr = np.sum(tr[i - length + 1:i + 1]) if sum_tr != 0: vi_plus[i] = sum_vm_plus / sum_tr vi_minus[i] = sum_vm_minus / sum_tr times = context.get_times() result_data = [ { "time": times[i], "vi_plus": float(vi_plus[i]) if not np.isnan(vi_plus[i]) else None, "vi_minus": float(vi_minus[i]) if not np.isnan(vi_minus[i]) else None, } for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class AwesomeOscillator(Indicator): """Awesome Oscillator - Bill Williams' momentum indicator.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="AO", display_name="Awesome Oscillator", description="Awesome Oscillator - Difference between 5 and 34 period SMAs of midpoint", category="momentum", parameters=[], use_cases=["Momentum shifts", "Trend reversals", "Divergence trading"], references=["https://www.investopedia.com/terms/a/awesomeoscillator.asp"], tags=["awesome", "oscillator", "momentum", "williams"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="ao", type="float", description="Awesome Oscillator", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) midpoint = (high + low) / 2 # SMA 5 sma5 = np.full_like(midpoint, np.nan) for i in range(4, len(midpoint)): sma5[i] = np.mean(midpoint[i - 4:i + 1]) # SMA 34 sma34 = np.full_like(midpoint, np.nan) for i in range(33, len(midpoint)): sma34[i] = np.mean(midpoint[i - 33:i + 1]) ao = sma5 - sma34 times = context.get_times() result_data = [ {"time": times[i], "ao": float(ao[i]) if not np.isnan(ao[i]) else None} for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class AcceleratorOscillator(Indicator): """Accelerator Oscillator - Rate of change of Awesome Oscillator.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="AC", display_name="Accelerator Oscillator", description="Accelerator Oscillator - Rate of change of Awesome Oscillator", category="momentum", parameters=[], use_cases=["Early momentum detection", "Trend acceleration", "Divergence signals"], references=["https://www.investopedia.com/terms/a/accelerator-oscillator.asp"], tags=["accelerator", "oscillator", "momentum", "williams"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="ac", type="float", description="Accelerator Oscillator", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) midpoint = (high + low) / 2 # Calculate AO first sma5 = np.full_like(midpoint, np.nan) for i in range(4, len(midpoint)): sma5[i] = np.mean(midpoint[i - 4:i + 1]) sma34 = np.full_like(midpoint, np.nan) for i in range(33, len(midpoint)): sma34[i] = np.mean(midpoint[i - 33:i + 1]) ao = sma5 - sma34 # AC = AO - SMA(AO, 5) sma_ao = np.full_like(ao, np.nan) for i in range(4, len(ao)): if not np.isnan(ao[i - 4:i + 1]).any(): sma_ao[i] = np.mean(ao[i - 4:i + 1]) ac = ao - sma_ao times = context.get_times() result_data = [ {"time": times[i], "ac": float(ac[i]) if not np.isnan(ac[i]) else None} for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class ChoppinessIndex(Indicator): """Choppiness Index - Determines if market is choppy or trending.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="CHOP", display_name="Choppiness Index", description="Choppiness Index - Measures market trendiness vs consolidation", category="volatility", parameters=[ IndicatorParameter( name="length", type="int", description="Period length", default=14, min_value=1, required=False ) ], use_cases=["Trend vs range identification", "Market regime detection"], references=["https://www.tradingview.com/support/solutions/43000501980/"], tags=["chop", "choppiness", "trend", "range"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ColumnInfo(name="close", type="float", description="Close price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="chop", type="float", description="Choppiness Index (0-100)", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) close = np.array([float(v) if v is not None else np.nan for v in context.get_column("close")]) length = self.params.get("length", 14) # True Range tr = np.maximum(high - low, np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1)))) tr[0] = high[0] - low[0] chop = np.full_like(close, np.nan) for i in range(length - 1, len(close)): sum_tr = np.sum(tr[i - length + 1:i + 1]) high_low_diff = np.max(high[i - length + 1:i + 1]) - np.min(low[i - length + 1:i + 1]) if high_low_diff != 0: chop[i] = 100 * np.log10(sum_tr / high_low_diff) / np.log10(length) times = context.get_times() result_data = [ {"time": times[i], "chop": float(chop[i]) if not np.isnan(chop[i]) else None} for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) class MassIndex(Indicator): """Mass Index - Identifies trend reversals based on range expansion.""" @classmethod def get_metadata(cls) -> IndicatorMetadata: return IndicatorMetadata( name="MASS", display_name="Mass Index", description="Mass Index - Identifies reversals when range narrows then expands", category="volatility", parameters=[ IndicatorParameter( name="fast_period", type="int", description="Fast EMA period", default=9, min_value=1, required=False ), IndicatorParameter( name="slow_period", type="int", description="Slow EMA period", default=25, min_value=1, required=False ) ], use_cases=["Reversal detection", "Volatility analysis", "Bulge identification"], references=["https://www.investopedia.com/terms/m/mass-index.asp"], tags=["mass", "index", "volatility", "reversal"] ) @classmethod def get_input_schema(cls) -> InputSchema: return InputSchema(required_columns=[ ColumnInfo(name="high", type="float", description="High price"), ColumnInfo(name="low", type="float", description="Low price"), ]) @classmethod def get_output_schema(cls, **params) -> OutputSchema: return OutputSchema(columns=[ ColumnInfo(name="mass", type="float", description="Mass Index", nullable=True) ]) def compute(self, context: ComputeContext) -> ComputeResult: high = np.array([float(v) if v is not None else np.nan for v in context.get_column("high")]) low = np.array([float(v) if v is not None else np.nan for v in context.get_column("low")]) fast_period = self.params.get("fast_period", 9) slow_period = self.params.get("slow_period", 25) hl_range = high - low # Single EMA alpha1 = 2.0 / (fast_period + 1) ema1 = np.full_like(hl_range, np.nan) ema1[0] = hl_range[0] for i in range(1, len(hl_range)): ema1[i] = alpha1 * hl_range[i] + (1 - alpha1) * ema1[i - 1] # Double EMA ema2 = np.full_like(ema1, np.nan) ema2[0] = ema1[0] for i in range(1, len(ema1)): if not np.isnan(ema1[i]): ema2[i] = alpha1 * ema1[i] + (1 - alpha1) * ema2[i - 1] # EMA Ratio ema_ratio = ema1 / ema2 # Mass Index mass = np.full_like(hl_range, np.nan) for i in range(slow_period - 1, len(ema_ratio)): mass[i] = np.nansum(ema_ratio[i - slow_period + 1:i + 1]) times = context.get_times() result_data = [ {"time": times[i], "mass": float(mass[i]) if not np.isnan(mass[i]) else None} for i in range(len(times)) ] return ComputeResult(data=result_data, is_partial=context.is_incremental) # Registry of all custom indicators CUSTOM_INDICATORS = [ VWAP, VWMA, HullMA, SuperTrend, DonchianChannels, KeltnerChannels, ChaikinMoneyFlow, VortexIndicator, AwesomeOscillator, AcceleratorOscillator, ChoppinessIndex, MassIndex, ] def register_custom_indicators(registry) -> int: """ Register all custom indicators with the registry. Args: registry: IndicatorRegistry instance Returns: Number of indicators registered """ registered_count = 0 for indicator_class in CUSTOM_INDICATORS: try: registry.register(indicator_class) registered_count += 1 logger.debug(f"Registered custom indicator: {indicator_class.__name__}") except Exception as e: logger.warning(f"Failed to register custom indicator {indicator_class.__name__}: {e}") logger.info(f"Registered {registered_count} custom indicators") return registered_count