custom indicators fixed

This commit is contained in:
2026-04-09 17:00:43 -04:00
parent a70dcd954f
commit fd431516cc
17 changed files with 778 additions and 440 deletions

View File

@@ -8,6 +8,7 @@ import LoginScreen from './components/LoginScreen.vue'
import { useChartStore } from './stores/chart'
import { useShapeStore } from './stores/shapes'
import { useIndicatorStore } from './stores/indicators'
import { useIndicatorTypesStore } from './stores/indicatorTypes'
import { useChannelStore } from './stores/channel'
import { useStateSync } from './composables/useStateSync'
import { wsManager } from './composables/useWebSocket'
@@ -93,11 +94,13 @@ const initializeApp = async () => {
const chartStore = useChartStore()
const shapeStore = useShapeStore()
const indicatorStore = useIndicatorStore()
const indicatorTypesStore = useIndicatorTypesStore()
const channelStore = useChannelStore()
const stateSync = useStateSync({
chartState: chartStore,
shapes: shapeStore,
indicators: indicatorStore,
indicator_types: indicatorTypesStore,
channelState: channelStore
})
stateSyncCleanup = stateSync.cleanup

View File

@@ -6,6 +6,7 @@ import { useTradingViewShapes } from '../composables/useTradingViewShapes'
import { useTradingViewIndicators } from '../composables/useTradingViewIndicators'
import { useCustomIndicators, getCustomIndicatorsGetter } from '../composables/useCustomIndicators'
import { useChartStore } from '../stores/chart'
import { useIndicatorTypesStore } from '../stores/indicatorTypes'
import type { IChartingLibraryWidget } from '../types/tradingview'
import { intervalToSeconds } from '../utils'
import { wsManager } from '../composables/useWebSocket'
@@ -51,6 +52,9 @@ function initChart() {
try {
datafeed = createTradingViewDatafeed()
const indicatorTypesStore = useIndicatorTypesStore()
console.log('[ChartView] indicator_types at widget init:', Object.keys(indicatorTypesStore.types))
tvWidget = new window.TradingView.widget({
symbol: chartStore.symbol, // Use symbol from store
datafeed: datafeed,
@@ -58,104 +62,16 @@ function initChart() {
container: chartContainer.value!,
library_path: '/charting_library/',
locale: 'en',
// Register the two generic custom study dispatch types.
// Register named studies (one per custom indicator type) plus generic fallbacks.
// Must be provided here — TV has no dynamic study registration API.
custom_indicators_getter: getCustomIndicatorsGetter(),
// indicatorTypesStore is populated before widget creation (session ready guard).
custom_indicators_getter: getCustomIndicatorsGetter(() => indicatorTypesStore.types),
disabled_features: [
'use_localstorage_for_settings',
'header_symbol_search',
'symbol_search_hot_key'
],
enabled_features: [],
// Restrict indicators to only those supported by both TA-Lib and TradingView.
// Custom AI-generated indicators (from custom_indicators_getter) must also be listed here.
studies_access: {
type: 'white',
tools: [
// AI custom indicator dispatch studies
{ name: 'dxo_customstudy_overlay' },
{ name: 'dxo_customstudy_pane' },
// Overlap Studies (14)
{ name: 'Moving Average' },
{ name: 'Moving Average Exponential' },
{ name: 'Weighted Moving Average' },
{ name: 'DEMA' },
{ name: 'TEMA' },
{ name: 'Triangular Moving Average' },
{ name: 'KAMA' },
{ name: 'MESA Adaptive Moving Average' },
{ name: 'T3' },
{ name: 'Bollinger Bands' },
{ name: 'Midpoint' },
{ name: 'Midprice' },
{ name: 'Parabolic SAR' },
{ name: 'Hilbert Transform - Instantaneous Trendline' },
// Momentum Indicators (21)
{ name: 'Relative Strength Index' },
{ name: 'Momentum' },
{ name: 'Rate of Change' },
{ name: 'TRIX' },
{ name: 'Chande Momentum Oscillator' },
{ name: 'Directional Movement Index' },
{ name: 'Average Directional Movement Index' },
{ name: 'Average Directional Movement Index Rating' },
{ name: 'Absolute Price Oscillator' },
{ name: 'Percentage Price Oscillator' },
{ name: 'MACD' },
{ name: 'Money Flow Index' },
{ name: 'Stochastic' },
{ name: 'Stochastic Fast' },
{ name: 'Stochastic RSI' },
{ name: 'Williams %R' },
{ name: 'Commodity Channel Index' },
{ name: 'Aroon' },
{ name: 'Aroon Oscillator' },
{ name: 'Balance Of Power' },
{ name: 'Ultimate Oscillator' },
// Volume Indicators (3)
{ name: 'Chaikin A/D Line' },
{ name: 'Chaikin A/D Oscillator' },
{ name: 'On Balance Volume' },
// Volatility Indicators (3)
{ name: 'Average True Range' },
{ name: 'Normalized Average True Range' },
{ name: 'True Range' },
// Price Transform (4)
{ name: 'Average Price' },
{ name: 'Median Price' },
{ name: 'Typical Price' },
{ name: 'Weighted Close Price' },
// Cycle Indicators (5)
{ name: 'Hilbert Transform - Dominant Cycle Period' },
{ name: 'Hilbert Transform - Dominant Cycle Phase' },
{ name: 'Hilbert Transform - Phasor Components' },
{ name: 'Hilbert Transform - SineWave' },
{ name: 'Hilbert Transform - Trend vs Cycle Mode' },
// Statistic Functions (9)
{ name: 'Beta' },
{ name: 'Pearson\'s Correlation Coefficient' },
{ name: 'Linear Regression' },
{ name: 'Linear Regression Angle' },
{ name: 'Linear Regression Intercept' },
{ name: 'Linear Regression Slope' },
{ name: 'Standard Deviation' },
{ name: 'Time Series Forecast' },
{ name: 'Variance' },
// Custom Indicators (12)
{ name: 'VWAP' },
{ name: 'VWMA' },
{ name: 'Hull Moving Average' },
{ name: 'SuperTrend' },
{ name: 'Donchian Channels' },
{ name: 'Keltner Channels' },
{ name: 'Chaikin Money Flow' },
{ name: 'Vortex Indicator' },
{ name: 'Awesome Oscillator' },
{ name: 'Accelerator Oscillator' },
{ name: 'Choppiness Index' },
{ name: 'Mass Index' }
]
},
fullscreen: false,
autosize: true,
theme: 'Dark',

View File

@@ -326,6 +326,11 @@ const sendMessage = async (event: any) => {
// Show typing indicator immediately (before first chunk arrives)
isAgentProcessing.value = true
// Add thinking bubble in a macrotask so it runs in the same execution context
// as the WebSocket handler (where tool bubbles work). nextTick / sync both fail
// because vue-advanced-chat processes the send-message event asynchronously.
setTimeout(() => addToolCallBubble('Thinking...'), 0)
// Mark as distributed (single checkmark) after confirming WS send
setTimeout(() => {
const msgIndex = messages.value.findIndex(m => m._id === messageId)

View File

@@ -8,28 +8,37 @@
* dynamic registration API (createCustomStudy does not exist on the widget
* or chart APIs).
*
* To support custom indicators that arrive at runtime (e.g. from the AI
* agent), we pre-register two generic dispatch studies in
* `custom_indicators_getter`:
* Study types are registered in two tiers:
*
* dxo_customstudy_overlay — is_price_study: true (drawn on price pane)
* dxo_customstudy_pane — is_price_study: false (separate pane)
* 1. Named studies — one per custom indicator type stored in indicator_types:
* dxo_ind_custom_my_indicator
* These appear in TV's indicator search with their human-readable display
* names so users can discover and add them from the UI.
*
* Each has a single text input `_cfg` (a config key) and MAX_PLOTS
* line plots. The constructor dispatches to `customStudyRegistry[cfgKey]`
* to look up the per-indicator configuration and data.
* 2. Generic dispatch studies — backward-compatibility fallbacks:
* dxo_customstudy_overlay (is_price_study: true)
* dxo_customstudy_pane (is_price_study: false)
* Used when a named study type is not yet registered (e.g. race between
* widget init and indicator_types store population).
*
* Each named study has a single text input `_cfg` (a config key) and MAX_PLOTS
* line plots. The constructor dispatches to `customStudyRegistry[cfgKey]` to
* look up the per-indicator configuration and data.
*
* These study type names MUST also appear in the `studies_access` whitelist
* in ChartView.vue — TV treats unlisted studies as nonexistent.
*
* Registration flow
* -----------------
* 1. Widget constructor calls getCustomIndicatorsGetter() which registers
* the two generic study types.
* 2. When a custom_ indicator appears in the store, registerCustomStudy():
* a. Stores the config in customStudyRegistry under a unique cfgKey.
* b. Calls chart.createStudy('dxo_customstudy_*', ..., { _cfg: cfgKey }).
* c. Calls study.setStudyTitle(indicator name) for a human-readable header.
* 1. Widget constructor calls getCustomIndicatorsGetter(getTypes) which registers
* named studies (one per type) plus the two generic fallback studies.
* 2a. Agent path: when a custom_ indicator appears in the indicators store,
* registerCustomStudy() calls chart.createStudy('dxo_ind_*', ..., {_cfg: cfgKey}).
* 2b. Picker path: user selects a named study from TV's search; TV creates it with
* empty _cfg; convertTVStudyToIndicator (useTradingViewIndicators) converts
* the dxo_ind_* event into an IndicatorInstance and adds it to the store;
* registerCustomStudy() then *adopts* the existing study by setting its _cfg
* input (no duplicate createStudy call).
* 3. TV calls the study's init(ctx, inputs):
* a. Reads symbol/period from ctx; builds the data cache key.
* b. Fires an async evaluateIndicator WebSocket request.
@@ -41,8 +50,9 @@
* `custom_indicators_getter` option when creating the TradingView widget.
*/
import { watch } from 'vue'
import { watch, ref } from 'vue'
import { useIndicatorStore, type IndicatorInstance, type CustomIndicatorMetadata } from '../stores/indicators'
import { useIndicatorTypesStore, type CustomIndicatorType } from '../stores/indicatorTypes'
import { useChartStore } from '../stores/chart'
import { wsManager, type MessageHandler } from './useWebSocket'
import { intervalToSeconds } from '../utils'
@@ -141,6 +151,10 @@ const customStudyRegistry = new Map<string, CustomStudyEntry>()
// Called by the constructor when async data arrives to trigger TV re-run.
const refreshCallbacks = new Map<string, (newCfgKey: string) => void>()
// indicatorId → { fromTime, toTime } of the last evaluate_indicator request.
// Used to detect when the visible range has expanded beyond the fetched range.
const fetchedRanges = new Map<string, { fromTime: number; toTime: number }>()
// TradingView widget reference — set by useCustomIndicators() so the
// constructor can query the current visible range.
let _tvWidget: any = null
@@ -156,144 +170,289 @@ const MULTI_LINE_COLORS = [
'#00BCD4', '#FF5722', '#795548',
]
// ---------------------------------------------------------------------------
// Study name helpers
// ---------------------------------------------------------------------------
/** TV study type name for a given pandas_ta_name. */
function tvStudyName(pandasTaName: string): string {
return `dxo_ind_${pandasTaName}`
}
/** Build the constructor function. nOutputs controls how many values main() returns. */
function makeStudyConstructor(nOutputs: number = MAX_PLOTS) {
return function (this: any) {
let _cfgKey = ''
let _dataKey = ''
let _fetchGen = 0
this.init = function (ctx: any, inputs: (i: number) => any) {
const cfgKey = inputs(0) as string
_cfgKey = cfgKey
_fetchGen++
const myGen = _fetchGen
const entry = customStudyRegistry.get(cfgKey)
if (!entry) return
const symbol: string = ctx.symbol.ticker
const periodStr: string = ctx.symbol.period
const periodSeconds = intervalToSeconds(periodStr)
const paramsHash = JSON.stringify(entry.parameters)
const dk = cacheKey(entry.indicatorId, symbol, periodSeconds, paramsHash)
_dataKey = dk
if (dataCache.has(dk)) return
let fromTime: number
let toTime: number
const now = Math.floor(Date.now() / 1000)
toTime = now
fromTime = now - periodSeconds * 500
if (_tvWidget) {
try {
const range = _tvWidget.activeChart().getVisibleRange()
if (range?.from && range?.to) {
const dur = Math.floor(range.to) - Math.floor(range.from)
fromTime = Math.floor(range.from) - Math.floor(dur * 0.5)
toTime = Math.floor(range.to)
}
} catch { /* chart not yet ready */ }
}
const capturedDk = dk
const capturedCfgKey = cfgKey
// Record the fetched range so range-expansion watchers can detect gaps
fetchedRanges.set(entry.indicatorId, { fromTime, toTime })
evaluateIndicator(symbol, fromTime, toTime, periodSeconds, entry.pandasTaName, entry.parameters)
.then((result) => {
if (myGen !== _fetchGen) return
dataCache.set(capturedDk, buildDataCache(result))
const refreshKey = `${capturedCfgKey}__r`
customStudyRegistry.set(refreshKey, entry)
const cb = refreshCallbacks.get(entry.indicatorId)
if (cb) cb(refreshKey)
})
.catch((err) => {
console.error('[CustomIndicators] Failed to fetch data for', entry.pandasTaName, err)
fetchedRanges.delete(entry.indicatorId)
})
}
this.main = function (ctx: any, _inputs: (i: number) => any) {
const ts: number = ctx.symbol.bartime()
if (!_cfgKey || !_dataKey) return new Array(nOutputs).fill(NaN)
const entry = customStudyRegistry.get(_cfgKey)
if (!entry) return new Array(nOutputs).fill(NaN)
const cache = dataCache.get(_dataKey)
if (!cache) return new Array(nOutputs).fill(NaN)
const row = cache.get(ts)
return Array.from({ length: nOutputs }, (_, i) => {
const col = entry.metadata.output_columns[i]
return col && row ? (row[col.name] as number) ?? NaN : NaN
})
}
}
}
// ---------------------------------------------------------------------------
// Study definition builders
// ---------------------------------------------------------------------------
function makeStudyMetainfo(name: string, description: string, isPriceStudy: boolean): any {
const plots = Array.from({ length: MAX_PLOTS }, (_, i) => ({ id: `plot_${i}`, type: 'line' }))
const styles: Record<string, any> = {}
const defaultStyles: Record<string, any> = {}
for (let i = 0; i < MAX_PLOTS; i++) {
styles[`plot_${i}`] = { title: `Plot ${i}` }
defaultStyles[`plot_${i}`] = {
linestyle: 0,
linewidth: 1,
plottype: 0,
color: MULTI_LINE_COLORS[i % MULTI_LINE_COLORS.length],
visible: i === 0,
}
}
return {
_metainfoVersion: 53,
id: `${name}@tv-basicstudies-1`,
scriptIdPart: '',
name,
description,
shortDescription: description,
is_hidden_study: false,
is_price_study: isPriceStudy,
isCustomIndicator: true,
format: { type: 'inherit' },
inputs: [
{ id: '_cfg', name: 'Config Key', type: 'text', defval: '' },
],
plots,
styles,
defaults: {
inputs: { _cfg: '' },
styles: defaultStyles,
},
}
}
function tvInputType(paramType: string): string {
switch (paramType) {
case 'int': return 'integer'
case 'float': return 'float'
case 'bool': return 'bool'
default: return 'text'
}
}
/** Create metainfo for a named study with real parameter inputs and exact output count. */
function makeNamedStudyMetainfo(name: string, type: CustomIndicatorType): any {
const meta = type.metadata
const nOutputs = meta.output_columns.length
const isPriceStudy = meta.pane === 'price'
const plots = Array.from({ length: nOutputs }, (_, i) => ({ id: `plot_${i}`, type: 'line' }))
const styles: Record<string, any> = {}
const defaultStyles: Record<string, any> = {}
for (let i = 0; i < nOutputs; i++) {
const col = meta.output_columns[i]
styles[`plot_${i}`] = { title: col.display_name || col.name }
defaultStyles[`plot_${i}`] = {
linestyle: 0,
linewidth: col.plot?.linewidth ?? 1,
plottype: col.plot?.style ?? 0,
color: col.plot?.color ?? MULTI_LINE_COLORS[i % MULTI_LINE_COLORS.length],
visible: col.plot?.visible ?? true,
}
}
// _cfg is the internal dispatch key — hidden from the user
const inputs: any[] = [
{ id: '_cfg', name: 'Config Key', type: 'text', defval: '', isHidden: true },
]
const defaultInputs: Record<string, any> = { _cfg: '' }
for (const [paramName, param] of Object.entries(meta.parameters)) {
const input: any = {
id: `param_${paramName}`,
name: paramName,
type: tvInputType(param.type),
defval: param.default,
}
if (param.min !== undefined) input.min = param.min
if (param.max !== undefined) input.max = param.max
if (param.description) input.tooltip = param.description
inputs.push(input)
defaultInputs[`param_${paramName}`] = param.default
}
return {
_metainfoVersion: 53,
id: `${name}@tv-basicstudies-1`,
scriptIdPart: '',
name,
description: meta.display_name,
shortDescription: meta.display_name,
is_hidden_study: false,
is_price_study: isPriceStudy,
isCustomIndicator: true,
format: { type: 'inherit' },
inputs,
plots,
styles,
defaults: {
inputs: defaultInputs,
styles: defaultStyles,
},
}
}
/** Create a generic fallback dispatch study (overlay or separate pane). Hidden from picker. */
function makeGenericStudy(name: string, isPriceStudy: boolean): any {
const metainfo = makeStudyMetainfo(name, name, isPriceStudy)
metainfo.is_hidden_study = true
return {
name,
metainfo,
constructor: makeStudyConstructor(),
}
}
/** Create a named study for a specific custom indicator type. */
function makeNamedStudy(type: CustomIndicatorType): any {
const name = tvStudyName(type.pandas_ta_name)
const nOutputs = type.metadata.output_columns.length
return {
name,
metainfo: makeNamedStudyMetainfo(name, type),
constructor: makeStudyConstructor(nOutputs),
}
}
// ---------------------------------------------------------------------------
// Custom indicators getter
// Pass the result of this function as the widget option:
// custom_indicators_getter: getCustomIndicatorsGetter()
// custom_indicators_getter: getCustomIndicatorsGetter(getTypes)
//
// The study type names must also be listed in studies_access in ChartView.vue.
// ---------------------------------------------------------------------------
export function getCustomIndicatorsGetter(): (_PineJS: any) => Promise<any[]> {
function makeGenericStudy(name: string, isPriceStudy: boolean): any {
const plots = Array.from({ length: MAX_PLOTS }, (_, i) => ({ id: `plot_${i}`, type: 'line' }))
const styles: Record<string, any> = {}
const defaultStyles: Record<string, any> = {}
for (let i = 0; i < MAX_PLOTS; i++) {
styles[`plot_${i}`] = { title: `Plot ${i}` }
defaultStyles[`plot_${i}`] = {
linestyle: 0,
linewidth: 1,
plottype: 0,
color: MULTI_LINE_COLORS[i % MULTI_LINE_COLORS.length],
visible: i === 0,
}
}
return {
name,
metainfo: {
_metainfoVersion: 51,
// Use @tv-custom-1 — @tv-basicstudies-1 is reserved for built-ins
// and TV throws "unexpected study id" if a custom indicator uses it.
id: `${name}@tv-custom-1`,
scriptIdPart: '',
name,
description: name,
shortDescription: name,
is_price_study: isPriceStudy,
isCustomIndicator: true,
format: { type: 'inherit' },
// Single text input carries the per-instance config key.
inputs: [
{ id: '_cfg', name: 'Config Key', type: 'text', defval: '' },
],
plots,
styles,
defaults: {
inputs: { _cfg: '' },
styles: defaultStyles,
/** Static test study — a plain close-price line to verify custom_indicators_getter works. */
function makeTestStudy(PineJS: any): any {
return {
name: 'dxo_test_close',
metainfo: {
_metainfoVersion: 53,
id: 'dxo_test_close@tv-custom-1',
scriptIdPart: '',
name: 'dxo_test_close',
description: 'DXO Test (close price)',
shortDescription: 'DXO Test',
is_hidden_study: false,
is_price_study: true,
isCustomIndicator: true,
linkedToSeries: true,
format: { type: 'inherit' },
inputs: [],
plots: [{ id: 'plot_0', type: 'line' }],
styles: { plot_0: { title: 'Close', joinPoints: false } },
defaults: {
inputs: {},
styles: {
plot_0: { linestyle: 0, linewidth: 2, color: '#FF0000', transparency: 0, visible: true },
},
},
// ES5 constructor — TV instantiates this with `new`
constructor: function (this: any) {
// Per-instance mutable state stored on the constructor instance
let _cfgKey = '' // current config key (from inputs(0))
let _dataKey = '' // data cache key (built from ctx symbol/period/params)
let _fetchGen = 0 // incremented each init(); used to cancel stale fetches
this.init = function (ctx: any, inputs: (i: number) => any) {
const cfgKey = inputs(0) as string
_cfgKey = cfgKey
_fetchGen++
const myGen = _fetchGen
const entry = customStudyRegistry.get(cfgKey)
if (!entry) return
// Derive symbol and period from the TV context object.
// ctx.symbol.ticker — symbol name without exchange prefix
// ctx.symbol.period — TV interval string ("15", "1D", etc.)
const symbol: string = ctx.symbol.ticker
const periodStr: string = ctx.symbol.period
const periodSeconds = intervalToSeconds(periodStr)
const paramsHash = JSON.stringify(entry.parameters)
const dk = cacheKey(entry.indicatorId, symbol, periodSeconds, paramsHash)
_dataKey = dk
if (dataCache.has(dk)) return // Data already fetched for this symbol/period/params
// Determine time range: prefer chart's visible range, fall back to 500-bar window
let fromTime: number
let toTime: number
const now = Math.floor(Date.now() / 1000)
toTime = now
fromTime = now - periodSeconds * 500
if (_tvWidget) {
try {
const range = _tvWidget.activeChart().getVisibleRange()
if (range?.from && range?.to) {
const dur = Math.floor(range.to) - Math.floor(range.from)
fromTime = Math.floor(range.from) - Math.floor(dur * 0.5)
toTime = Math.floor(range.to)
}
} catch { /* chart not yet ready */ }
}
// Capture mutable vars before async gap
const capturedDk = dk
const capturedCfgKey = cfgKey
evaluateIndicator(symbol, fromTime, toTime, periodSeconds, entry.pandasTaName, entry.parameters)
.then((result) => {
if (myGen !== _fetchGen) return // Superseded by a newer init() call
dataCache.set(capturedDk, buildDataCache(result))
// Create a sibling config key pointing to the same entry.
// Calling setInputValues() with this new key causes TV to
// re-invoke init()+main() with the now-populated cache.
const refreshKey = `${capturedCfgKey}__r`
customStudyRegistry.set(refreshKey, entry)
const cb = refreshCallbacks.get(entry.indicatorId)
if (cb) cb(refreshKey)
})
.catch((err) => {
console.error('[CustomIndicators] Failed to fetch data for', entry.pandasTaName, err)
})
}
this.main = function (ctx: any, _inputs: (i: number) => any) {
// ctx.symbol.bartime() returns the bar timestamp in milliseconds (documented)
const ts: number = ctx.symbol.bartime()
if (!_cfgKey || !_dataKey) return new Array(MAX_PLOTS).fill(NaN)
const entry = customStudyRegistry.get(_cfgKey)
if (!entry) return new Array(MAX_PLOTS).fill(NaN)
const cache = dataCache.get(_dataKey)
if (!cache) return new Array(MAX_PLOTS).fill(NaN)
const row = cache.get(ts)
return Array.from({ length: MAX_PLOTS }, (_, i) => {
const col = entry.metadata.output_columns[i]
return col && row ? (row[col.name] as number) ?? NaN : NaN
})
}
},
}
},
constructor: function (this: any) {
this.main = function (ctx: any, _inputs: any) {
this._context = ctx
return [PineJS.Std.close(ctx)]
}
},
}
}
return (_PineJS: any): Promise<any[]> => {
return Promise.resolve([
export function getCustomIndicatorsGetter(
getTypes: () => Record<string, CustomIndicatorType>
): (_PineJS: any) => Promise<any[]> {
return (PineJS: any): Promise<any[]> => {
const types = getTypes()
const typeKeys = Object.keys(types)
console.log('[CustomIndicators] custom_indicators_getter called, types in store:', typeKeys)
const namedStudies = Object.values(types).map(makeNamedStudy)
const testStudy = makeTestStudy(PineJS)
const studies = [
makeGenericStudy('dxo_customstudy_overlay', true),
makeGenericStudy('dxo_customstudy_pane', false),
])
testStudy,
...namedStudies,
]
console.log('[CustomIndicators] Registering studies:', studies.map((s) => s.name))
return Promise.resolve(studies)
}
}
@@ -305,14 +464,16 @@ export function useCustomIndicators(tvWidget: any) {
_tvWidget = tvWidget
const indicatorStore = useIndicatorStore()
const indicatorTypesStore = useIndicatorTypesStore()
const chartStore = useChartStore()
// Maps indicator id → { cfgKey, tvStudyId, symbol }
const registered = new Map<string, { cfgKey: string; tvStudyId: string | null; symbol: string }>()
// Tracks indicator IDs whose registerCustomStudy() is in-flight (createStudy not yet resolved).
const pendingRegistration = new Set<string>()
// Monotonic version counter per indicator for unique config keys
const cfgVersions = new Map<string, number>()
// Last-seen parameter hash per indicator id for change detection.
// Needed because Pinia $patch mutates in place (oldValue === newValue).
const lastParams = new Map<string, string>()
let isChartReady = false
@@ -323,8 +484,6 @@ export function useCustomIndicators(tvWidget: any) {
return `cfg_${indicatorId.replace(/[^a-zA-Z0-9]/g, '_')}_v${v}`
}
// Apply per-indicator visual overrides after createStudy() returns.
// Uses per-column plot config (style, color, linewidth, visible) from metadata.
function applyStudyOverrides(studyId: string, meta: CustomIndicatorMetadata) {
try {
const study = tvWidget.activeChart().getStudyById(studyId)
@@ -346,17 +505,35 @@ export function useCustomIndicators(tvWidget: any) {
overrides[`styles.plot_${i}.color`] = p?.color ?? MULTI_LINE_COLORS[i % MULTI_LINE_COLORS.length]
}
// Note: TV band `value` is fixed at metainfo-declaration time and cannot be changed
// via overrides. Indicators that need horizontal reference lines at configurable
// values (e.g. RSI at 70/30) should instead include a constant-value output column
// rather than relying on meta.bands.
study.applyOverrides(overrides)
} catch (err) {
console.warn('[CustomIndicators] Could not apply overrides:', err)
}
}
function applyStudyTitle(studyId: string, meta: CustomIndicatorMetadata) {
try {
const study = tvWidget.activeChart().getStudyById(studyId)
if (!study) return
const displayName = meta.display_name
if (typeof study.setStudyTitle === 'function') {
study.setStudyTitle(displayName)
}
} catch { /* setStudyTitle not available in this TV build */ }
}
// ------------------------------------------------------------------
// Resolve the study type name to use when creating a new TV study
// ------------------------------------------------------------------
function resolveStudyTypeName(pandasTaName: string, pane: string): string {
// TV's createStudy() matches by the `description` field in metainfo, not the internal `name`.
// Named studies have description = display_name (e.g. "TrendFlex"), not "dxo_ind_*".
const typeEntry = indicatorTypesStore.types[pandasTaName]
if (typeEntry) return typeEntry.metadata.display_name
// Generic fallbacks have name === description, so either works.
return pane === 'price' ? 'dxo_customstudy_overlay' : 'dxo_customstudy_pane'
}
// ------------------------------------------------------------------
// Register a custom indicator as a TV study instance
// ------------------------------------------------------------------
@@ -367,10 +544,11 @@ export function useCustomIndicators(tvWidget: any) {
return
}
if (pendingRegistration.has(indicator.id)) return
pendingRegistration.add(indicator.id)
const symbol = indicator.symbol || chartStore.symbol
const cfgKey = nextCfgKey(indicator.id)
const forceOverlay = meta.pane === 'price'
const studyTypeName = meta.pane === 'price' ? 'dxo_customstudy_overlay' : 'dxo_customstudy_pane'
// Store per-instance config in the registry so the constructor can find it
customStudyRegistry.set(cfgKey, {
@@ -381,8 +559,6 @@ export function useCustomIndicators(tvWidget: any) {
})
// Register the callback invoked by the constructor after async data loads.
// We change the study's _cfg input to a sibling key, which causes TV to
// re-run init()+main() and pick up the freshly populated cache.
refreshCallbacks.set(indicator.id, (newCfgKey: string) => {
const entry = registered.get(indicator.id)
if (!entry?.tvStudyId) return
@@ -398,33 +574,67 @@ export function useCustomIndicators(tvWidget: any) {
})
try {
const tvStudyId = (await tvWidget.activeChart().createStudy(
studyTypeName, forceOverlay, false,
{ _cfg: cfgKey }
)) as string | null
// Check if the study already exists on the chart (picker-added path):
// when a user picks a named study from TV's indicator search, TV creates
// it with empty _cfg and fires study_event. useTradingViewIndicators converts
// that to an IndicatorInstance with tv_study_id set. We must adopt the
// existing study rather than create a duplicate.
let tvStudyId: string | null = null
let adopted = false
if (indicator.tv_study_id) {
try {
const existingStudy = tvWidget.activeChart().getStudyById(indicator.tv_study_id)
if (existingStudy) {
tvStudyId = indicator.tv_study_id
adopted = true
}
} catch { /* study not on chart */ }
}
if (adopted && tvStudyId) {
// Picker path: adopt the existing study by setting its _cfg + param inputs.
const study = tvWidget.activeChart().getStudyById(tvStudyId)
if (study) {
const inputValues: Array<{ id: string; value: any }> = [{ id: '_cfg', value: cfgKey }]
for (const [paramName, value] of Object.entries(indicator.parameters)) {
inputValues.push({ id: `param_${paramName}`, value })
}
study.setInputValues(inputValues)
}
console.log('[CustomIndicators] Adopted picker-added study:', indicator.pandas_ta_name, '(', tvStudyId, ')')
} else {
// Agent path: create a new study on the chart.
const forceOverlay = meta.pane === 'price'
const studyTypeName = resolveStudyTypeName(indicator.pandas_ta_name, meta.pane)
const inputsDict: Record<string, any> = { _cfg: cfgKey }
for (const [paramName, value] of Object.entries(indicator.parameters)) {
inputsDict[`param_${paramName}`] = value
}
tvStudyId = (await tvWidget.activeChart().createStudy(
studyTypeName, forceOverlay, false,
inputsDict
)) as string | null
console.log('[CustomIndicators] Created study:', indicator.pandas_ta_name, '→', studyTypeName, '(', tvStudyId, ')')
}
registered.set(indicator.id, { cfgKey, tvStudyId: tvStudyId ?? null, symbol })
lastParams.set(indicator.id, JSON.stringify(indicator.parameters))
if (tvStudyId) {
// Set human-readable panel title (falls back to pandas_ta_name if no display name)
const displayName = meta.display_name || indicator.pandas_ta_name.replace(/^custom_/, '')
try {
const study = tvWidget.activeChart().getStudyById(tvStudyId)
if (study && typeof study.setStudyTitle === 'function') {
study.setStudyTitle(displayName)
}
} catch { /* setStudyTitle not available in this TV build */ }
applyStudyTitle(tvStudyId, meta)
applyStudyOverrides(tvStudyId, meta)
if (tvStudyId !== indicator.tv_study_id) {
indicatorStore.updateIndicator(indicator.id, { tv_study_id: tvStudyId })
}
}
console.log('[CustomIndicators] Registered:', indicator.pandas_ta_name, '→', studyTypeName, '(', tvStudyId, ')')
} catch (err) {
console.error('[CustomIndicators] Failed to create TV custom study:', studyTypeName, err)
console.error('[CustomIndicators] Failed to register TV custom study:', indicator.pandas_ta_name, err)
} finally {
pendingRegistration.delete(indicator.id)
}
}
@@ -437,7 +647,9 @@ export function useCustomIndicators(tvWidget: any) {
registered.delete(indicatorId)
lastParams.delete(indicatorId)
refreshCallbacks.delete(indicatorId)
fetchedRanges.delete(indicatorId)
pendingRegistration.delete(indicatorId)
if (entry.tvStudyId) {
try { tvWidget.activeChart().removeStudy(entry.tvStudyId) } catch { /* already gone */ }
}
@@ -447,7 +659,6 @@ export function useCustomIndicators(tvWidget: any) {
// Re-register when parameters/symbol/period change (forces new data fetch)
// ------------------------------------------------------------------
async function refreshCustomStudy(indicator: IndicatorInstance) {
// Purge stale cache entries so init() fetches fresh data
for (const key of Array.from(dataCache.keys())) {
if (key.startsWith(`${indicator.id}_`)) {
dataCache.delete(key)
@@ -458,20 +669,36 @@ export function useCustomIndicators(tvWidget: any) {
}
// ------------------------------------------------------------------
// Store watcher — respond to indicator additions, changes, removals
//
// NOTE: Pinia $patch mutates in place, so oldValue === newValue for
// backend-originated updates. We track state manually via lastParams.
// Chart ready — set isChartReady and track when TV data has loaded
// ------------------------------------------------------------------
isChartReady = true
// Reactive flag: true once TV has loaded chart data (earliest safe point to create studies).
// Using ref() so it can be a watched source alongside indicatorStore.indicators.
const isDataLoaded = ref(false)
tvWidget.activeChart().onDataLoaded().subscribe(null, () => {
if (isDataLoaded.value) return
console.log('[CustomIndicators] onDataLoaded fired')
isDataLoaded.value = true
})
// ------------------------------------------------------------------
// Combined watcher: fires when data loads OR store changes.
// immediate:true handles the case where indicators are already in the
// store before this composable is called.
// ------------------------------------------------------------------
watch(
() => indicatorStore.indicators,
async (newIndicators) => {
if (!isChartReady) return
[isDataLoaded, () => indicatorStore.indicators],
async ([loaded, newIndicators]) => {
if (!isChartReady || !loaded) return
for (const [id, indicator] of Object.entries(newIndicators)) {
const indicators = newIndicators as Record<string, InstanceType<any>>
for (const [id, indicator] of Object.entries(indicators)) {
if (!indicator.pandas_ta_name.startsWith('custom_')) continue
if (!registered.has(id)) {
if (!registered.has(id) && !pendingRegistration.has(id)) {
lastParams.set(id, JSON.stringify(indicator.parameters))
await registerCustomStudy(indicator)
} else {
@@ -488,13 +715,13 @@ export function useCustomIndicators(tvWidget: any) {
// Handle removals
for (const id of registered.keys()) {
if (!(id in newIndicators)) {
if (!(id in indicators)) {
lastParams.delete(id)
removeCustomStudy(id)
}
}
},
{ deep: true }
{ immediate: true, deep: true }
)
// Re-fetch when chart resolution changes
@@ -512,37 +739,42 @@ export function useCustomIndicators(tvWidget: any) {
}
)
// ------------------------------------------------------------------
// Chart ready — apply any indicators already in the store
// ------------------------------------------------------------------
// useCustomIndicators is always called from within tvWidget.onChartReady in ChartView,
// so the chart is already ready.
isChartReady = true
// TV processes custom_indicators_getter asynchronously (Promise microtask), so the
// custom study types are not yet available at onChartReady time. Defer the initial
// registration of any pending indicators until chart data loads — by that point the
// getter Promise has resolved and the study types are registered in TV's internal
// study index (and the studies_access whitelist check passes).
let initialApplied = false
tvWidget.activeChart().onDataLoaded().subscribe(null, () => {
if (initialApplied) return
initialApplied = true
const pending = Object.values(indicatorStore.indicators).filter(
(ind) => ind.pandas_ta_name.startsWith('custom_') && !registered.has(ind.id)
)
for (const indicator of pending) {
lastParams.set(indicator.id, JSON.stringify(indicator.parameters))
registerCustomStudy(indicator)
// Re-fetch when visible range expands beyond the fetched range (e.g. user zooms out).
// Debounced to avoid hammering on every scroll tick.
// Due to lookback effects, we always re-fetch the full new range rather than just the gap.
let rangeWatchTimer: ReturnType<typeof setTimeout> | null = null
watch(
[() => chartStore.start_time, () => chartStore.end_time],
([newStart, newEnd]) => {
if (!isChartReady) return
if (rangeWatchTimer) clearTimeout(rangeWatchTimer)
rangeWatchTimer = setTimeout(() => {
rangeWatchTimer = null
for (const [id, indicator] of Object.entries(indicatorStore.indicators)) {
if (!indicator.pandas_ta_name.startsWith('custom_')) continue
if (!registered.has(id) || pendingRegistration.has(id)) continue
const range = fetchedRanges.get(id)
if (!range) continue
const startExpanded = newStart != null && newStart < range.fromTime
// Allow a 10-bar buffer on the right before triggering (live bars naturally advance)
const endExpanded = newEnd != null && newEnd > range.toTime + chartStore.period * 10
if (startExpanded || endExpanded) {
console.log('[CustomIndicators] Visible range expanded beyond fetch range, re-fetching:', indicator.pandas_ta_name, { newStart, rangeFrom: range.fromTime })
refreshCustomStudy(indicator)
}
}
}, 500)
}
})
)
// Cleanup
return () => {
if (rangeWatchTimer) { clearTimeout(rangeWatchTimer); rangeWatchTimer = null }
for (const id of [...registered.keys()]) {
removeCustomStudy(id)
}
registered.clear()
pendingRegistration.clear()
cfgVersions.clear()
lastParams.clear()
if (_tvWidget === tvWidget) _tvWidget = null

View File

@@ -40,7 +40,8 @@ interface SymbolDenominators {
export class WebSocketDatafeed implements IBasicDataFeed {
private pendingRequests: Map<string, PendingRequest> = new Map()
private subscriptions: Map<string, Subscription> = new Map()
private requestTimeout = 10000 // 10 seconds
private requestTimeout = 10000 // 10 seconds (used for all requests except getBars)
private barsRequestTimeout = 60000 // 60 seconds (cold-cache pipeline may take a while)
private configuration: DatafeedConfiguration | null = null
private messageHandler: MessageHandler
private symbolDenominators: Map<string, SymbolDenominators> = new Map() // Track denominators per symbol
@@ -56,7 +57,7 @@ export class WebSocketDatafeed implements IBasicDataFeed {
return `tv_${Date.now()}_${Math.random().toString(36).substring(7)}`
}
private sendRequest<T>(message: any): Promise<T> {
private sendRequest<T>(message: any, timeoutMs?: number): Promise<T> {
const requestId = message.request_id || this.generateRequestId()
message.request_id = requestId
@@ -67,7 +68,7 @@ export class WebSocketDatafeed implements IBasicDataFeed {
console.error('[TradingView Datafeed] Request timeout:', requestId, message.type)
this.pendingRequests.delete(requestId)
reject(new Error('Request timeout'))
}, this.requestTimeout)
}, timeoutMs ?? this.requestTimeout)
this.pendingRequests.set(requestId, { resolve, reject, timeout })
@@ -244,14 +245,23 @@ export class WebSocketDatafeed implements IBasicDataFeed {
const symbolKey = symbolInfo.ticker || symbolInfo.name
const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 }
this.sendRequest<any>({
const doRequest = () => this.sendRequest<any>({
type: 'get_bars',
symbol: symbolKey,
period_seconds: intervalToSeconds(resolution),
from_time: periodParams.from,
to_time: periodParams.to,
countback: periodParams.countBack
})
}, this.barsRequestTimeout)
doRequest()
.catch((err: Error) => {
if (err.message === 'Request timeout') {
console.warn('[TradingView Datafeed] getBars timed out, retrying once (data may now be in cache)...')
return doRequest()
}
throw err
})
.then((response) => {
if (response.history) {
console.log('[TradingView Datafeed] Raw bar sample:', response.history.bars?.[0])

View File

@@ -1,6 +1,7 @@
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'
@@ -230,6 +231,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
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
@@ -280,15 +282,63 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
}
}
// Final fallback to property access
if (studyName === 'Unknown') {
studyName = tvStudy._study?.name?.() ||
tvStudy._metaInfo?.description ||
tvStudy._metaInfo?.shortDescription || 'Unknown'
// 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)
@@ -429,6 +479,17 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
// 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')
@@ -452,6 +513,11 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
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
)
@@ -545,6 +611,35 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
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') {
@@ -596,11 +691,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
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()
}
console.log('[Indicators] No existing indicator found for properties change, ignoring')
}
}
} catch (error) {
@@ -608,35 +699,25 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
}
}, 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 with no study ID, ignoring')
}
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()
})
// 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 {
// 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)
}
@@ -647,74 +728,6 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
}
}
/**
* Scan TradingView for indicators and sync to store
*/
function syncIndicatorsFromTV() {
if (!isChartReady) return
try {
const chart = tvWidget.activeChart()
if (!chart) return
const currentSymbol = chartStore.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
*/

View File

@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { CustomIndicatorMetadata } from './indicators'
export interface CustomIndicatorType {
pandas_ta_name: string
display_name: string
description?: string
metadata: CustomIndicatorMetadata
created_at: number
modified_at: number
}
export const useIndicatorTypesStore = defineStore('indicator_types', () => {
const types = ref<Record<string, CustomIndicatorType>>({})
return { types }
})