955 lines
36 KiB
Python
955 lines
36 KiB
Python
"""
|
|
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
|