From a50955558ee66a021a4ab9e2afb9be0e159bcdb6 Mon Sep 17 00:00:00 2001 From: Tim Olson Date: Wed, 4 Mar 2026 03:28:09 -0400 Subject: [PATCH] indicator integration --- backend/src/agent/tools/indicator_tools.py | 270 ++++- backend/src/indicator/__init__.py | 7 + backend/src/indicator/custom_indicators.py | 954 ++++++++++++++++++ backend/src/indicator/talib_adapter.py | 17 +- backend/src/indicator/tv_mapping.py | 360 +++++++ backend/src/main.py | 16 +- backend/src/schema/indicator.py | 40 + backend/src/sync/registry.py | 42 +- web/src/App.vue | 5 +- web/src/components/ChartView.vue | 94 ++ web/src/components/LoginScreen.vue | 10 +- web/src/composables/useStateSync.ts | 6 + .../composables/useTradingViewIndicators.ts | 905 +++++++++++++++++ web/src/stores/indicators.ts | 64 ++ 14 files changed, 2777 insertions(+), 13 deletions(-) create mode 100644 backend/src/indicator/custom_indicators.py create mode 100644 backend/src/indicator/tv_mapping.py create mode 100644 backend/src/schema/indicator.py create mode 100644 web/src/composables/useTradingViewIndicators.ts create mode 100644 web/src/stores/indicators.ts diff --git a/backend/src/agent/tools/indicator_tools.py b/backend/src/agent/tools/indicator_tools.py index 939876c..1e4f493 100644 --- a/backend/src/agent/tools/indicator_tools.py +++ b/backend/src/agent/tools/indicator_tools.py @@ -1,7 +1,18 @@ -"""Technical indicator tools.""" +"""Technical indicator tools. + +These tools allow the agent to: +1. Discover available indicators (list, search, get info) +2. Add indicators to the chart +3. Update/remove indicators +4. Query currently applied indicators +""" from typing import Dict, Any, List, Optional from langchain_core.tools import tool +import logging +import time + +logger = logging.getLogger(__name__) def _get_indicator_registry(): @@ -10,6 +21,20 @@ def _get_indicator_registry(): return _indicator_registry +def _get_registry(): + """Get the global sync registry instance.""" + from . import _registry + return _registry + + +def _get_indicator_store(): + """Get the global IndicatorStore instance.""" + registry = _get_registry() + if registry and "IndicatorStore" in registry.entries: + return registry.entries["IndicatorStore"].model + return None + + @tool def list_indicators() -> List[str]: """List all available technical indicators. @@ -161,9 +186,250 @@ def get_indicator_categories() -> Dict[str, int]: return categories +@tool +async def add_indicator_to_chart( + indicator_id: str, + talib_name: str, + parameters: Optional[Dict[str, Any]] = None, + symbol: Optional[str] = None +) -> Dict[str, Any]: + """Add a technical indicator to the chart. + + This will create a new indicator instance and display it on the TradingView chart. + The indicator will be synchronized with the frontend in real-time. + + Args: + indicator_id: Unique identifier for this indicator instance (e.g., 'rsi_14', 'sma_50') + talib_name: Name of the TA-Lib indicator (e.g., 'RSI', 'SMA', 'MACD', 'BBANDS') + Use search_indicators() or get_indicator_info() to find available indicators + parameters: Optional dictionary of indicator parameters + Example for RSI: {'timeperiod': 14} + Example for SMA: {'timeperiod': 50} + Example for MACD: {'fastperiod': 12, 'slowperiod': 26, 'signalperiod': 9} + Example for BBANDS: {'timeperiod': 20, 'nbdevup': 2, 'nbdevdn': 2} + symbol: Optional symbol to apply the indicator to (defaults to current chart symbol) + + Returns: + Dictionary with: + - status: 'created' or 'updated' + - indicator: The complete indicator object + + Example: + # Add RSI(14) + await add_indicator_to_chart( + indicator_id='rsi_14', + talib_name='RSI', + parameters={'timeperiod': 14} + ) + + # Add 50-period SMA + await add_indicator_to_chart( + indicator_id='sma_50', + talib_name='SMA', + parameters={'timeperiod': 50} + ) + + # Add MACD with default parameters + await add_indicator_to_chart( + indicator_id='macd_default', + talib_name='MACD' + ) + """ + from schema.indicator import IndicatorInstance + + registry = _get_registry() + if not registry: + raise ValueError("SyncRegistry not initialized") + + indicator_store = _get_indicator_store() + if not indicator_store: + raise ValueError("IndicatorStore not initialized") + + # Verify the indicator exists + indicator_registry = _get_indicator_registry() + if not indicator_registry: + raise ValueError("IndicatorRegistry not initialized") + + metadata = indicator_registry.get_metadata(talib_name) + if not metadata: + raise ValueError( + f"Indicator '{talib_name}' not found. " + f"Use search_indicators() to find available indicators." + ) + + # Check if updating existing indicator + existing_indicator = indicator_store.indicators.get(indicator_id) + is_update = existing_indicator is not None + + # If symbol is not provided, try to get it from ChartStore + if symbol is None and "ChartStore" in registry.entries: + chart_store = registry.entries["ChartStore"].model + if hasattr(chart_store, 'chart_state') and hasattr(chart_store.chart_state, 'symbol'): + symbol = chart_store.chart_state.symbol + logger.info(f"Using current chart symbol for indicator: {symbol}") + + now = int(time.time()) + + # Create indicator instance + indicator = IndicatorInstance( + id=indicator_id, + talib_name=talib_name, + instance_name=f"{talib_name}_{indicator_id}", + parameters=parameters or {}, + visible=True, + pane='chart', # Most indicators go on the chart pane + symbol=symbol, + created_at=existing_indicator.get('created_at') if existing_indicator else now, + modified_at=now + ) + + # Update the store + indicator_store.indicators[indicator_id] = indicator.model_dump(mode="json") + + # Trigger sync + await registry.push_all() + + logger.info( + f"{'Updated' if is_update else 'Created'} indicator '{indicator_id}' " + f"(TA-Lib: {talib_name}) with parameters: {parameters}" + ) + + return { + "status": "updated" if is_update else "created", + "indicator": indicator.model_dump(mode="json") + } + + +@tool +async def remove_indicator_from_chart(indicator_id: str) -> Dict[str, str]: + """Remove an indicator from the chart. + + Args: + indicator_id: ID of the indicator instance to remove + + Returns: + Dictionary with status message + + Raises: + ValueError: If indicator doesn't exist + + Example: + await remove_indicator_from_chart('rsi_14') + """ + registry = _get_registry() + if not registry: + raise ValueError("SyncRegistry not initialized") + + indicator_store = _get_indicator_store() + if not indicator_store: + raise ValueError("IndicatorStore not initialized") + + if indicator_id not in indicator_store.indicators: + raise ValueError(f"Indicator '{indicator_id}' not found") + + # Delete the indicator + del indicator_store.indicators[indicator_id] + + # Trigger sync + await registry.push_all() + + logger.info(f"Removed indicator '{indicator_id}'") + + return { + "status": "success", + "message": f"Indicator '{indicator_id}' removed" + } + + +@tool +def list_chart_indicators(symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """List all indicators currently applied to the chart. + + Args: + symbol: Optional filter by symbol (defaults to current chart symbol) + + Returns: + List of indicator instances, each containing: + - id: Indicator instance ID + - talib_name: TA-Lib indicator name + - instance_name: Display name + - parameters: Current parameter values + - visible: Whether indicator is visible + - pane: Which pane it's displayed in + - symbol: Symbol it's applied to + + Example: + # List all indicators on current symbol + indicators = list_chart_indicators() + + # List indicators on specific symbol + btc_indicators = list_chart_indicators(symbol='BINANCE:BTC/USDT') + """ + indicator_store = _get_indicator_store() + if not indicator_store: + raise ValueError("IndicatorStore not initialized") + + logger.info(f"list_chart_indicators: Raw store indicators: {indicator_store.indicators}") + + # If symbol is not provided, try to get it from ChartStore + if symbol is None: + registry = _get_registry() + if registry and "ChartStore" in registry.entries: + chart_store = registry.entries["ChartStore"].model + if hasattr(chart_store, 'chart_state') and hasattr(chart_store.chart_state, 'symbol'): + symbol = chart_store.chart_state.symbol + + indicators = list(indicator_store.indicators.values()) + + logger.info(f"list_chart_indicators: Converted to list: {indicators}") + logger.info(f"list_chart_indicators: Filtering by symbol: {symbol}") + + # Filter by symbol if provided + if symbol: + indicators = [ind for ind in indicators if ind.get('symbol') == symbol] + + logger.info(f"list_chart_indicators: Returning {len(indicators)} indicators") + return indicators + + +@tool +def get_chart_indicator(indicator_id: str) -> Dict[str, Any]: + """Get details of a specific indicator on the chart. + + Args: + indicator_id: ID of the indicator instance + + Returns: + Dictionary containing the indicator data + + Raises: + ValueError: If indicator doesn't exist + + Example: + indicator = get_chart_indicator('rsi_14') + print(f"Indicator: {indicator['talib_name']}") + print(f"Parameters: {indicator['parameters']}") + """ + indicator_store = _get_indicator_store() + if not indicator_store: + raise ValueError("IndicatorStore not initialized") + + indicator = indicator_store.indicators.get(indicator_id) + if not indicator: + raise ValueError(f"Indicator '{indicator_id}' not found") + + return indicator + + INDICATOR_TOOLS = [ + # Discovery tools list_indicators, get_indicator_info, search_indicators, - get_indicator_categories + get_indicator_categories, + # Chart indicator management tools + add_indicator_to_chart, + remove_indicator_from_chart, + list_chart_indicators, + get_chart_indicator ] diff --git a/backend/src/indicator/__init__.py b/backend/src/indicator/__init__.py index 57a5fd3..5ec46a8 100644 --- a/backend/src/indicator/__init__.py +++ b/backend/src/indicator/__init__.py @@ -149,6 +149,10 @@ from .talib_adapter import ( is_talib_available, get_talib_version, ) +from .custom_indicators import ( + register_custom_indicators, + CUSTOM_INDICATORS, +) __all__ = [ # Core classes @@ -169,4 +173,7 @@ __all__ = [ "register_all_talib_indicators", "is_talib_available", "get_talib_version", + # Custom indicators + "register_custom_indicators", + "CUSTOM_INDICATORS", ] diff --git a/backend/src/indicator/custom_indicators.py b/backend/src/indicator/custom_indicators.py new file mode 100644 index 0000000..d8d4363 --- /dev/null +++ b/backend/src/indicator/custom_indicators.py @@ -0,0 +1,954 @@ +""" +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 diff --git a/backend/src/indicator/talib_adapter.py b/backend/src/indicator/talib_adapter.py index 61a4238..b3cb5d2 100644 --- a/backend/src/indicator/talib_adapter.py +++ b/backend/src/indicator/talib_adapter.py @@ -372,12 +372,14 @@ def create_talib_indicator_class(func_name: str) -> type: ) -def register_all_talib_indicators(registry) -> int: +def register_all_talib_indicators(registry, only_tradingview_supported: bool = True) -> int: """ Auto-register all available TA-Lib indicators with the registry. Args: registry: IndicatorRegistry instance + only_tradingview_supported: If True, only register indicators that have + TradingView equivalents (default: True) Returns: Number of indicators registered @@ -392,6 +394,9 @@ def register_all_talib_indicators(registry) -> int: ) return 0 + # Get list of supported indicators if filtering is enabled + from .tv_mapping import is_indicator_supported + # Get all TA-Lib functions func_groups = talib.get_function_groups() all_functions = [] @@ -402,8 +407,16 @@ def register_all_talib_indicators(registry) -> int: all_functions = sorted(set(all_functions)) registered_count = 0 + skipped_count = 0 + for func_name in all_functions: try: + # Skip if filtering enabled and indicator not supported in TradingView + if only_tradingview_supported and not is_indicator_supported(func_name): + skipped_count += 1 + logger.debug(f"Skipping TA-Lib function {func_name} - not supported in TradingView") + continue + # Create indicator class for this function indicator_class = create_talib_indicator_class(func_name) @@ -415,7 +428,7 @@ def register_all_talib_indicators(registry) -> int: logger.warning(f"Failed to register TA-Lib function {func_name}: {e}") continue - logger.info(f"Registered {registered_count} TA-Lib indicators") + logger.info(f"Registered {registered_count} TA-Lib indicators (skipped {skipped_count} unsupported)") return registered_count diff --git a/backend/src/indicator/tv_mapping.py b/backend/src/indicator/tv_mapping.py new file mode 100644 index 0000000..e059c17 --- /dev/null +++ b/backend/src/indicator/tv_mapping.py @@ -0,0 +1,360 @@ +""" +Mapping layer between TA-Lib indicators and TradingView indicators. + +This module provides bidirectional conversion between our internal TA-Lib-based +indicator representation and TradingView's indicator system. +""" + +from typing import Dict, Any, Optional, Tuple, List +import logging + +logger = logging.getLogger(__name__) + + +# Mapping of TA-Lib indicator names to TradingView indicator names +# Only includes indicators that are present in BOTH systems (inner join) +# Format: {talib_name: tv_name} +TALIB_TO_TV_NAMES = { + # Overlap Studies (14) + "SMA": "Moving Average", + "EMA": "Moving Average Exponential", + "WMA": "Weighted Moving Average", + "DEMA": "DEMA", + "TEMA": "TEMA", + "TRIMA": "Triangular Moving Average", + "KAMA": "KAMA", + "MAMA": "MESA Adaptive Moving Average", + "T3": "T3", + "BBANDS": "Bollinger Bands", + "MIDPOINT": "Midpoint", + "MIDPRICE": "Midprice", + "SAR": "Parabolic SAR", + "HT_TRENDLINE": "Hilbert Transform - Instantaneous Trendline", + + # Momentum Indicators (21) + "RSI": "Relative Strength Index", + "MOM": "Momentum", + "ROC": "Rate of Change", + "TRIX": "TRIX", + "CMO": "Chande Momentum Oscillator", + "DX": "Directional Movement Index", + "ADX": "Average Directional Movement Index", + "ADXR": "Average Directional Movement Index Rating", + "APO": "Absolute Price Oscillator", + "PPO": "Percentage Price Oscillator", + "MACD": "MACD", + "MFI": "Money Flow Index", + "STOCH": "Stochastic", + "STOCHF": "Stochastic Fast", + "STOCHRSI": "Stochastic RSI", + "WILLR": "Williams %R", + "CCI": "Commodity Channel Index", + "AROON": "Aroon", + "AROONOSC": "Aroon Oscillator", + "BOP": "Balance Of Power", + "ULTOSC": "Ultimate Oscillator", + + # Volume Indicators (3) + "AD": "Chaikin A/D Line", + "ADOSC": "Chaikin A/D Oscillator", + "OBV": "On Balance Volume", + + # Volatility Indicators (3) + "ATR": "Average True Range", + "NATR": "Normalized Average True Range", + "TRANGE": "True Range", + + # Price Transform (4) + "AVGPRICE": "Average Price", + "MEDPRICE": "Median Price", + "TYPPRICE": "Typical Price", + "WCLPRICE": "Weighted Close Price", + + # Cycle Indicators (5) + "HT_DCPERIOD": "Hilbert Transform - Dominant Cycle Period", + "HT_DCPHASE": "Hilbert Transform - Dominant Cycle Phase", + "HT_PHASOR": "Hilbert Transform - Phasor Components", + "HT_SINE": "Hilbert Transform - SineWave", + "HT_TRENDMODE": "Hilbert Transform - Trend vs Cycle Mode", + + # Statistic Functions (9) + "BETA": "Beta", + "CORREL": "Pearson's Correlation Coefficient", + "LINEARREG": "Linear Regression", + "LINEARREG_ANGLE": "Linear Regression Angle", + "LINEARREG_INTERCEPT": "Linear Regression Intercept", + "LINEARREG_SLOPE": "Linear Regression Slope", + "STDDEV": "Standard Deviation", + "TSF": "Time Series Forecast", + "VAR": "Variance", +} + +# Total: 60 indicators supported in both systems + +# Custom indicators (TradingView indicators implemented in our backend) +CUSTOM_TO_TV_NAMES = { + "VWAP": "VWAP", + "VWMA": "VWMA", + "HMA": "Hull Moving Average", + "SUPERTREND": "SuperTrend", + "DONCHIAN": "Donchian Channels", + "KELTNER": "Keltner Channels", + "CMF": "Chaikin Money Flow", + "VORTEX": "Vortex Indicator", + "AO": "Awesome Oscillator", + "AC": "Accelerator Oscillator", + "CHOP": "Choppiness Index", + "MASS": "Mass Index", +} + +# Combined mapping (TA-Lib + Custom) +ALL_BACKEND_TO_TV_NAMES = {**TALIB_TO_TV_NAMES, **CUSTOM_TO_TV_NAMES} + +# Total: 72 indicators (60 TA-Lib + 12 Custom) + +# Reverse mapping +TV_TO_TALIB_NAMES = {v: k for k, v in TALIB_TO_TV_NAMES.items()} +TV_TO_CUSTOM_NAMES = {v: k for k, v in CUSTOM_TO_TV_NAMES.items()} +TV_TO_BACKEND_NAMES = {v: k for k, v in ALL_BACKEND_TO_TV_NAMES.items()} + + +def get_tv_indicator_name(talib_name: str) -> str: + """ + Convert TA-Lib indicator name to TradingView indicator name. + + Args: + talib_name: TA-Lib indicator name (e.g., 'RSI') + + Returns: + TradingView indicator name + """ + return TALIB_TO_TV_NAMES.get(talib_name, talib_name) + + +def get_talib_indicator_name(tv_name: str) -> Optional[str]: + """ + Convert TradingView indicator name to TA-Lib indicator name. + + Args: + tv_name: TradingView indicator name + + Returns: + TA-Lib indicator name or None if not mapped + """ + return TV_TO_TALIB_NAMES.get(tv_name) + + +def convert_talib_params_to_tv_inputs( + talib_name: str, + talib_params: Dict[str, Any] +) -> Dict[str, Any]: + """ + Convert TA-Lib parameters to TradingView input format. + + Args: + talib_name: TA-Lib indicator name + talib_params: TA-Lib parameter dictionary + + Returns: + TradingView inputs dictionary + """ + tv_inputs = {} + + # Common parameter mappings + param_mapping = { + "timeperiod": "length", + "fastperiod": "fastLength", + "slowperiod": "slowLength", + "signalperiod": "signalLength", + "nbdevup": "mult", # Standard deviations for upper band + "nbdevdn": "mult", # Standard deviations for lower band + "fastlimit": "fastLimit", + "slowlimit": "slowLimit", + "acceleration": "start", + "maximum": "increment", + "fastk_period": "kPeriod", + "slowk_period": "kPeriod", + "slowd_period": "dPeriod", + "fastd_period": "dPeriod", + "matype": "maType", + } + + # Special handling for specific indicators + if talib_name == "BBANDS": + # Bollinger Bands + tv_inputs["length"] = talib_params.get("timeperiod", 20) + tv_inputs["mult"] = talib_params.get("nbdevup", 2) + tv_inputs["source"] = "close" + elif talib_name == "MACD": + # MACD + tv_inputs["fastLength"] = talib_params.get("fastperiod", 12) + tv_inputs["slowLength"] = talib_params.get("slowperiod", 26) + tv_inputs["signalLength"] = talib_params.get("signalperiod", 9) + tv_inputs["source"] = "close" + elif talib_name == "RSI": + # RSI + tv_inputs["length"] = talib_params.get("timeperiod", 14) + tv_inputs["source"] = "close" + elif talib_name in ["SMA", "EMA", "WMA", "DEMA", "TEMA", "TRIMA"]: + # Moving averages + tv_inputs["length"] = talib_params.get("timeperiod", 14) + tv_inputs["source"] = "close" + elif talib_name == "STOCH": + # Stochastic + tv_inputs["kPeriod"] = talib_params.get("fastk_period", 14) + tv_inputs["dPeriod"] = talib_params.get("slowd_period", 3) + tv_inputs["smoothK"] = talib_params.get("slowk_period", 3) + elif talib_name == "ATR": + # ATR + tv_inputs["length"] = talib_params.get("timeperiod", 14) + elif talib_name == "CCI": + # CCI + tv_inputs["length"] = talib_params.get("timeperiod", 20) + else: + # Generic parameter conversion + for talib_param, value in talib_params.items(): + tv_param = param_mapping.get(talib_param, talib_param) + tv_inputs[tv_param] = value + + logger.debug(f"Converted TA-Lib params for {talib_name}: {talib_params} -> TV inputs: {tv_inputs}") + return tv_inputs + + +def convert_tv_inputs_to_talib_params( + tv_name: str, + tv_inputs: Dict[str, Any] +) -> Tuple[Optional[str], Dict[str, Any]]: + """ + Convert TradingView inputs to TA-Lib parameters. + + Args: + tv_name: TradingView indicator name + tv_inputs: TradingView inputs dictionary + + Returns: + Tuple of (talib_name, talib_params) + """ + talib_name = get_talib_indicator_name(tv_name) + if not talib_name: + logger.warning(f"No TA-Lib mapping for TradingView indicator: {tv_name}") + return None, {} + + talib_params = {} + + # Reverse parameter mappings + reverse_mapping = { + "length": "timeperiod", + "fastLength": "fastperiod", + "slowLength": "slowperiod", + "signalLength": "signalperiod", + "mult": "nbdevup", # Use same for both up and down + "fastLimit": "fastlimit", + "slowLimit": "slowlimit", + "start": "acceleration", + "increment": "maximum", + "kPeriod": "fastk_period", + "dPeriod": "slowd_period", + "smoothK": "slowk_period", + "maType": "matype", + } + + # Special handling for specific indicators + if talib_name == "BBANDS": + # Bollinger Bands + talib_params["timeperiod"] = tv_inputs.get("length", 20) + talib_params["nbdevup"] = tv_inputs.get("mult", 2) + talib_params["nbdevdn"] = tv_inputs.get("mult", 2) + talib_params["matype"] = 0 # SMA + elif talib_name == "MACD": + # MACD + talib_params["fastperiod"] = tv_inputs.get("fastLength", 12) + talib_params["slowperiod"] = tv_inputs.get("slowLength", 26) + talib_params["signalperiod"] = tv_inputs.get("signalLength", 9) + elif talib_name == "RSI": + # RSI + talib_params["timeperiod"] = tv_inputs.get("length", 14) + elif talib_name in ["SMA", "EMA", "WMA", "DEMA", "TEMA", "TRIMA"]: + # Moving averages + talib_params["timeperiod"] = tv_inputs.get("length", 14) + elif talib_name == "STOCH": + # Stochastic + talib_params["fastk_period"] = tv_inputs.get("kPeriod", 14) + talib_params["slowd_period"] = tv_inputs.get("dPeriod", 3) + talib_params["slowk_period"] = tv_inputs.get("smoothK", 3) + talib_params["slowk_matype"] = 0 # SMA + talib_params["slowd_matype"] = 0 # SMA + elif talib_name == "ATR": + # ATR + talib_params["timeperiod"] = tv_inputs.get("length", 14) + elif talib_name == "CCI": + # CCI + talib_params["timeperiod"] = tv_inputs.get("length", 20) + else: + # Generic parameter conversion + for tv_param, value in tv_inputs.items(): + if tv_param == "source": + continue # Skip source parameter + talib_param = reverse_mapping.get(tv_param, tv_param) + talib_params[talib_param] = value + + logger.debug(f"Converted TV inputs for {tv_name}: {tv_inputs} -> TA-Lib {talib_name} params: {talib_params}") + return talib_name, talib_params + + +def is_indicator_supported(talib_name: str) -> bool: + """ + Check if a TA-Lib indicator is supported in TradingView. + + Args: + talib_name: TA-Lib indicator name + + Returns: + True if supported + """ + return talib_name in TALIB_TO_TV_NAMES + + +def get_supported_indicators() -> List[str]: + """ + Get list of supported TA-Lib indicators. + + Returns: + List of TA-Lib indicator names + """ + return list(TALIB_TO_TV_NAMES.keys()) + + +def get_supported_indicator_count() -> int: + """ + Get count of supported indicators. + + Returns: + Number of indicators supported in both systems (TA-Lib + Custom) + """ + return len(ALL_BACKEND_TO_TV_NAMES) + + +def is_custom_indicator(indicator_name: str) -> bool: + """ + Check if an indicator is a custom implementation (not TA-Lib). + + Args: + indicator_name: Indicator name + + Returns: + True if custom indicator + """ + return indicator_name in CUSTOM_TO_TV_NAMES + + +def get_backend_indicator_name(tv_name: str) -> Optional[str]: + """ + Get backend indicator name from TradingView name (TA-Lib or custom). + + Args: + tv_name: TradingView indicator name + + Returns: + Backend indicator name or None if not mapped + """ + return TV_TO_BACKEND_NAMES.get(tv_name) diff --git a/backend/src/main.py b/backend/src/main.py index d178768..4294e76 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -24,11 +24,12 @@ from agent.tools import set_registry, set_datasource_registry, set_indicator_reg from schema.order_spec import SwapOrder from schema.chart_state import ChartState from schema.shape import ShapeCollection +from schema.indicator import IndicatorCollection from datasource.registry import DataSourceRegistry from datasource.subscription_manager import SubscriptionManager from datasource.websocket_handler import DatafeedWebSocketHandler from secrets_manager import SecretsStore, InvalidMasterPassword -from indicator import IndicatorRegistry, register_all_talib_indicators +from indicator import IndicatorRegistry, register_all_talib_indicators, register_custom_indicators # Configure logging logging.basicConfig( @@ -93,6 +94,13 @@ async def lifespan(app: FastAPI): logger.warning(f"Failed to register TA-Lib indicators: {e}") logger.info("TA-Lib indicators will not be available. Install TA-Lib C library and Python wrapper to enable.") + # Register custom indicators (TradingView indicators not in TA-Lib) + try: + custom_count = register_custom_indicators(indicator_registry) + logger.info(f"Registered {custom_count} custom indicators") + except Exception as e: + logger.warning(f"Failed to register custom indicators: {e}") + # Get API keys from secrets store if unlocked, otherwise fall back to environment anthropic_api_key = None @@ -164,15 +172,21 @@ class ChartStore(BaseModel): class ShapeStore(BaseModel): shapes: dict[str, dict] = {} # Dictionary of shapes keyed by ID +# IndicatorStore model for synchronization +class IndicatorStore(BaseModel): + indicators: dict[str, dict] = {} # Dictionary of indicators keyed by ID + # Initialize stores order_store = OrderStore() chart_store = ChartStore() shape_store = ShapeStore() +indicator_store = IndicatorStore() # Register with SyncRegistry registry.register(order_store, store_name="OrderStore") registry.register(chart_store, store_name="ChartStore") registry.register(shape_store, store_name="ShapeStore") +registry.register(indicator_store, store_name="IndicatorStore") @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): diff --git a/backend/src/schema/indicator.py b/backend/src/schema/indicator.py new file mode 100644 index 0000000..2880ce8 --- /dev/null +++ b/backend/src/schema/indicator.py @@ -0,0 +1,40 @@ +from typing import Dict, Any, Optional, List +from pydantic import BaseModel, Field + + +class IndicatorInstance(BaseModel): + """ + Represents an instance of an indicator applied to a chart. + + This schema holds both the TA-Lib metadata and TradingView-specific data + needed for synchronization. + """ + id: str = Field(..., description="Unique identifier for this indicator instance") + + # TA-Lib metadata + talib_name: str = Field(..., description="TA-Lib indicator name (e.g., 'RSI', 'SMA', 'MACD')") + instance_name: str = Field(..., description="User-friendly instance name") + parameters: Dict[str, Any] = Field(default_factory=dict, description="TA-Lib indicator parameters") + + # TradingView metadata + tv_study_id: Optional[str] = Field(default=None, description="TradingView study ID assigned by the chart widget") + tv_indicator_name: Optional[str] = Field(default=None, description="TradingView indicator name if different from TA-Lib") + tv_inputs: Optional[Dict[str, Any]] = Field(default=None, description="TradingView-specific input parameters") + + # Visual properties + visible: bool = Field(default=True, description="Whether indicator is visible on chart") + pane: str = Field(default="chart", description="Pane where indicator is displayed ('chart' or 'separate')") + + # Metadata + symbol: Optional[str] = Field(default=None, description="Symbol this indicator is applied to") + created_at: Optional[int] = Field(default=None, description="Creation timestamp (Unix seconds)") + modified_at: Optional[int] = Field(default=None, description="Last modification timestamp (Unix seconds)") + original_id: Optional[str] = Field(default=None, description="Original ID from backend before TradingView assigns its own ID") + + +class IndicatorCollection(BaseModel): + """Collection of all indicator instances on the chart.""" + indicators: Dict[str, IndicatorInstance] = Field( + default_factory=dict, + description="Dictionary of indicator instances keyed by ID" + ) diff --git a/backend/src/sync/registry.py b/backend/src/sync/registry.py index 0489c54..e7e47ab 100644 --- a/backend/src/sync/registry.py +++ b/backend/src/sync/registry.py @@ -116,6 +116,10 @@ class SyncRegistry: logger.info(f"apply_client_patch: New state after patch: {new_state}") self._update_model(entry.model, new_state) + # Verify the model was actually updated + updated_state = entry.model.model_dump(mode="json") + logger.info(f"apply_client_patch: Model state after _update_model: {updated_state}") + entry.commit_patch(patch) logger.info(f"apply_client_patch: Patch committed, new seq={entry.seq}") # Don't broadcast back to client - they already have this change @@ -206,7 +210,37 @@ class SyncRegistry: await self.websocket.send_json(msg.model_dump(mode="json")) def _update_model(self, model: BaseModel, new_data: Dict[str, Any]): - # Update model using model_validate for potentially nested models - new_model = model.__class__.model_validate(new_data) - for field in model.model_fields: - setattr(model, field, getattr(new_model, field)) + # Update model fields in-place to preserve references + # This is important for dict fields that may be referenced elsewhere + for field_name, field_info in model.model_fields.items(): + if field_name in new_data: + new_value = new_data[field_name] + current_value = getattr(model, field_name) + + # For dict fields, update in-place instead of replacing + if isinstance(current_value, dict) and isinstance(new_value, dict): + self._deep_update_dict(current_value, new_value) + else: + # For other types, just set the new value + setattr(model, field_name, new_value) + + def _deep_update_dict(self, target: dict, source: dict): + """Deep update target dict with source dict, preserving nested dict references.""" + # Remove keys that are in target but not in source + keys_to_remove = set(target.keys()) - set(source.keys()) + for key in keys_to_remove: + del target[key] + + # Update or add keys from source + for key, source_value in source.items(): + if key in target: + target_value = target[key] + # If both are dicts, recursively update + if isinstance(target_value, dict) and isinstance(source_value, dict): + self._deep_update_dict(target_value, source_value) + else: + # Replace the value + target[key] = source_value + else: + # Add new key + target[key] = source_value diff --git a/web/src/App.vue b/web/src/App.vue index b75739b..0cb5d80 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -8,6 +8,7 @@ import LoginScreen from './components/LoginScreen.vue' import { useOrderStore } from './stores/orders' import { useChartStore } from './stores/chart' import { useShapeStore } from './stores/shapes' +import { useIndicatorStore } from './stores/indicators' import { useStateSync } from './composables/useStateSync' import { wsManager } from './composables/useWebSocket' @@ -65,10 +66,12 @@ const handleAuthenticate = async ( const orderStore = useOrderStore() const chartStore = useChartStore() const shapeStore = useShapeStore() + const indicatorStore = useIndicatorStore() const stateSync = useStateSync({ OrderStore: orderStore, ChartStore: chartStore, - ShapeStore: shapeStore + ShapeStore: shapeStore, + IndicatorStore: indicatorStore }) stateSyncCleanup = stateSync.cleanup } else { diff --git a/web/src/components/ChartView.vue b/web/src/components/ChartView.vue index 87416eb..0377b5d 100644 --- a/web/src/components/ChartView.vue +++ b/web/src/components/ChartView.vue @@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount, watch } from 'vue' import Card from 'primevue/card' import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed' import { useTradingViewShapes } from '../composables/useTradingViewShapes' +import { useTradingViewIndicators } from '../composables/useTradingViewIndicators' import { useChartStore } from '../stores/chart' import type { IChartingLibraryWidget } from '../types/tradingview' @@ -12,6 +13,7 @@ let tvWidget: IChartingLibraryWidget | null = null let datafeed: any = null let isUpdatingFromChart = false // Flag to prevent circular updates let shapeCleanup: (() => void) | null = null // Cleanup function for shape sync +let indicatorCleanup: (() => void) | null = null // Cleanup function for indicator sync onMounted(() => { if (!chartContainer.value) return @@ -39,6 +41,91 @@ onMounted(() => { 'symbol_search_hot_key' ], enabled_features: ['study_templates'], + // Restrict indicators to only those supported by both TA-Lib and TradingView + studies_access: { + type: 'white', + tools: [ + // Overlap Studies (14) + { name: 'Moving Average' }, + { name: 'Moving Average Exponential' }, + { name: 'Weighted Moving Average' }, + { name: 'DEMA' }, + { name: 'TEMA' }, + { name: 'Triangular Moving Average' }, + { name: 'KAMA' }, + { name: 'MESA Adaptive Moving Average' }, + { name: 'T3' }, + { name: 'Bollinger Bands' }, + { name: 'Midpoint' }, + { name: 'Midprice' }, + { name: 'Parabolic SAR' }, + { name: 'Hilbert Transform - Instantaneous Trendline' }, + // Momentum Indicators (21) + { name: 'Relative Strength Index' }, + { name: 'Momentum' }, + { name: 'Rate of Change' }, + { name: 'TRIX' }, + { name: 'Chande Momentum Oscillator' }, + { name: 'Directional Movement Index' }, + { name: 'Average Directional Movement Index' }, + { name: 'Average Directional Movement Index Rating' }, + { name: 'Absolute Price Oscillator' }, + { name: 'Percentage Price Oscillator' }, + { name: 'MACD' }, + { name: 'Money Flow Index' }, + { name: 'Stochastic' }, + { name: 'Stochastic Fast' }, + { name: 'Stochastic RSI' }, + { name: 'Williams %R' }, + { name: 'Commodity Channel Index' }, + { name: 'Aroon' }, + { name: 'Aroon Oscillator' }, + { name: 'Balance Of Power' }, + { name: 'Ultimate Oscillator' }, + // Volume Indicators (3) + { name: 'Chaikin A/D Line' }, + { name: 'Chaikin A/D Oscillator' }, + { name: 'On Balance Volume' }, + // Volatility Indicators (3) + { name: 'Average True Range' }, + { name: 'Normalized Average True Range' }, + { name: 'True Range' }, + // Price Transform (4) + { name: 'Average Price' }, + { name: 'Median Price' }, + { name: 'Typical Price' }, + { name: 'Weighted Close Price' }, + // Cycle Indicators (5) + { name: 'Hilbert Transform - Dominant Cycle Period' }, + { name: 'Hilbert Transform - Dominant Cycle Phase' }, + { name: 'Hilbert Transform - Phasor Components' }, + { name: 'Hilbert Transform - SineWave' }, + { name: 'Hilbert Transform - Trend vs Cycle Mode' }, + // Statistic Functions (9) + { name: 'Beta' }, + { name: 'Pearson\'s Correlation Coefficient' }, + { name: 'Linear Regression' }, + { name: 'Linear Regression Angle' }, + { name: 'Linear Regression Intercept' }, + { name: 'Linear Regression Slope' }, + { name: 'Standard Deviation' }, + { name: 'Time Series Forecast' }, + { name: 'Variance' }, + // Custom Indicators (12) + { name: 'VWAP' }, + { name: 'VWMA' }, + { name: 'Hull Moving Average' }, + { name: 'SuperTrend' }, + { name: 'Donchian Channels' }, + { name: 'Keltner Channels' }, + { name: 'Chaikin Money Flow' }, + { name: 'Vortex Indicator' }, + { name: 'Awesome Oscillator' }, + { name: 'Accelerator Oscillator' }, + { name: 'Choppiness Index' }, + { name: 'Mass Index' } + ] + }, fullscreen: false, autosize: true, theme: 'Dark', @@ -54,6 +141,7 @@ onMounted(() => { // Setup shape synchronization if (tvWidget) { shapeCleanup = useTradingViewShapes(tvWidget) + indicatorCleanup = useTradingViewIndicators(tvWidget) } }) } catch (error) { @@ -177,6 +265,12 @@ onBeforeUnmount(() => { shapeCleanup = null } + // Cleanup indicator synchronization + if (indicatorCleanup) { + indicatorCleanup() + indicatorCleanup = null + } + if (tvWidget) { tvWidget.remove() tvWidget = null diff --git a/web/src/components/LoginScreen.vue b/web/src/components/LoginScreen.vue index 180a2ea..0882b7e 100644 --- a/web/src/components/LoginScreen.vue +++ b/web/src/components/LoginScreen.vue @@ -21,6 +21,7 @@ const newPassword = ref('') const confirmNewPassword = ref('') const isLoading = ref(false) const isChangingPassword = ref(false) +const passwordInput = ref | null>(null) const canSubmit = computed(() => { if (!password.value || isLoading.value) return false @@ -69,9 +70,11 @@ const togglePasswordChange = () => { onMounted(() => { // Focus on the password input when component mounts - const passwordInput = document.querySelector('#password input') as HTMLInputElement - if (passwordInput) { - passwordInput.focus() + if (passwordInput.value?.$el) { + const inputElement = passwordInput.value.$el.querySelector('input') as HTMLInputElement + if (inputElement) { + inputElement.focus() + } } }) @@ -105,6 +108,7 @@ onMounted(() => {
) { patch: patch }; sendJson(msg); + + // Optimistically increment sequence number after sending + // This ensures the next patch we send has the correct base sequence + currentSeqs[storeName] = seq + 1; + saveStoredSeqs(currentSeqs); + console.log(`[StateSync] Incremented seq for "${storeName}" to ${seq + 1} after sending patch`); }; // Set up watchers for each store to send patches on changes diff --git a/web/src/composables/useTradingViewIndicators.ts b/web/src/composables/useTradingViewIndicators.ts new file mode 100644 index 0000000..196d99d --- /dev/null +++ b/web/src/composables/useTradingViewIndicators.ts @@ -0,0 +1,905 @@ +import { watch } from 'vue' +import type { IChartingLibraryWidget, IStudyApi } from '../types/tradingview' +import { useIndicatorStore } from '../stores/indicators' +import { useChartStore } from '../stores/chart' +import type { IndicatorInstance } from '../stores/indicators' + +/** + * Mapping between TA-Lib indicator names and TradingView indicator names + * Only includes indicators that are present in BOTH systems (inner join) + */ +const TALIB_TO_TV_NAMES: Record = { + // Overlap Studies (14) + 'SMA': 'Moving Average', + 'EMA': 'Moving Average Exponential', + 'WMA': 'Weighted Moving Average', + 'DEMA': 'DEMA', + 'TEMA': 'TEMA', + 'TRIMA': 'Triangular Moving Average', + 'KAMA': 'KAMA', + 'MAMA': 'MESA Adaptive Moving Average', + 'T3': 'T3', + 'BBANDS': 'Bollinger Bands', + 'MIDPOINT': 'Midpoint', + 'MIDPRICE': 'Midprice', + 'SAR': 'Parabolic SAR', + 'HT_TRENDLINE': 'Hilbert Transform - Instantaneous Trendline', + + // Momentum Indicators (21) + 'RSI': 'Relative Strength Index', + 'MOM': 'Momentum', + 'ROC': 'Rate of Change', + 'TRIX': 'TRIX', + 'CMO': 'Chande Momentum Oscillator', + 'DX': 'Directional Movement Index', + 'ADX': 'Average Directional Movement Index', + 'ADXR': 'Average Directional Movement Index Rating', + 'APO': 'Absolute Price Oscillator', + 'PPO': 'Percentage Price Oscillator', + 'MACD': 'MACD', + 'MFI': 'Money Flow Index', + 'STOCH': 'Stochastic', + 'STOCHF': 'Stochastic Fast', + 'STOCHRSI': 'Stochastic RSI', + 'WILLR': 'Williams %R', + 'CCI': 'Commodity Channel Index', + 'AROON': 'Aroon', + 'AROONOSC': 'Aroon Oscillator', + 'BOP': 'Balance Of Power', + 'ULTOSC': 'Ultimate Oscillator', + + // Volume Indicators (3) + 'AD': 'Chaikin A/D Line', + 'ADOSC': 'Chaikin A/D Oscillator', + 'OBV': 'On Balance Volume', + + // Volatility Indicators (3) + 'ATR': 'Average True Range', + 'NATR': 'Normalized Average True Range', + 'TRANGE': 'True Range', + + // Price Transform (4) + 'AVGPRICE': 'Average Price', + 'MEDPRICE': 'Median Price', + 'TYPPRICE': 'Typical Price', + 'WCLPRICE': 'Weighted Close Price', + + // Cycle Indicators (5) + 'HT_DCPERIOD': 'Hilbert Transform - Dominant Cycle Period', + 'HT_DCPHASE': 'Hilbert Transform - Dominant Cycle Phase', + 'HT_PHASOR': 'Hilbert Transform - Phasor Components', + 'HT_SINE': 'Hilbert Transform - SineWave', + 'HT_TRENDMODE': 'Hilbert Transform - Trend vs Cycle Mode', + + // Statistic Functions (9) + 'BETA': 'Beta', + 'CORREL': 'Pearson\'s Correlation Coefficient', + 'LINEARREG': 'Linear Regression', + 'LINEARREG_ANGLE': 'Linear Regression Angle', + 'LINEARREG_INTERCEPT': 'Linear Regression Intercept', + 'LINEARREG_SLOPE': 'Linear Regression Slope', + 'STDDEV': 'Standard Deviation', + 'TSF': 'Time Series Forecast', + 'VAR': 'Variance', +} + +// Total: 60 TA-Lib indicators + +/** + * Custom indicators (implemented in backend, not in TA-Lib) + */ +const CUSTOM_TO_TV_NAMES: Record = { + 'VWAP': 'VWAP', + 'VWMA': 'VWMA', + 'HMA': 'Hull Moving Average', + 'SUPERTREND': 'SuperTrend', + 'DONCHIAN': 'Donchian Channels', + 'KELTNER': 'Keltner Channels', + 'CMF': 'Chaikin Money Flow', + 'VORTEX': 'Vortex Indicator', + 'AO': 'Awesome Oscillator', + 'AC': 'Accelerator Oscillator', + 'CHOP': 'Choppiness Index', + 'MASS': 'Mass Index', +} + +// Combined mapping (TA-Lib + Custom) +const ALL_BACKEND_TO_TV_NAMES: Record = { + ...TALIB_TO_TV_NAMES, + ...CUSTOM_TO_TV_NAMES +} + +// Total: 72 indicators (60 TA-Lib + 12 Custom) + +/** + * Reverse mapping from TradingView to backend + */ +const TV_TO_TALIB_NAMES: Record = Object.fromEntries( + Object.entries(TALIB_TO_TV_NAMES).map(([k, v]) => [v, k]) +) + +const TV_TO_CUSTOM_NAMES: Record = Object.fromEntries( + Object.entries(CUSTOM_TO_TV_NAMES).map(([k, v]) => [v, k]) +) + +const TV_TO_BACKEND_NAMES: Record = Object.fromEntries( + Object.entries(ALL_BACKEND_TO_TV_NAMES).map(([k, v]) => [v, k]) +) + +/** + * Convert TA-Lib parameters to TradingView inputs + */ +function convertTALibParamsToTVInputs(talibName: string, talibParams: Record): Record { + const tvInputs: Record = {} + + // Common parameter mappings + const paramMapping: Record = { + 'timeperiod': 'length', + 'fastperiod': 'fastLength', + 'slowperiod': 'slowLength', + 'signalperiod': 'signalLength', + 'nbdevup': 'mult', + 'nbdevdn': 'mult', + 'fastlimit': 'fastLimit', + 'slowlimit': 'slowLimit', + 'acceleration': 'start', + 'maximum': 'increment', + 'fastk_period': 'kPeriod', + 'slowk_period': 'kPeriod', + 'slowd_period': 'dPeriod', + 'fastd_period': 'dPeriod', + } + + // Special handling for specific indicators + if (talibName === 'BBANDS') { + tvInputs.length = talibParams.timeperiod || 20 + tvInputs.mult = talibParams.nbdevup || 2 + tvInputs.source = 'close' + } else if (talibName === 'MACD') { + tvInputs.fastLength = talibParams.fastperiod || 12 + tvInputs.slowLength = talibParams.slowperiod || 26 + tvInputs.signalLength = talibParams.signalperiod || 9 + tvInputs.source = 'close' + } else if (talibName === 'RSI') { + tvInputs.length = talibParams.timeperiod || 14 + tvInputs.source = 'close' + } else if (['SMA', 'EMA', 'WMA', 'DEMA', 'TEMA', 'TRIMA'].includes(talibName)) { + tvInputs.length = talibParams.timeperiod || 14 + tvInputs.source = 'close' + } else if (talibName === 'STOCH') { + tvInputs.kPeriod = talibParams.fastk_period || 14 + tvInputs.dPeriod = talibParams.slowd_period || 3 + tvInputs.smoothK = talibParams.slowk_period || 3 + } else if (talibName === 'ATR') { + tvInputs.length = talibParams.timeperiod || 14 + } else if (talibName === 'CCI') { + tvInputs.length = talibParams.timeperiod || 20 + } else { + // Generic parameter conversion + for (const [talibParam, value] of Object.entries(talibParams)) { + const tvParam = paramMapping[talibParam] || talibParam + tvInputs[tvParam] = value + } + } + + return tvInputs +} + +/** + * Convert TradingView inputs to TA-Lib parameters + */ +function convertTVInputsToTALibParams(tvName: string, tvInputs: Record): { talibName: string | null, talibParams: Record } { + const talibName = TV_TO_BACKEND_NAMES[tvName] || null + if (!talibName) { + console.warn('[Indicators] No backend mapping for TradingView indicator:', tvName) + return { talibName: null, talibParams: {} } + } + + const talibParams: Record = {} + + // Reverse parameter mappings + const reverseMapping: Record = { + 'length': 'timeperiod', + 'fastLength': 'fastperiod', + 'slowLength': 'slowperiod', + 'signalLength': 'signalperiod', + 'mult': 'nbdevup', + 'fastLimit': 'fastlimit', + 'slowLimit': 'slowlimit', + 'start': 'acceleration', + 'increment': 'maximum', + 'kPeriod': 'fastk_period', + 'dPeriod': 'slowd_period', + 'smoothK': 'slowk_period', + } + + // Special handling for specific indicators + if (talibName === 'BBANDS') { + // TradingView uses in_0 for length, in_1 for multiplier + talibParams.timeperiod = tvInputs.in_0 || tvInputs.length || 20 + talibParams.nbdevup = tvInputs.in_1 || tvInputs.mult || 2 + talibParams.nbdevdn = tvInputs.in_1 || tvInputs.mult || 2 + talibParams.matype = 0 // SMA + } else if (talibName === 'MACD') { + talibParams.fastperiod = tvInputs.fastLength || 12 + talibParams.slowperiod = tvInputs.slowLength || 26 + talibParams.signalperiod = tvInputs.signalLength || 9 + } else if (talibName === 'RSI') { + talibParams.timeperiod = tvInputs.in_0 || tvInputs.length || 14 + } else if (['SMA', 'EMA', 'WMA', 'DEMA', 'TEMA', 'TRIMA'].includes(talibName)) { + talibParams.timeperiod = tvInputs.in_0 || tvInputs.length || 14 + } else if (talibName === 'STOCH') { + talibParams.fastk_period = tvInputs.kPeriod || 14 + talibParams.slowd_period = tvInputs.dPeriod || 3 + talibParams.slowk_period = tvInputs.smoothK || 3 + talibParams.slowk_matype = 0 // SMA + talibParams.slowd_matype = 0 // SMA + } else if (talibName === 'ATR') { + talibParams.timeperiod = tvInputs.length || 14 + } else if (talibName === 'CCI') { + talibParams.timeperiod = tvInputs.length || 20 + } else { + // Generic parameter conversion + for (const [tvParam, value] of Object.entries(tvInputs)) { + if (tvParam === 'source') continue // Skip source parameter + const talibParam = reverseMapping[tvParam] || tvParam + talibParams[talibParam] = value + } + } + + return { talibName, talibParams } +} + +/** + * Setup bidirectional synchronization between TradingView indicators and IndicatorStore + */ +export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { + const indicatorStore = useIndicatorStore() + const chartStore = useChartStore() + + let isApplyingTVUpdate = false // Prevent circular updates when TV modifies indicators + let isUpdatingStore = false // Prevent circular updates when we update the store from TV + let isChartReady = false // Track if chart API is ready + + /** + * Convert TradingView study to our IndicatorInstance format + */ + function convertTVStudyToIndicator(tvStudy: any, symbol: string, studyId?: string): IndicatorInstance | null { + try { + console.log('[Indicators] convertTVStudyToIndicator called with:', { tvStudy, symbol, studyId }) + console.log('[Indicators] Study object keys:', Object.keys(tvStudy)) + console.log('[Indicators] Study object type:', typeof tvStudy) + + // Expose study object to window for debugging + ;(window as any).__lastTVStudy = tvStudy + console.log('[Indicators] Study object exposed as window.__lastTVStudy for debugging') + + // Study object has methods - name() gives the indicator type, title() gives display name + let studyName = 'Unknown' + + // Try tvStudy.study().name() first + if (typeof tvStudy.study === 'function') { + try { + const innerStudy = tvStudy.study() + if (innerStudy && typeof innerStudy.name === 'function') { + studyName = innerStudy.name() + console.log('[Indicators] Called tvStudy.study().name():', studyName) + } + } catch (e) { + console.log('[Indicators] Error calling study().name():', e) + } + } + + // Try name() directly if that didn't work + if (studyName === 'Unknown' && typeof tvStudy.name === 'function') { + try { + studyName = tvStudy.name() + console.log('[Indicators] Called study.name():', studyName) + } catch (e) { + console.log('[Indicators] Error calling name():', e) + } + } + + // Fallback to title() if name() didn't work + if (studyName === 'Unknown' && typeof tvStudy.title === 'function') { + try { + studyName = tvStudy.title() + console.log('[Indicators] Called study.title():', studyName) + } catch (e) { + console.log('[Indicators] Error calling title():', e) + } + } + + // Final fallback to property access + if (studyName === 'Unknown') { + studyName = tvStudy._study?.name?.() || + tvStudy._metaInfo?.description || + tvStudy._metaInfo?.shortDescription || 'Unknown' + } + + console.log('[Indicators] Study name extracted:', studyName) + + const talibName = TV_TO_BACKEND_NAMES[studyName] + console.log('[Indicators] Backend mapping:', studyName, '->', talibName) + + if (!talibName) { + console.log('[Indicators] TradingView study not mapped to backend:', studyName) + console.log('[Indicators] Available mappings:', Object.keys(TV_TO_BACKEND_NAMES).slice(0, 10)) + return null + } + + // Extract inputs from study - try calling getInputValues() if it's a method + let tvInputs: any = {} + + if (typeof tvStudy.getInputValues === 'function') { + try { + const inputsArray = tvStudy.getInputValues() + console.log('[Indicators] Called study.getInputValues():', inputsArray) + + // Convert array of {id, value} objects to a simple object + if (Array.isArray(inputsArray)) { + tvInputs = {} + for (const input of inputsArray) { + if (input.id && input.value !== undefined) { + tvInputs[input.id] = input.value + } + } + console.log('[Indicators] Converted array inputs to object:', tvInputs) + } else { + tvInputs = inputsArray + } + } catch (e) { + console.log('[Indicators] Error calling getInputValues():', e) + } + } else { + tvInputs = tvStudy.inputs || tvStudy._inputs || {} + } + + console.log('[Indicators] TV inputs:', tvInputs) + + const { talibParams } = convertTVInputsToTALibParams(studyName, tvInputs) + console.log('[Indicators] Converted TA-Lib params:', talibParams) + + const now = Math.floor(Date.now() / 1000) + + const indicator = { + id: studyId || tvStudy.id || `ind_${Date.now()}`, + talib_name: talibName, + instance_name: `${talibName}_${Date.now()}`, + parameters: talibParams, + tv_study_id: studyId || tvStudy.id, + tv_indicator_name: studyName, + tv_inputs: tvInputs, + visible: true, + pane: 'chart', + symbol, + created_at: now, + modified_at: now + } + + console.log('[Indicators] Created indicator object:', indicator) + return indicator + } catch (error) { + console.error('[Indicators] Error converting TV study:', error) + return null + } + } + + /** + * Setup listeners for TradingView indicator events + */ + function setupIndicatorListeners() { + try { + const chart = tvWidget.activeChart() + if (!chart) { + console.warn('[Indicators] Active chart not available yet') + return () => {} + } + isChartReady = true + + console.log('[Indicators] Setting up indicator event subscriptions') + console.log('[Indicators] Chart ready, widget:', tvWidget) + + // Subscribe to 'study_event' - fires for specific study instance events with ID + // Callback signature: (studyId: string, eventType: string) + tvWidget.subscribe('study_event', (studyId: any, eventType?: any) => { + console.log('[Indicators] ===== STUDY_EVENT FIRED =====') + console.log('[Indicators] Arg 1 (studyId):', studyId) + console.log('[Indicators] Arg 2 (eventType):', eventType) + + if (isApplyingTVUpdate) { + console.log('[Indicators] Ignoring study_event - isApplyingTVUpdate is true') + return + } + + const currentSymbol = chartStore.chart_state.symbol + + // If studyId is actually an object, extract the real values + let actualStudyId = studyId + let actualEventType = eventType + + if (typeof studyId === 'object' && studyId !== null) { + actualStudyId = studyId.id || studyId.studyId + actualEventType = studyId.type || studyId.event || eventType + } + + console.log('[Indicators] Parsed - Study ID:', actualStudyId, 'Event type:', actualEventType) + + if (!actualStudyId) { + console.warn('[Indicators] No study ID found, cannot process event') + return + } + + // Filter out events we don't care about + if (actualEventType && !['create', 'remove'].includes(actualEventType)) { + console.log('[Indicators] Ignoring event type:', actualEventType) + return + } + + // If we have an event type, handle it specifically + if (actualEventType === 'create') { + console.log('[Indicators] New indicator created with ID:', actualStudyId) + + // Wait for TradingView to finish initializing the study + setTimeout(() => { + try { + const study = chart.getStudyById(actualStudyId) + if (study) { + console.log('[Indicators] Retrieved new study after timeout') + + const indicator = convertTVStudyToIndicator(study, currentSymbol, actualStudyId) + if (indicator) { + console.log('[Indicators] Adding new indicator to store:', indicator) + isUpdatingStore = true + indicatorStore.addIndicator(indicator) + console.log('[Indicators] Indicator added! Store now has:', Object.keys(indicatorStore.indicators).length, 'indicators') + isUpdatingStore = false + } else { + console.warn('[Indicators] Conversion returned null - indicator not supported') + } + } + } catch (error) { + console.error('[Indicators] Error handling create event:', error) + } + }, 0) + } + else if (actualEventType === 'remove') { + console.log('[Indicators] Indicator removed with ID:', actualStudyId) + + const existingIndicator = Object.values(indicatorStore.indicators).find( + ind => ind.tv_study_id === actualStudyId + ) + + if (existingIndicator) { + console.log('[Indicators] Removing indicator from store:', existingIndicator.id) + isUpdatingStore = true + indicatorStore.removeIndicator(existingIndicator.id) + console.log('[Indicators] Indicator removed!') + isUpdatingStore = false + } else { + console.log('[Indicators] Indicator not found in store for removal') + } + } + else { + // No event type provided, just a study ID - check if it exists + console.log('[Indicators] Event fired with study ID but no type, checking if study exists...') + + try { + const study = chart.getStudyById(actualStudyId) + if (study) { + console.log('[Indicators] Study exists - likely created or modified') + + const existingIndicator = Object.values(indicatorStore.indicators).find( + ind => ind.tv_study_id === actualStudyId + ) + + if (!existingIndicator) { + // New indicator + console.log('[Indicators] New indicator detected, adding to store...') + const indicator = convertTVStudyToIndicator(study, currentSymbol, actualStudyId) + if (indicator) { + isUpdatingStore = true + indicatorStore.addIndicator(indicator) + console.log('[Indicators] Indicator added!') + isUpdatingStore = false + } + } else { + console.log('[Indicators] Indicator already exists in store') + } + } + } catch (error) { + // Study doesn't exist - it was removed + console.log('[Indicators] Study does not exist - checking for removal...') + + const existingIndicator = Object.values(indicatorStore.indicators).find( + ind => ind.tv_study_id === actualStudyId + ) + + if (existingIndicator) { + console.log('[Indicators] Removing indicator from store') + isUpdatingStore = true + indicatorStore.removeIndicator(existingIndicator.id) + isUpdatingStore = false + } + } + } + + console.log('[Indicators] ===== STUDY_EVENT COMPLETE =====') + }) + + // Subscribe to 'study_properties_changed' - fires when indicator settings are modified + // This is the key event for detecting when users edit indicator parameters + // Callback signature: (studyId: string) + tvWidget.subscribe('study_properties_changed', (studyId: any) => { + console.log('[Indicators] ===== STUDY_PROPERTIES_CHANGED FIRED =====') + console.log('[Indicators] Study ID:', studyId) + + if (isApplyingTVUpdate) { + console.log('[Indicators] Ignoring properties change - isApplyingTVUpdate is true') + return + } + + const currentSymbol = chartStore.chart_state.symbol + + if (studyId && typeof studyId === 'string') { + console.log('[Indicators] Study properties changed for ID:', studyId) + + // Wait for TradingView to finish applying the changes + setTimeout(() => { + try { + const study = chart.getStudyById(studyId) + if (study) { + console.log('[Indicators] Retrieved updated study') + + // Find existing indicator in store + const existingIndicator = Object.values(indicatorStore.indicators).find( + ind => ind.tv_study_id === studyId + ) + + if (existingIndicator) { + console.log('[Indicators] Found existing indicator:', existingIndicator.id) + + // Get the study name using study().name() + let studyName = 'Unknown' + if (typeof study.study === 'function') { + const innerStudy = study.study() + if (innerStudy && typeof innerStudy.name === 'function') { + studyName = innerStudy.name() + } + } + + console.log('[Indicators] Study name:', studyName) + + // Get inputs - try calling getInputValues() if available + let tvInputs: any = {} + if (typeof study.getInputValues === 'function') { + const inputsArray = study.getInputValues() + + // Convert array of {id, value} objects to a simple object + if (Array.isArray(inputsArray)) { + tvInputs = {} + for (const input of inputsArray) { + if (input.id && input.value !== undefined) { + tvInputs[input.id] = input.value + } + } + console.log('[Indicators] Converted inputs array to object:', tvInputs) + } else { + tvInputs = inputsArray + } + } + + console.log('[Indicators] TV inputs:', tvInputs) + + const { talibParams } = convertTVInputsToTALibParams(studyName, tvInputs) + + console.log('[Indicators] Old params:', existingIndicator.parameters) + console.log('[Indicators] New params:', talibParams) + + // Update the store with new parameters + if (JSON.stringify(existingIndicator.parameters) !== JSON.stringify(talibParams)) { + console.log('[Indicators] Parameters changed, updating store...') + isUpdatingStore = true + indicatorStore.updateIndicator(existingIndicator.id, { + parameters: talibParams, + tv_inputs: tvInputs + }) + console.log('[Indicators] Indicator updated in store!') + isUpdatingStore = false + } else { + console.log('[Indicators] Parameters unchanged (might be visual properties only)') + } + } else { + console.log('[Indicators] No existing indicator found, doing full sync...') + // Might be a new indicator, do full sync + if (!isUpdatingStore && !isApplyingTVUpdate) { + syncIndicatorsFromTV() + } + } + } + } catch (error) { + console.error('[Indicators] Error handling study_properties_changed:', error) + } + }, 0) + } else { + console.log('[Indicators] No study ID in event, doing full sync...') + // Fallback to full sync + setTimeout(() => { + if (!isUpdatingStore && !isApplyingTVUpdate) { + syncIndicatorsFromTV() + } + }, 100) + } + + console.log('[Indicators] ===== STUDY_PROPERTIES_CHANGED COMPLETE =====') + }) + + // Initial sync on data load + const dataLoadedSubscription = chart.onDataLoaded().subscribe(null, () => { + console.log('[Indicators] Chart data loaded, performing initial sync') + syncIndicatorsFromTV() + }) + + // Cleanup function + return () => { + console.log('[Indicators] Cleaning up event subscriptions') + try { + // Unsubscribe from all widget events + tvWidget.unsubscribe('study_event') + tvWidget.unsubscribe('study_properties_changed') + + if (dataLoadedSubscription && typeof dataLoadedSubscription.unsubscribe === 'function') { + dataLoadedSubscription.unsubscribe() + } + } catch (error) { + console.error('[Indicators] Error during cleanup:', error) + } + } + } catch (error) { + console.error('[Indicators] Error setting up indicator listeners:', error) + return () => {} + } + } + + /** + * Scan TradingView for indicators and sync to store + */ + function syncIndicatorsFromTV() { + if (!isChartReady) return + + try { + const chart = tvWidget.activeChart() + if (!chart) return + + const currentSymbol = chartStore.chart_state.symbol + const allStudies = chart.getAllStudies() + + if (!allStudies) return + + const seenStudyIds = new Set() + + isUpdatingStore = true + try { + for (const studyInfo of allStudies) { + seenStudyIds.add(studyInfo.id) + + // getAllStudies() returns simple objects {id, name} + // We need to get the full study object using getStudyById() + try { + const tvStudy = chart.getStudyById(studyInfo.id) + if (!tvStudy) continue + + const indicator = convertTVStudyToIndicator(tvStudy, currentSymbol, studyInfo.id) + if (!indicator) continue + + const existingIndicator = Object.values(indicatorStore.indicators).find( + ind => ind.tv_study_id === studyInfo.id + ) + + if (!existingIndicator) { + console.log('[Indicators] New indicator detected:', indicator) + indicatorStore.addIndicator(indicator) + } else if (JSON.stringify(existingIndicator.parameters) !== JSON.stringify(indicator.parameters)) { + console.log('[Indicators] Indicator parameters changed:', indicator.id) + indicatorStore.updateIndicator(existingIndicator.id, { + parameters: indicator.parameters, + tv_inputs: indicator.tv_inputs + }) + } + } catch (err) { + console.warn('[Indicators] Could not get study details for:', studyInfo.id, err) + } + } + + // Check for deleted indicators + const allStoreIndicators = indicatorStore.getAllIndicators() + for (const storeIndicator of allStoreIndicators) { + if (storeIndicator.symbol === currentSymbol && + storeIndicator.tv_study_id && + !seenStudyIds.has(storeIndicator.tv_study_id)) { + console.log('[Indicators] Indicator deleted:', storeIndicator.id) + indicatorStore.removeIndicator(storeIndicator.id) + } + } + } finally { + isUpdatingStore = false + } + } catch (error) { + console.error('[Indicators] Error syncing indicators from TV:', error) + } + } + + /** + * Setup watchers for IndicatorStore changes to apply to TradingView + */ + function setupStoreWatchers() { + watch( + () => indicatorStore.indicators, + async (newIndicators, oldIndicators) => { + if (isUpdatingStore || !isChartReady) return + + isApplyingTVUpdate = true + + try { + // Check if widget is ready before accessing activeChart + if (!tvWidget || typeof tvWidget.activeChart !== 'function') { + console.log('[Indicators] Widget not ready, skipping store update') + isApplyingTVUpdate = false + return + } + + const chart = tvWidget.activeChart() + if (!chart) { + console.log('[Indicators] Chart not available, skipping store update') + isApplyingTVUpdate = false + return + } + + const currentSymbol = chartStore.chart_state.symbol + + // Find added indicators + for (const [id, indicator] of Object.entries(newIndicators)) { + if (indicator.symbol && indicator.symbol !== currentSymbol) continue + + if (!oldIndicators || !oldIndicators[id]) { + // Indicator was added to store + await createTVStudy(indicator) + } else { + // Check if indicator exists in TradingView + let study = null + if (indicator.tv_study_id) { + try { + study = chart.getStudyById(indicator.tv_study_id) + } catch (e) { + // Study doesn't exist + } + } + + if (!study) { + // Indicator is in store but not in TradingView - create it + await createTVStudy(indicator) + } else if (JSON.stringify(indicator) !== JSON.stringify(oldIndicators[id])) { + // Indicator was modified + updateTVStudy(indicator) + } + } + } + + // Find removed indicators + if (oldIndicators) { + for (const [id, indicator] of Object.entries(oldIndicators)) { + if (indicator.symbol && indicator.symbol !== currentSymbol) continue + + if (!newIndicators[id]) { + removeTVStudy(indicator) + } + } + } + } catch (error) { + console.error('[Indicators] Error applying store changes:', error) + } finally { + isApplyingTVUpdate = false + } + }, + { deep: true } + ) + } + + /** + * Create an indicator in TradingView + */ + async function createTVStudy(indicator: IndicatorInstance) { + if (!isChartReady) return + + try { + const chart = tvWidget.activeChart() + if (!chart) return + + const currentSymbol = chartStore.chart_state.symbol + const tvName = ALL_BACKEND_TO_TV_NAMES[indicator.talib_name] + + if (!tvName) { + console.warn('[Indicators] No TradingView mapping for backend indicator:', indicator.talib_name) + return + } + + const tvInputs = convertTALibParamsToTVInputs(indicator.talib_name, indicator.parameters) + + console.log(`[Indicators] Creating TradingView study: ${tvName} with inputs:`, tvInputs) + + const studyId = await chart.createStudy(tvName, false, false, tvInputs) + + // If TradingView assigned a different ID, update the store + if (studyId && studyId !== indicator.tv_study_id) { + isUpdatingStore = true + await new Promise(resolve => setTimeout(resolve, 0)) + + try { + indicatorStore.updateIndicator(indicator.id, { + tv_study_id: studyId, + symbol: indicator.symbol || currentSymbol + }) + } finally { + await new Promise(resolve => setTimeout(resolve, 0)) + isUpdatingStore = false + } + } + } catch (error) { + console.error('[Indicators] Error creating TV study:', error) + } + } + + /** + * Update an indicator in TradingView + */ + function updateTVStudy(indicator: IndicatorInstance) { + if (!isChartReady || !indicator.tv_study_id) return + + try { + const chart = tvWidget.activeChart() + if (!chart) return + + const study = chart.getStudyById(indicator.tv_study_id) + if (!study) { + console.warn('[Indicators] Study not found in TV, creating instead:', indicator.id) + createTVStudy(indicator) + return + } + + const tvInputs = convertTALibParamsToTVInputs(indicator.talib_name, indicator.parameters) + + // Update study inputs + chart.getStudyById(indicator.tv_study_id).applyOverrides(tvInputs) + + console.log('[Indicators] Updated study in TV:', indicator.id) + } catch (error) { + console.error('[Indicators] Error updating TV study:', error) + } + } + + /** + * Remove an indicator from TradingView + */ + function removeTVStudy(indicator: IndicatorInstance) { + if (!isChartReady || !indicator.tv_study_id) return + + try { + const chart = tvWidget.activeChart() + if (!chart) return + + chart.removeStudy(indicator.tv_study_id) + console.log('[Indicators] Removed study from TV:', indicator.id) + } catch (error) { + console.error('[Indicators] Error removing TV study:', error) + } + } + + // Initialize + const cleanupIndicatorListeners = setupIndicatorListeners() + setupStoreWatchers() + + // Return cleanup function + return () => { + cleanupIndicatorListeners() + } +} diff --git a/web/src/stores/indicators.ts b/web/src/stores/indicators.ts new file mode 100644 index 0000000..1366c3f --- /dev/null +++ b/web/src/stores/indicators.ts @@ -0,0 +1,64 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface IndicatorInstance { + id: string + talib_name: string + instance_name: string + parameters: Record + tv_study_id?: string + tv_indicator_name?: string + tv_inputs?: Record + visible: boolean + pane: string + symbol?: string + created_at?: number + modified_at?: number + original_id?: string +} + +export const useIndicatorStore = defineStore('IndicatorStore', () => { + const indicators = ref>({}) + + // Helper methods + const addIndicator = (indicator: IndicatorInstance) => { + indicators.value[indicator.id] = indicator + } + + const updateIndicator = (id: string, updates: Partial) => { + if (indicators.value[id]) { + const updated = { + ...indicators.value[id], + ...updates, + modified_at: Math.floor(Date.now() / 1000) + } + indicators.value[id] = updated + } + } + + const removeIndicator = (id: string) => { + delete indicators.value[id] + } + + const getIndicator = (id: string): IndicatorInstance | undefined => { + return indicators.value[id] + } + + const getAllIndicators = (): IndicatorInstance[] => { + return Object.values(indicators.value) + } + + const getIndicatorsBySymbol = (symbol: string): IndicatorInstance[] => { + return Object.values(indicators.value).filter(ind => ind.symbol === symbol) + } + + return { + indicators, + addIndicator, + updateIndicator, + removeIndicator, + getIndicator, + getAllIndicators, + getIndicatorsBySymbol + } +})