909 lines
34 KiB
TypeScript
909 lines
34 KiB
TypeScript
import { watch } from 'vue'
|
|
import type { IChartingLibraryWidget, IStudyApi } from '../types/tradingview'
|
|
import { useIndicatorStore } from '../stores/indicators'
|
|
import { useIndicatorTypesStore } from '../stores/indicatorTypes'
|
|
import { useChartStore } from '../stores/chart'
|
|
import type { IndicatorInstance } from '../stores/indicators'
|
|
|
|
/**
|
|
* Mapping between pandas-ta indicator function names and TradingView indicator display names.
|
|
* Only includes indicators present in BOTH systems (inner join).
|
|
* Keys are lowercase pandas-ta function names; values are TradingView display strings.
|
|
*/
|
|
const PANDAS_TA_TO_TV_NAMES: Record<string, string> = {
|
|
// Overlap / Moving Averages (17)
|
|
'sma': 'Moving Average',
|
|
'ema': 'Moving Average Exponential',
|
|
'wma': 'Moving Average Weighted',
|
|
'dema': 'Double EMA',
|
|
'tema': 'Triple EMA',
|
|
'trima': 'Triangular Moving Average',
|
|
'kama': 'Moving Average Adaptive',
|
|
't3': 'T3',
|
|
'hma': 'Hull Moving Average',
|
|
'alma': 'Arnaud Legoux Moving Average',
|
|
'midpoint': 'Midpoint',
|
|
'midprice': 'Midprice',
|
|
'supertrend': 'SuperTrend',
|
|
'ichimoku': 'Ichimoku Cloud',
|
|
'vwap': 'VWAP',
|
|
'vwma': 'VWMA',
|
|
'bbands': 'Bollinger Bands',
|
|
|
|
// Momentum (22)
|
|
'rsi': 'Relative Strength Index',
|
|
'macd': 'MACD',
|
|
'stoch': 'Stochastic',
|
|
'stochrsi':'Stochastic RSI',
|
|
'cci': 'Commodity Channel Index',
|
|
'willr': 'Williams %R',
|
|
'mom': 'Momentum',
|
|
'roc': 'Rate Of Change',
|
|
'trix': 'TRIX',
|
|
'cmo': 'Chande Momentum Oscillator',
|
|
'adx': 'Average Directional Index',
|
|
'aroon': 'Aroon',
|
|
'ao': 'Awesome Oscillator',
|
|
'bop': 'Balance of Power',
|
|
'uo': 'Ultimate Oscillator',
|
|
'apo': 'Price Oscillator',
|
|
'mfi': 'Money Flow Index',
|
|
'coppock': 'Coppock Curve',
|
|
'dpo': 'Detrended Price Oscillator',
|
|
'fisher': 'Fisher Transform',
|
|
'rvgi': 'Relative Vigor Index',
|
|
'kst': 'Know Sure Thing',
|
|
|
|
// Volatility (3)
|
|
'atr': 'Average True Range',
|
|
'kc': 'Keltner Channels',
|
|
'donchian': 'Donchian Channels',
|
|
|
|
// Volume (8)
|
|
'obv': 'On Balance Volume',
|
|
'ad': 'Accumulation/Distribution',
|
|
'adosc': 'Chaikin Oscillator',
|
|
'cmf': 'Chaikin Money Flow',
|
|
'eom': 'Ease of Movement',
|
|
'efi': "Elder's Force Index",
|
|
'kvo': 'Klinger Oscillator',
|
|
'pvt': 'Price Volume Trend',
|
|
|
|
// Statistics / Price Transforms (6)
|
|
'stdev': 'Standard Deviation',
|
|
'linreg': 'Linear Regression Curve',
|
|
'slope': 'Linear Regression Slope',
|
|
'hl2': 'Median Price',
|
|
'hlc3': 'Typical Price',
|
|
'ohlc4': 'Average Price',
|
|
|
|
// Trend (3)
|
|
'psar': 'Parabolic SAR',
|
|
'vortex': 'Vortex Indicator',
|
|
'chop': 'Choppiness Index',
|
|
}
|
|
|
|
// Total: 59 indicators
|
|
|
|
/**
|
|
* Reverse mapping from TradingView display name to pandas-ta function name
|
|
*/
|
|
const TV_TO_PANDAS_TA_NAMES: Record<string, string> = Object.fromEntries(
|
|
Object.entries(PANDAS_TA_TO_TV_NAMES).map(([k, v]) => [v, k])
|
|
)
|
|
|
|
/**
|
|
* Convert pandas-ta parameters to TradingView inputs
|
|
*/
|
|
function convertPandasTaParamsToTVInputs(pandasTaName: string, params: Record<string, any>): Record<string, any> {
|
|
const tvInputs: Record<string, any> = {}
|
|
|
|
if (pandasTaName === 'bbands') {
|
|
tvInputs.length = params.length || 20
|
|
tvInputs.mult = params.upper_std ?? params.std ?? 2
|
|
tvInputs.source = 'close'
|
|
} else if (pandasTaName === 'macd') {
|
|
tvInputs.fastLength = params.fast || 12
|
|
tvInputs.slowLength = params.slow || 26
|
|
tvInputs.signalLength = params.signal || 9
|
|
tvInputs.source = 'close'
|
|
} else if (pandasTaName === 'stoch') {
|
|
tvInputs.kPeriod = params.k || 14
|
|
tvInputs.dPeriod = params.d || 3
|
|
tvInputs.smoothK = params.smooth_k || 3
|
|
} else if (pandasTaName === 'stochrsi') {
|
|
tvInputs.length = params.length || 14
|
|
tvInputs.rsiLength = params.rsi_length || 14
|
|
tvInputs.kPeriod = params.k || 3
|
|
tvInputs.dPeriod = params.d || 3
|
|
} else if (pandasTaName === 'psar') {
|
|
tvInputs.start = params.af0 || 0.02
|
|
tvInputs.increment = params.af || 0.02
|
|
tvInputs.max = params.max_af || 0.2
|
|
} else if (pandasTaName === 'ichimoku') {
|
|
tvInputs.conversionPeriod = params.tenkan || 9
|
|
tvInputs.basePeriod = params.kijun || 26
|
|
tvInputs.laggingSpanPeriod = params.senkou || 52
|
|
} else if (pandasTaName === 'vwap') {
|
|
tvInputs.anchor = params.anchor || 'D'
|
|
} else if (['apo', 'ppo'].includes(pandasTaName)) {
|
|
tvInputs.fastLength = params.fast || 12
|
|
tvInputs.slowLength = params.slow || 26
|
|
tvInputs.source = 'close'
|
|
} else if (pandasTaName === 'uo') {
|
|
tvInputs.fast = params.fast || 7
|
|
tvInputs.medium = params.medium || 14
|
|
tvInputs.slow = params.slow || 28
|
|
} else if (pandasTaName === 'adosc') {
|
|
tvInputs.fastLength = params.fast || 3
|
|
tvInputs.slowLength = params.slow || 10
|
|
} else if (pandasTaName === 'kc') {
|
|
tvInputs.length = params.length || 20
|
|
tvInputs.mult = params.scalar || 2
|
|
} else {
|
|
// Generic: pass length through; skip source
|
|
if (params.length !== undefined) tvInputs.length = params.length
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (k === 'length' || k === 'source') continue
|
|
tvInputs[k] = v
|
|
}
|
|
}
|
|
|
|
return tvInputs
|
|
}
|
|
|
|
/**
|
|
* Convert TradingView inputs to pandas-ta parameters
|
|
*/
|
|
function convertTVInputsToPandasTaParams(tvName: string, tvInputs: Record<string, any>): { pandasTaName: string | null, pandasTaParams: Record<string, any> } {
|
|
const pandasTaName = TV_TO_PANDAS_TA_NAMES[tvName] || null
|
|
if (!pandasTaName) {
|
|
console.warn('[Indicators] No pandas-ta mapping for TradingView indicator:', tvName)
|
|
return { pandasTaName: null, pandasTaParams: {} }
|
|
}
|
|
|
|
const pandasTaParams: Record<string, any> = {}
|
|
|
|
if (pandasTaName === 'bbands') {
|
|
pandasTaParams.length = tvInputs.in_0 ?? tvInputs.length ?? 20
|
|
const std = tvInputs.in_1 ?? tvInputs.mult ?? 2
|
|
pandasTaParams.upper_std = std
|
|
pandasTaParams.lower_std = std
|
|
} else if (pandasTaName === 'macd') {
|
|
pandasTaParams.fast = tvInputs.fastLength ?? 12
|
|
pandasTaParams.slow = tvInputs.slowLength ?? 26
|
|
pandasTaParams.signal = tvInputs.signalLength ?? 9
|
|
} else if (pandasTaName === 'stoch') {
|
|
pandasTaParams.k = tvInputs.kPeriod ?? 14
|
|
pandasTaParams.d = tvInputs.dPeriod ?? 3
|
|
pandasTaParams.smooth_k = tvInputs.smoothK ?? 3
|
|
} else if (pandasTaName === 'stochrsi') {
|
|
pandasTaParams.length = tvInputs.length ?? 14
|
|
pandasTaParams.rsi_length = tvInputs.rsiLength ?? 14
|
|
pandasTaParams.k = tvInputs.kPeriod ?? 3
|
|
pandasTaParams.d = tvInputs.dPeriod ?? 3
|
|
} else if (pandasTaName === 'psar') {
|
|
pandasTaParams.af0 = tvInputs.start ?? 0.02
|
|
pandasTaParams.af = tvInputs.increment ?? 0.02
|
|
pandasTaParams.max_af = tvInputs.max ?? 0.2
|
|
} else if (pandasTaName === 'ichimoku') {
|
|
pandasTaParams.tenkan = tvInputs.conversionPeriod ?? 9
|
|
pandasTaParams.kijun = tvInputs.basePeriod ?? 26
|
|
pandasTaParams.senkou = tvInputs.laggingSpanPeriod ?? 52
|
|
} else if (pandasTaName === 'vwap') {
|
|
pandasTaParams.anchor = tvInputs.anchor ?? 'D'
|
|
} else if (['apo', 'ppo'].includes(pandasTaName)) {
|
|
pandasTaParams.fast = tvInputs.fastLength ?? 12
|
|
pandasTaParams.slow = tvInputs.slowLength ?? 26
|
|
} else if (pandasTaName === 'uo') {
|
|
pandasTaParams.fast = tvInputs.fast ?? 7
|
|
pandasTaParams.medium = tvInputs.medium ?? 14
|
|
pandasTaParams.slow = tvInputs.slow ?? 28
|
|
} else if (pandasTaName === 'adosc') {
|
|
pandasTaParams.fast = tvInputs.fastLength ?? 3
|
|
pandasTaParams.slow = tvInputs.slowLength ?? 10
|
|
} else if (pandasTaName === 'kc') {
|
|
pandasTaParams.length = tvInputs.length ?? 20
|
|
pandasTaParams.scalar = tvInputs.mult ?? 2
|
|
} else {
|
|
// Generic: extract length; skip source
|
|
for (const [k, v] of Object.entries(tvInputs)) {
|
|
if (k === 'source') continue
|
|
pandasTaParams[k] = v
|
|
}
|
|
// Normalise TV in_0 → length for simple single-period indicators
|
|
if (tvInputs.in_0 !== undefined && pandasTaParams.length === undefined) {
|
|
pandasTaParams.length = tvInputs.in_0
|
|
delete pandasTaParams.in_0
|
|
}
|
|
}
|
|
|
|
return { pandasTaName, pandasTaParams }
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
let isUnmounting = false // Set during cleanup to suppress study_event: remove from widget destruction
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
}
|
|
|
|
// Fallback: metaInfo.name is the registered study name (e.g. dxo_ind_custom_trendflex)
|
|
if (studyName === 'Unknown' || (!studyName.startsWith('dxo_ind_') && !TV_TO_PANDAS_TA_NAMES[studyName])) {
|
|
const metaName = tvStudy._study?.name?.() ||
|
|
tvStudy._metaInfo?.name ||
|
|
tvStudy.metaInfo?.()?.name
|
|
if (metaName && typeof metaName === 'string') {
|
|
console.log('[Indicators] Overriding studyName with metaInfo.name:', metaName)
|
|
studyName = metaName
|
|
}
|
|
}
|
|
|
|
// Last resort: if still unresolved, check all type descriptions
|
|
if (!studyName.startsWith('dxo_ind_') && !TV_TO_PANDAS_TA_NAMES[studyName]) {
|
|
const indicatorTypesStore = useIndicatorTypesStore()
|
|
const title = typeof tvStudy.title === 'function' ? tvStudy.title() : null
|
|
const desc = tvStudy._metaInfo?.description || tvStudy._metaInfo?.shortDescription
|
|
// Also match against studyName itself (TV returns display name from study().name())
|
|
const matchByDesc = Object.values(indicatorTypesStore.types).find(
|
|
t => t.display_name === studyName || t.display_name === title || t.display_name === desc
|
|
)
|
|
if (matchByDesc) {
|
|
studyName = `dxo_ind_${matchByDesc.pandas_ta_name}`
|
|
console.log('[Indicators] Resolved studyName by description match:', studyName)
|
|
}
|
|
}
|
|
|
|
console.log('[Indicators] Study name extracted:', studyName)
|
|
|
|
// Handle our named custom indicator studies (dxo_ind_ prefix)
|
|
if (studyName.startsWith('dxo_ind_')) {
|
|
const pandasTaNameFromStudy = studyName.slice('dxo_ind_'.length)
|
|
const indicatorTypesStore = useIndicatorTypesStore()
|
|
const typeEntry = indicatorTypesStore.types[pandasTaNameFromStudy]
|
|
if (!typeEntry) {
|
|
console.log('[Indicators] Custom indicator type not found in store:', pandasTaNameFromStudy)
|
|
return null
|
|
}
|
|
const defaultParams = Object.fromEntries(
|
|
Object.entries(typeEntry.metadata.parameters).map(([k, v]) => [k, v.default])
|
|
)
|
|
const now = Math.floor(Date.now() / 1000)
|
|
return {
|
|
id: studyId || `ind_${Date.now()}`,
|
|
pandas_ta_name: pandasTaNameFromStudy,
|
|
instance_name: typeEntry.display_name,
|
|
parameters: defaultParams,
|
|
tv_study_id: studyId,
|
|
tv_indicator_name: studyName,
|
|
visible: true,
|
|
pane: typeEntry.metadata.pane,
|
|
symbol,
|
|
created_at: now,
|
|
modified_at: now,
|
|
custom_metadata: typeEntry.metadata,
|
|
} as IndicatorInstance
|
|
}
|
|
|
|
const pandasTaName = TV_TO_PANDAS_TA_NAMES[studyName]
|
|
console.log('[Indicators] pandas-ta mapping:', studyName, '->', pandasTaName)
|
|
|
|
if (!pandasTaName) {
|
|
console.log('[Indicators] TradingView study not mapped to pandas-ta:', studyName)
|
|
console.log('[Indicators] Available mappings:', Object.keys(TV_TO_PANDAS_TA_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 { pandasTaParams } = convertTVInputsToPandasTaParams(studyName, tvInputs)
|
|
console.log('[Indicators] Converted pandas-ta params:', pandasTaParams)
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
const indicator = {
|
|
id: studyId || tvStudy.id || `ind_${Date.now()}`,
|
|
pandas_ta_name: pandasTaName,
|
|
instance_name: `${pandasTaName}_${Date.now()}`,
|
|
parameters: pandasTaParams,
|
|
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
|
|
|
|
// Apply any indicators that arrived before chart was ready (e.g. from workspace sync on page load)
|
|
const pendingIndicators = Object.values(indicatorStore.indicators).filter(ind => !ind.tv_study_id)
|
|
if (pendingIndicators.length > 0) {
|
|
console.log('[Indicators] Chart ready, applying', pendingIndicators.length, 'pending indicators from store')
|
|
isApplyingTVUpdate = true
|
|
;(async () => {
|
|
try {
|
|
for (const indicator of pendingIndicators) {
|
|
await createTVStudy(indicator)
|
|
}
|
|
} finally {
|
|
isApplyingTVUpdate = false
|
|
}
|
|
})()
|
|
}
|
|
|
|
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.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 {
|
|
// Guard against feedback loop: if we created this study from the store
|
|
// (via createTVStudy or registerCustomStudy), the store entry already has
|
|
// tv_study_id set by the time this setTimeout fires. Skip in that case.
|
|
const alreadyTracked = Object.values(indicatorStore.indicators).find(
|
|
ind => ind.tv_study_id === actualStudyId
|
|
)
|
|
if (alreadyTracked) {
|
|
console.log('[Indicators] Study already tracked in store, skipping create event:', alreadyTracked.id)
|
|
return
|
|
}
|
|
|
|
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)
|
|
|
|
if (isUnmounting) {
|
|
console.log('[Indicators] Ignoring study remove during widget destruction')
|
|
return
|
|
}
|
|
|
|
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.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)
|
|
|
|
// Custom indicators expose param_* inputs that map to their parameters.
|
|
// Extract those and update the store (useCustomIndicators will re-fetch data).
|
|
if (existingIndicator.pandas_ta_name.startsWith('custom_')) {
|
|
if (typeof study.getInputValues === 'function') {
|
|
const inputsArray = study.getInputValues()
|
|
if (Array.isArray(inputsArray)) {
|
|
const newParams: Record<string, any> = {}
|
|
for (const input of inputsArray) {
|
|
if (input.id && input.id.startsWith('param_')) {
|
|
newParams[input.id.slice(6)] = input.value
|
|
}
|
|
}
|
|
if (Object.keys(newParams).length > 0) {
|
|
const hasChanged = Object.entries(newParams).some(
|
|
([k, v]) => existingIndicator.parameters[k] !== v
|
|
)
|
|
if (hasChanged) {
|
|
console.log('[Indicators] Custom indicator params changed:', newParams)
|
|
isUpdatingStore = true
|
|
indicatorStore.updateIndicator(existingIndicator.id, { parameters: newParams })
|
|
isUpdatingStore = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
|
|
// 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 { pandasTaParams } = convertTVInputsToPandasTaParams(studyName, tvInputs)
|
|
|
|
console.log('[Indicators] Old params:', existingIndicator.parameters)
|
|
console.log('[Indicators] New params:', pandasTaParams)
|
|
|
|
// Update the store with new parameters
|
|
if (JSON.stringify(existingIndicator.parameters) !== JSON.stringify(pandasTaParams)) {
|
|
console.log('[Indicators] Parameters changed, updating store...')
|
|
isUpdatingStore = true
|
|
indicatorStore.updateIndicator(existingIndicator.id, {
|
|
parameters: pandasTaParams,
|
|
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 for properties change, ignoring')
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[Indicators] Error handling study_properties_changed:', error)
|
|
}
|
|
}, 0)
|
|
} else {
|
|
console.log('[Indicators] study_properties_changed with no study ID, ignoring')
|
|
}
|
|
|
|
console.log('[Indicators] ===== STUDY_PROPERTIES_CHANGED COMPLETE =====')
|
|
})
|
|
|
|
// Suppress study_event: remove during page unload (TV fires removes for all studies
|
|
// as part of its own cleanup, which would otherwise wipe the persistence store)
|
|
const onBeforeUnload = () => { isUnmounting = true }
|
|
window.addEventListener('beforeunload', onBeforeUnload)
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
console.log('[Indicators] Cleaning up event subscriptions')
|
|
isUnmounting = true
|
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
|
try {
|
|
tvWidget.unsubscribe('study_event')
|
|
tvWidget.unsubscribe('study_properties_changed')
|
|
} catch (error) {
|
|
console.error('[Indicators] Error during cleanup:', error)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[Indicators] Error setting up indicator listeners:', error)
|
|
return () => {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.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
|
|
|
|
// Custom indicators (pandas_ta_name starts with "custom_") are handled by
|
|
// useCustomIndicators — they use TV createCustomStudy, not createStudy.
|
|
if (indicator.pandas_ta_name.startsWith('custom_')) return
|
|
|
|
try {
|
|
const chart = tvWidget.activeChart()
|
|
if (!chart) return
|
|
|
|
const currentSymbol = chartStore.symbol
|
|
const tvName = PANDAS_TA_TO_TV_NAMES[indicator.pandas_ta_name]
|
|
|
|
if (!tvName) {
|
|
console.warn('[Indicators] No TradingView mapping for pandas-ta indicator:', indicator.pandas_ta_name)
|
|
return
|
|
}
|
|
|
|
const tvInputs = convertPandasTaParamsToTVInputs(indicator.pandas_ta_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 = convertPandasTaParamsToTVInputs(indicator.pandas_ta_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()
|
|
}
|
|
}
|