indicator integration
This commit is contained in:
@@ -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 typing import Dict, Any, List, Optional
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_indicator_registry():
|
def _get_indicator_registry():
|
||||||
@@ -10,6 +21,20 @@ def _get_indicator_registry():
|
|||||||
return _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
|
@tool
|
||||||
def list_indicators() -> List[str]:
|
def list_indicators() -> List[str]:
|
||||||
"""List all available technical indicators.
|
"""List all available technical indicators.
|
||||||
@@ -161,9 +186,250 @@ def get_indicator_categories() -> Dict[str, int]:
|
|||||||
return categories
|
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 = [
|
INDICATOR_TOOLS = [
|
||||||
|
# Discovery tools
|
||||||
list_indicators,
|
list_indicators,
|
||||||
get_indicator_info,
|
get_indicator_info,
|
||||||
search_indicators,
|
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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ from .talib_adapter import (
|
|||||||
is_talib_available,
|
is_talib_available,
|
||||||
get_talib_version,
|
get_talib_version,
|
||||||
)
|
)
|
||||||
|
from .custom_indicators import (
|
||||||
|
register_custom_indicators,
|
||||||
|
CUSTOM_INDICATORS,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Core classes
|
# Core classes
|
||||||
@@ -169,4 +173,7 @@ __all__ = [
|
|||||||
"register_all_talib_indicators",
|
"register_all_talib_indicators",
|
||||||
"is_talib_available",
|
"is_talib_available",
|
||||||
"get_talib_version",
|
"get_talib_version",
|
||||||
|
# Custom indicators
|
||||||
|
"register_custom_indicators",
|
||||||
|
"CUSTOM_INDICATORS",
|
||||||
]
|
]
|
||||||
|
|||||||
954
backend/src/indicator/custom_indicators.py
Normal file
954
backend/src/indicator/custom_indicators.py
Normal file
@@ -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
|
||||||
@@ -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.
|
Auto-register all available TA-Lib indicators with the registry.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
registry: IndicatorRegistry instance
|
registry: IndicatorRegistry instance
|
||||||
|
only_tradingview_supported: If True, only register indicators that have
|
||||||
|
TradingView equivalents (default: True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of indicators registered
|
Number of indicators registered
|
||||||
@@ -392,6 +394,9 @@ def register_all_talib_indicators(registry) -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# Get list of supported indicators if filtering is enabled
|
||||||
|
from .tv_mapping import is_indicator_supported
|
||||||
|
|
||||||
# Get all TA-Lib functions
|
# Get all TA-Lib functions
|
||||||
func_groups = talib.get_function_groups()
|
func_groups = talib.get_function_groups()
|
||||||
all_functions = []
|
all_functions = []
|
||||||
@@ -402,8 +407,16 @@ def register_all_talib_indicators(registry) -> int:
|
|||||||
all_functions = sorted(set(all_functions))
|
all_functions = sorted(set(all_functions))
|
||||||
|
|
||||||
registered_count = 0
|
registered_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
for func_name in all_functions:
|
for func_name in all_functions:
|
||||||
try:
|
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
|
# Create indicator class for this function
|
||||||
indicator_class = create_talib_indicator_class(func_name)
|
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}")
|
logger.warning(f"Failed to register TA-Lib function {func_name}: {e}")
|
||||||
continue
|
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
|
return registered_count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
360
backend/src/indicator/tv_mapping.py
Normal file
360
backend/src/indicator/tv_mapping.py
Normal file
@@ -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)
|
||||||
@@ -24,11 +24,12 @@ from agent.tools import set_registry, set_datasource_registry, set_indicator_reg
|
|||||||
from schema.order_spec import SwapOrder
|
from schema.order_spec import SwapOrder
|
||||||
from schema.chart_state import ChartState
|
from schema.chart_state import ChartState
|
||||||
from schema.shape import ShapeCollection
|
from schema.shape import ShapeCollection
|
||||||
|
from schema.indicator import IndicatorCollection
|
||||||
from datasource.registry import DataSourceRegistry
|
from datasource.registry import DataSourceRegistry
|
||||||
from datasource.subscription_manager import SubscriptionManager
|
from datasource.subscription_manager import SubscriptionManager
|
||||||
from datasource.websocket_handler import DatafeedWebSocketHandler
|
from datasource.websocket_handler import DatafeedWebSocketHandler
|
||||||
from secrets_manager import SecretsStore, InvalidMasterPassword
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -93,6 +94,13 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning(f"Failed to register TA-Lib indicators: {e}")
|
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.")
|
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
|
# Get API keys from secrets store if unlocked, otherwise fall back to environment
|
||||||
anthropic_api_key = None
|
anthropic_api_key = None
|
||||||
|
|
||||||
@@ -164,15 +172,21 @@ class ChartStore(BaseModel):
|
|||||||
class ShapeStore(BaseModel):
|
class ShapeStore(BaseModel):
|
||||||
shapes: dict[str, dict] = {} # Dictionary of shapes keyed by ID
|
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
|
# Initialize stores
|
||||||
order_store = OrderStore()
|
order_store = OrderStore()
|
||||||
chart_store = ChartStore()
|
chart_store = ChartStore()
|
||||||
shape_store = ShapeStore()
|
shape_store = ShapeStore()
|
||||||
|
indicator_store = IndicatorStore()
|
||||||
|
|
||||||
# Register with SyncRegistry
|
# Register with SyncRegistry
|
||||||
registry.register(order_store, store_name="OrderStore")
|
registry.register(order_store, store_name="OrderStore")
|
||||||
registry.register(chart_store, store_name="ChartStore")
|
registry.register(chart_store, store_name="ChartStore")
|
||||||
registry.register(shape_store, store_name="ShapeStore")
|
registry.register(shape_store, store_name="ShapeStore")
|
||||||
|
registry.register(indicator_store, store_name="IndicatorStore")
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
|||||||
40
backend/src/schema/indicator.py
Normal file
40
backend/src/schema/indicator.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
@@ -116,6 +116,10 @@ class SyncRegistry:
|
|||||||
logger.info(f"apply_client_patch: New state after patch: {new_state}")
|
logger.info(f"apply_client_patch: New state after patch: {new_state}")
|
||||||
self._update_model(entry.model, 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)
|
entry.commit_patch(patch)
|
||||||
logger.info(f"apply_client_patch: Patch committed, new seq={entry.seq}")
|
logger.info(f"apply_client_patch: Patch committed, new seq={entry.seq}")
|
||||||
# Don't broadcast back to client - they already have this change
|
# 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"))
|
await self.websocket.send_json(msg.model_dump(mode="json"))
|
||||||
|
|
||||||
def _update_model(self, model: BaseModel, new_data: Dict[str, Any]):
|
def _update_model(self, model: BaseModel, new_data: Dict[str, Any]):
|
||||||
# Update model using model_validate for potentially nested models
|
# Update model fields in-place to preserve references
|
||||||
new_model = model.__class__.model_validate(new_data)
|
# This is important for dict fields that may be referenced elsewhere
|
||||||
for field in model.model_fields:
|
for field_name, field_info in model.model_fields.items():
|
||||||
setattr(model, field, getattr(new_model, field))
|
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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import LoginScreen from './components/LoginScreen.vue'
|
|||||||
import { useOrderStore } from './stores/orders'
|
import { useOrderStore } from './stores/orders'
|
||||||
import { useChartStore } from './stores/chart'
|
import { useChartStore } from './stores/chart'
|
||||||
import { useShapeStore } from './stores/shapes'
|
import { useShapeStore } from './stores/shapes'
|
||||||
|
import { useIndicatorStore } from './stores/indicators'
|
||||||
import { useStateSync } from './composables/useStateSync'
|
import { useStateSync } from './composables/useStateSync'
|
||||||
import { wsManager } from './composables/useWebSocket'
|
import { wsManager } from './composables/useWebSocket'
|
||||||
|
|
||||||
@@ -65,10 +66,12 @@ const handleAuthenticate = async (
|
|||||||
const orderStore = useOrderStore()
|
const orderStore = useOrderStore()
|
||||||
const chartStore = useChartStore()
|
const chartStore = useChartStore()
|
||||||
const shapeStore = useShapeStore()
|
const shapeStore = useShapeStore()
|
||||||
|
const indicatorStore = useIndicatorStore()
|
||||||
const stateSync = useStateSync({
|
const stateSync = useStateSync({
|
||||||
OrderStore: orderStore,
|
OrderStore: orderStore,
|
||||||
ChartStore: chartStore,
|
ChartStore: chartStore,
|
||||||
ShapeStore: shapeStore
|
ShapeStore: shapeStore,
|
||||||
|
IndicatorStore: indicatorStore
|
||||||
})
|
})
|
||||||
stateSyncCleanup = stateSync.cleanup
|
stateSyncCleanup = stateSync.cleanup
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
|||||||
import Card from 'primevue/card'
|
import Card from 'primevue/card'
|
||||||
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
|
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
|
||||||
import { useTradingViewShapes } from '../composables/useTradingViewShapes'
|
import { useTradingViewShapes } from '../composables/useTradingViewShapes'
|
||||||
|
import { useTradingViewIndicators } from '../composables/useTradingViewIndicators'
|
||||||
import { useChartStore } from '../stores/chart'
|
import { useChartStore } from '../stores/chart'
|
||||||
import type { IChartingLibraryWidget } from '../types/tradingview'
|
import type { IChartingLibraryWidget } from '../types/tradingview'
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ let tvWidget: IChartingLibraryWidget | null = null
|
|||||||
let datafeed: any = null
|
let datafeed: any = null
|
||||||
let isUpdatingFromChart = false // Flag to prevent circular updates
|
let isUpdatingFromChart = false // Flag to prevent circular updates
|
||||||
let shapeCleanup: (() => void) | null = null // Cleanup function for shape sync
|
let shapeCleanup: (() => void) | null = null // Cleanup function for shape sync
|
||||||
|
let indicatorCleanup: (() => void) | null = null // Cleanup function for indicator sync
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!chartContainer.value) return
|
if (!chartContainer.value) return
|
||||||
@@ -39,6 +41,91 @@ onMounted(() => {
|
|||||||
'symbol_search_hot_key'
|
'symbol_search_hot_key'
|
||||||
],
|
],
|
||||||
enabled_features: ['study_templates'],
|
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,
|
fullscreen: false,
|
||||||
autosize: true,
|
autosize: true,
|
||||||
theme: 'Dark',
|
theme: 'Dark',
|
||||||
@@ -54,6 +141,7 @@ onMounted(() => {
|
|||||||
// Setup shape synchronization
|
// Setup shape synchronization
|
||||||
if (tvWidget) {
|
if (tvWidget) {
|
||||||
shapeCleanup = useTradingViewShapes(tvWidget)
|
shapeCleanup = useTradingViewShapes(tvWidget)
|
||||||
|
indicatorCleanup = useTradingViewIndicators(tvWidget)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -177,6 +265,12 @@ onBeforeUnmount(() => {
|
|||||||
shapeCleanup = null
|
shapeCleanup = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup indicator synchronization
|
||||||
|
if (indicatorCleanup) {
|
||||||
|
indicatorCleanup()
|
||||||
|
indicatorCleanup = null
|
||||||
|
}
|
||||||
|
|
||||||
if (tvWidget) {
|
if (tvWidget) {
|
||||||
tvWidget.remove()
|
tvWidget.remove()
|
||||||
tvWidget = null
|
tvWidget = null
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const newPassword = ref('')
|
|||||||
const confirmNewPassword = ref('')
|
const confirmNewPassword = ref('')
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isChangingPassword = ref(false)
|
const isChangingPassword = ref(false)
|
||||||
|
const passwordInput = ref<InstanceType<typeof Password> | null>(null)
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
if (!password.value || isLoading.value) return false
|
if (!password.value || isLoading.value) return false
|
||||||
@@ -69,9 +70,11 @@ const togglePasswordChange = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Focus on the password input when component mounts
|
// Focus on the password input when component mounts
|
||||||
const passwordInput = document.querySelector('#password input') as HTMLInputElement
|
if (passwordInput.value?.$el) {
|
||||||
if (passwordInput) {
|
const inputElement = passwordInput.value.$el.querySelector('input') as HTMLInputElement
|
||||||
passwordInput.focus()
|
if (inputElement) {
|
||||||
|
inputElement.focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -105,6 +108,7 @@ onMounted(() => {
|
|||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label for="password">{{ isChangingPassword ? 'Current Password' : 'Password' }}</label>
|
<label for="password">{{ isChangingPassword ? 'Current Password' : 'Password' }}</label>
|
||||||
<Password
|
<Password
|
||||||
|
ref="passwordInput"
|
||||||
id="password"
|
id="password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
:feedback="needsConfirmation"
|
:feedback="needsConfirmation"
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ export function useStateSync(stores: Record<string, Store>) {
|
|||||||
patch: patch
|
patch: patch
|
||||||
};
|
};
|
||||||
sendJson(msg);
|
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
|
// Set up watchers for each store to send patches on changes
|
||||||
|
|||||||
905
web/src/composables/useTradingViewIndicators.ts
Normal file
905
web/src/composables/useTradingViewIndicators.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
// 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<string, string> = {
|
||||||
|
'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<string, string> = {
|
||||||
|
...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<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(TALIB_TO_TV_NAMES).map(([k, v]) => [v, k])
|
||||||
|
)
|
||||||
|
|
||||||
|
const TV_TO_CUSTOM_NAMES: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(CUSTOM_TO_TV_NAMES).map(([k, v]) => [v, k])
|
||||||
|
)
|
||||||
|
|
||||||
|
const TV_TO_BACKEND_NAMES: Record<string, string> = 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<string, any>): Record<string, any> {
|
||||||
|
const tvInputs: Record<string, any> = {}
|
||||||
|
|
||||||
|
// Common parameter mappings
|
||||||
|
const paramMapping: Record<string, string> = {
|
||||||
|
'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<string, any>): { talibName: string | null, talibParams: Record<string, any> } {
|
||||||
|
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<string, any> = {}
|
||||||
|
|
||||||
|
// Reverse parameter mappings
|
||||||
|
const reverseMapping: Record<string, string> = {
|
||||||
|
'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<string>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
64
web/src/stores/indicators.ts
Normal file
64
web/src/stores/indicators.ts
Normal file
@@ -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<string, any>
|
||||||
|
tv_study_id?: string
|
||||||
|
tv_indicator_name?: string
|
||||||
|
tv_inputs?: Record<string, any>
|
||||||
|
visible: boolean
|
||||||
|
pane: string
|
||||||
|
symbol?: string
|
||||||
|
created_at?: number
|
||||||
|
modified_at?: number
|
||||||
|
original_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIndicatorStore = defineStore('IndicatorStore', () => {
|
||||||
|
const indicators = ref<Record<string, IndicatorInstance>>({})
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
const addIndicator = (indicator: IndicatorInstance) => {
|
||||||
|
indicators.value[indicator.id] = indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateIndicator = (id: string, updates: Partial<IndicatorInstance>) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user