data fixes, partial custom indicator support
This commit is contained in:
@@ -4,9 +4,11 @@ import Card from 'primevue/card'
|
||||
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
|
||||
import { useTradingViewShapes } from '../composables/useTradingViewShapes'
|
||||
import { useTradingViewIndicators } from '../composables/useTradingViewIndicators'
|
||||
import { useCustomIndicators, getCustomIndicatorsGetter } from '../composables/useCustomIndicators'
|
||||
import { useChartStore } from '../stores/chart'
|
||||
import type { IChartingLibraryWidget } from '../types/tradingview'
|
||||
import { intervalToSeconds } from '../utils'
|
||||
import { wsManager } from '../composables/useWebSocket'
|
||||
|
||||
// Convert seconds to TradingView interval string
|
||||
function secondsToInterval(seconds: number): string {
|
||||
@@ -22,12 +24,25 @@ let datafeed: any = null
|
||||
let isUpdatingFromChart = false // Flag to prevent circular updates
|
||||
let shapeCleanup: (() => void) | null = null // Cleanup function for shape sync
|
||||
let indicatorCleanup: (() => void) | null = null // Cleanup function for indicator sync
|
||||
let customIndicatorCleanup: (() => void) | null = null // Cleanup for custom TV studies
|
||||
let chartInitialized = false // Guard against double-init on reconnect
|
||||
|
||||
const maybeInitChart = () => {
|
||||
if (chartInitialized || !chartContainer.value) return
|
||||
chartInitialized = true
|
||||
initChart()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!chartContainer.value) return
|
||||
// Wait for workspace to be ready (persistent stores loaded from container)
|
||||
// before initializing TradingView, so stores are populated when onChartReady fires.
|
||||
watch(wsManager.sessionStatus, (status) => {
|
||||
if (status === 'ready') maybeInitChart()
|
||||
}, { immediate: true })
|
||||
})
|
||||
|
||||
// Wait for TradingView library to load
|
||||
const initChart = () => {
|
||||
// Wait for TradingView library to load
|
||||
function initChart() {
|
||||
if (!window.TradingView) {
|
||||
setTimeout(initChart, 100)
|
||||
return
|
||||
@@ -43,16 +58,23 @@ onMounted(() => {
|
||||
container: chartContainer.value!,
|
||||
library_path: '/charting_library/',
|
||||
locale: 'en',
|
||||
// Register the two generic custom study dispatch types.
|
||||
// Must be provided here — TV has no dynamic study registration API.
|
||||
custom_indicators_getter: getCustomIndicatorsGetter(),
|
||||
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
|
||||
// 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' },
|
||||
@@ -150,15 +172,13 @@ onMounted(() => {
|
||||
if (tvWidget) {
|
||||
shapeCleanup = useTradingViewShapes(tvWidget)
|
||||
indicatorCleanup = useTradingViewIndicators(tvWidget)
|
||||
customIndicatorCleanup = useCustomIndicators(tvWidget)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize TradingView widget:', error)
|
||||
}
|
||||
}
|
||||
|
||||
initChart()
|
||||
})
|
||||
}
|
||||
|
||||
function initializeVisibleRange() {
|
||||
if (!tvWidget) return
|
||||
@@ -281,6 +301,12 @@ onBeforeUnmount(() => {
|
||||
indicatorCleanup = null
|
||||
}
|
||||
|
||||
// Cleanup custom TV studies
|
||||
if (customIndicatorCleanup) {
|
||||
customIndicatorCleanup()
|
||||
customIndicatorCleanup = null
|
||||
}
|
||||
|
||||
if (tvWidget) {
|
||||
tvWidget.remove()
|
||||
tvWidget = null
|
||||
|
||||
@@ -238,14 +238,7 @@ const handleMessage = (data: WebSocketMessage) => {
|
||||
|
||||
// Stop agent processing
|
||||
const stopAgent = () => {
|
||||
// Send empty message to trigger interrupt without new agent round
|
||||
const wsMessage = {
|
||||
type: 'agent_user_message',
|
||||
session_id: SESSION_ID,
|
||||
content: '',
|
||||
attachments: []
|
||||
}
|
||||
wsManager.send(wsMessage)
|
||||
wsManager.send({ type: 'agent_stop', session_id: SESSION_ID })
|
||||
isAgentProcessing.value = false
|
||||
removeToolCallBubble()
|
||||
lastSentMessageId = null
|
||||
@@ -586,7 +579,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.workspace-loading {
|
||||
flex: 1;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -639,7 +634,7 @@ onUnmounted(() => {
|
||||
.stop-button-container {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
|
||||
551
web/src/composables/useCustomIndicators.ts
Normal file
551
web/src/composables/useCustomIndicators.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* TradingView custom study integration for pandas-ta custom indicators.
|
||||
*
|
||||
* Architecture overview
|
||||
* ---------------------
|
||||
* TV's custom study API only allows registering study types via the
|
||||
* `custom_indicators_getter` widget constructor option — there is no
|
||||
* 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`:
|
||||
*
|
||||
* dxo_customstudy_overlay — is_price_study: true (drawn on price pane)
|
||||
* dxo_customstudy_pane — is_price_study: false (separate pane)
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 4. When data arrives the constructor calls the registered refreshCallback
|
||||
* which calls IStudyApi.setInputValues() with a new config key, causing
|
||||
* TV to re-run init()+main() with the now-populated cache.
|
||||
*
|
||||
* IMPORTANT: call getCustomIndicatorsGetter() and pass it as the
|
||||
* `custom_indicators_getter` option when creating the TradingView widget.
|
||||
*/
|
||||
|
||||
import { watch } from 'vue'
|
||||
import { useIndicatorStore, type IndicatorInstance, type CustomIndicatorMetadata } from '../stores/indicators'
|
||||
import { useChartStore } from '../stores/chart'
|
||||
import { wsManager, type MessageHandler } from './useWebSocket'
|
||||
import { intervalToSeconds } from '../utils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket helper — evaluate_indicator request/response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EvaluateResult {
|
||||
symbol: string
|
||||
period_seconds: number
|
||||
pandas_ta_name: string
|
||||
parameters: Record<string, any>
|
||||
candle_count: number
|
||||
columns: string[]
|
||||
values: Array<Record<string, any>>
|
||||
error?: string
|
||||
}
|
||||
|
||||
function evaluateIndicator(
|
||||
symbol: string,
|
||||
fromTime: number,
|
||||
toTime: number,
|
||||
periodSeconds: number,
|
||||
pandasTaName: string,
|
||||
parameters: Record<string, any>,
|
||||
timeoutMs = 30_000
|
||||
): Promise<EvaluateResult> {
|
||||
const requestId = `cind_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
wsManager.removeHandler(handler)
|
||||
reject(new Error(`evaluate_indicator timeout for ${pandasTaName}`))
|
||||
}, timeoutMs)
|
||||
|
||||
const handler: MessageHandler = (message: any) => {
|
||||
if (message.type !== 'evaluate_indicator_result') return
|
||||
if (message.request_id !== requestId) return
|
||||
clearTimeout(timer)
|
||||
wsManager.removeHandler(handler)
|
||||
if (message.error) reject(new Error(message.error))
|
||||
else resolve(message as EvaluateResult)
|
||||
}
|
||||
|
||||
wsManager.addHandler(handler)
|
||||
wsManager.send({
|
||||
type: 'evaluate_indicator',
|
||||
request_id: requestId,
|
||||
symbol,
|
||||
from_time: fromTime,
|
||||
to_time: toTime,
|
||||
period_seconds: periodSeconds,
|
||||
pandas_ta_name: pandasTaName,
|
||||
parameters,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data cache — keyed by "indicatorId_symbol_periodSeconds_paramsHash"
|
||||
// Each entry maps timestamp-ms → row object
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DataRow = Record<string, number | null>
|
||||
const dataCache = new Map<string, Map<number, DataRow>>()
|
||||
|
||||
function cacheKey(indicatorId: string, symbol: string, periodSeconds: number, paramsHash: string): string {
|
||||
return `${indicatorId}_${symbol}_${periodSeconds}_${paramsHash}`
|
||||
}
|
||||
|
||||
function buildDataCache(result: EvaluateResult): Map<number, DataRow> {
|
||||
const map = new Map<number, DataRow>()
|
||||
for (const point of result.values) {
|
||||
const tsMs = (point.timestamp as number) * 1000 // server sends Unix seconds → ms
|
||||
map.set(tsMs, point as DataRow)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom study registry — config map shared between getter and composable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CustomStudyEntry {
|
||||
indicatorId: string
|
||||
pandasTaName: string
|
||||
parameters: Record<string, any>
|
||||
metadata: CustomIndicatorMetadata
|
||||
}
|
||||
|
||||
// cfgKey → per-instance config; populated by registerCustomStudy()
|
||||
const customStudyRegistry = new Map<string, CustomStudyEntry>()
|
||||
|
||||
// indicatorId → callback(newCfgKey); set by registerCustomStudy()
|
||||
// Called by the constructor when async data arrives to trigger TV re-run.
|
||||
const refreshCallbacks = new Map<string, (newCfgKey: string) => void>()
|
||||
|
||||
// TradingView widget reference — set by useCustomIndicators() so the
|
||||
// constructor can query the current visible range.
|
||||
let _tvWidget: any = null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic study design constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_PLOTS = 8
|
||||
|
||||
const MULTI_LINE_COLORS = [
|
||||
'#2196F3', '#FF9800', '#4CAF50', '#E91E63', '#9C27B0',
|
||||
'#00BCD4', '#FF5722', '#795548',
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom indicators getter
|
||||
// Pass the result of this function as the widget option:
|
||||
// custom_indicators_getter: getCustomIndicatorsGetter()
|
||||
//
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
// 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
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (_PineJS: any): Promise<any[]> => {
|
||||
return Promise.resolve([
|
||||
makeGenericStudy('dxo_customstudy_overlay', true),
|
||||
makeGenericStudy('dxo_customstudy_pane', false),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main composable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useCustomIndicators(tvWidget: any) {
|
||||
_tvWidget = tvWidget
|
||||
|
||||
const indicatorStore = useIndicatorStore()
|
||||
const chartStore = useChartStore()
|
||||
|
||||
// Maps indicator id → { cfgKey, tvStudyId, symbol }
|
||||
const registered = new Map<string, { cfgKey: string; tvStudyId: string | null; symbol: 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
|
||||
|
||||
function nextCfgKey(indicatorId: string): string {
|
||||
const v = (cfgVersions.get(indicatorId) || 0) + 1
|
||||
cfgVersions.set(indicatorId, v)
|
||||
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)
|
||||
if (!study) return
|
||||
const cols = meta.output_columns
|
||||
const overrides: Record<string, any> = {}
|
||||
|
||||
for (let i = 0; i < MAX_PLOTS; i++) {
|
||||
const col = cols[i]
|
||||
if (col == null) {
|
||||
overrides[`styles.plot_${i}.visible`] = false
|
||||
continue
|
||||
}
|
||||
const p = col.plot
|
||||
overrides[`styles.plot_${i}.visible`] = p?.visible ?? true
|
||||
overrides[`styles.plot_${i}.plottype`] = p?.style ?? 0
|
||||
overrides[`styles.plot_${i}.linewidth`] = p?.linewidth ?? 2
|
||||
overrides[`styles.plot_${i}.linestyle`] = 0
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Register a custom indicator as a TV study instance
|
||||
// ------------------------------------------------------------------
|
||||
async function registerCustomStudy(indicator: IndicatorInstance) {
|
||||
const meta = indicator.custom_metadata
|
||||
if (!meta) {
|
||||
console.warn('[CustomIndicators] No custom_metadata on indicator:', indicator.id)
|
||||
return
|
||||
}
|
||||
|
||||
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, {
|
||||
indicatorId: indicator.id,
|
||||
pandasTaName: indicator.pandas_ta_name,
|
||||
parameters: indicator.parameters,
|
||||
metadata: meta,
|
||||
})
|
||||
|
||||
// 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
|
||||
try {
|
||||
const study = tvWidget.activeChart().getStudyById(entry.tvStudyId)
|
||||
if (study) {
|
||||
registered.set(indicator.id, { ...entry, cfgKey: newCfgKey })
|
||||
study.setInputValues([{ id: '_cfg', value: newCfgKey }])
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[CustomIndicators] Could not refresh study after data load:', err)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const tvStudyId = (await tvWidget.activeChart().createStudy(
|
||||
studyTypeName, forceOverlay, false,
|
||||
{ _cfg: cfgKey }
|
||||
)) as string | null
|
||||
|
||||
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 */ }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Remove a custom study from the chart
|
||||
// ------------------------------------------------------------------
|
||||
function removeCustomStudy(indicatorId: string) {
|
||||
const entry = registered.get(indicatorId)
|
||||
if (!entry) return
|
||||
registered.delete(indicatorId)
|
||||
lastParams.delete(indicatorId)
|
||||
refreshCallbacks.delete(indicatorId)
|
||||
|
||||
if (entry.tvStudyId) {
|
||||
try { tvWidget.activeChart().removeStudy(entry.tvStudyId) } catch { /* already gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
removeCustomStudy(indicator.id)
|
||||
await registerCustomStudy(indicator)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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.
|
||||
// ------------------------------------------------------------------
|
||||
watch(
|
||||
() => indicatorStore.indicators,
|
||||
async (newIndicators) => {
|
||||
if (!isChartReady) return
|
||||
|
||||
for (const [id, indicator] of Object.entries(newIndicators)) {
|
||||
if (!indicator.pandas_ta_name.startsWith('custom_')) continue
|
||||
|
||||
if (!registered.has(id)) {
|
||||
lastParams.set(id, JSON.stringify(indicator.parameters))
|
||||
await registerCustomStudy(indicator)
|
||||
} else {
|
||||
const entry = registered.get(id)!
|
||||
const currParams = JSON.stringify(indicator.parameters)
|
||||
const prevParams = lastParams.get(id)
|
||||
const currSymbol = indicator.symbol || chartStore.symbol
|
||||
if (currParams !== prevParams || currSymbol !== entry.symbol) {
|
||||
lastParams.set(id, currParams)
|
||||
await refreshCustomStudy(indicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle removals
|
||||
for (const id of registered.keys()) {
|
||||
if (!(id in newIndicators)) {
|
||||
lastParams.delete(id)
|
||||
removeCustomStudy(id)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Re-fetch when chart resolution changes
|
||||
watch(
|
||||
() => chartStore.period,
|
||||
() => {
|
||||
if (!isChartReady) return
|
||||
for (const [id, indicator] of Object.entries(indicatorStore.indicators)) {
|
||||
if (!indicator.pandas_ta_name.startsWith('custom_')) continue
|
||||
if (registered.has(id)) {
|
||||
lastParams.set(id, JSON.stringify(indicator.parameters))
|
||||
refreshCustomStudy(indicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
for (const id of [...registered.keys()]) {
|
||||
removeCustomStudy(id)
|
||||
}
|
||||
registered.clear()
|
||||
cfgVersions.clear()
|
||||
lastParams.clear()
|
||||
if (_tvWidget === tvWidget) _tvWidget = null
|
||||
isChartReady = false
|
||||
}
|
||||
}
|
||||
@@ -259,37 +259,17 @@ export class WebSocketDatafeed implements IBasicDataFeed {
|
||||
|
||||
const rawBars: any[] = response.history.bars || []
|
||||
|
||||
// Parse bars, preserving null OHLC for gap bars (no trades that period)
|
||||
const parsedBars: Bar[] = rawBars.map((bar: any) => {
|
||||
if (bar.open === null || bar.close === null) {
|
||||
return { time: bar.time * 1000, open: null, high: null, low: null, close: null }
|
||||
}
|
||||
return {
|
||||
time: bar.time * 1000,
|
||||
open: parseFloat(bar.open) / denoms.tick,
|
||||
high: parseFloat(bar.high) / denoms.tick,
|
||||
low: parseFloat(bar.low) / denoms.tick,
|
||||
close: parseFloat(bar.close) / denoms.tick,
|
||||
volume: parseFloat(bar.volume) / denoms.base
|
||||
}
|
||||
})
|
||||
// All bars have non-null prices — ingestor forward-fills interior gaps.
|
||||
const bars: Bar[] = rawBars.map((bar: any) => ({
|
||||
time: bar.time * 1000,
|
||||
open: parseFloat(bar.open) / denoms.tick,
|
||||
high: parseFloat(bar.high) / denoms.tick,
|
||||
low: parseFloat(bar.low) / denoms.tick,
|
||||
close: parseFloat(bar.close) / denoms.tick,
|
||||
volume: parseFloat(bar.volume) / denoms.base
|
||||
}))
|
||||
|
||||
parsedBars.sort((a, b) => a.time - b.time)
|
||||
|
||||
// Fill any gaps between returned bars with null bars so TradingView
|
||||
// receives a contiguous array of the correct length.
|
||||
const periodMs = intervalToSeconds(resolution) * 1000
|
||||
const bars: Bar[] = []
|
||||
for (let i = 0; i < parsedBars.length; i++) {
|
||||
if (i > 0) {
|
||||
const prev = parsedBars[i - 1].time
|
||||
const curr = parsedBars[i].time
|
||||
for (let t = prev + periodMs; t < curr; t += periodMs) {
|
||||
bars.push({ time: t, open: null, high: null, low: null, close: null })
|
||||
}
|
||||
}
|
||||
bars.push(parsedBars[i])
|
||||
}
|
||||
bars.sort((a, b) => a.time - b.time)
|
||||
|
||||
console.log('[TradingView Datafeed] Scaled bar sample:', bars[0])
|
||||
|
||||
|
||||
@@ -367,6 +367,22 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -781,6 +797,10 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@ class WebSocketManager {
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = Infinity // Keep trying indefinitely
|
||||
private reconnectDelay = 1000 // Start with 1 second
|
||||
private maxReconnectDelay = 15000 // Max 15 seconds
|
||||
private maxReconnectDelay = 50000 // Max 50 seconds
|
||||
|
||||
/**
|
||||
* Connect to WebSocket with JWT token for authentication
|
||||
|
||||
@@ -1,6 +1,63 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface CustomIndicatorParam {
|
||||
type: 'int' | 'float' | 'bool' | 'string'
|
||||
default: any
|
||||
description?: string
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-series plot configuration.
|
||||
* style maps to LineStudyPlotStyle: 0=Line, 1=Histogram, 3=Dots/Cross,
|
||||
* 4=Area, 5=Columns, 6=Circles, 9=StepLine.
|
||||
*/
|
||||
export interface PlotConfig {
|
||||
style: number
|
||||
color?: string
|
||||
linewidth?: number
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
/** Shaded region between two plots ("plot_plot") or two bands ("hline_hline"). */
|
||||
export interface FilledAreaConfig {
|
||||
id: string
|
||||
type: 'plot_plot' | 'hline_hline'
|
||||
series1: string
|
||||
series2: string
|
||||
color?: string
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
/** Horizontal reference line (e.g. RSI ob/os). linestyle: 0=solid, 1=dotted, 2=dashed. */
|
||||
export interface BandConfig {
|
||||
id: string
|
||||
value: number
|
||||
color?: string
|
||||
linewidth?: number
|
||||
linestyle?: number
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export interface CustomIndicatorColumn {
|
||||
name: string
|
||||
display_name?: string
|
||||
description?: string
|
||||
plot?: PlotConfig
|
||||
}
|
||||
|
||||
export interface CustomIndicatorMetadata {
|
||||
display_name: string
|
||||
parameters: Record<string, CustomIndicatorParam>
|
||||
input_series: string[]
|
||||
output_columns: CustomIndicatorColumn[]
|
||||
pane: 'price' | 'separate'
|
||||
filled_areas?: FilledAreaConfig[]
|
||||
bands?: BandConfig[]
|
||||
}
|
||||
|
||||
export interface IndicatorInstance {
|
||||
id: string
|
||||
pandas_ta_name: string
|
||||
@@ -15,6 +72,8 @@ export interface IndicatorInstance {
|
||||
created_at?: number
|
||||
modified_at?: number
|
||||
original_id?: string
|
||||
/** Populated for custom_ indicators; drives TV custom study auto-construction. */
|
||||
custom_metadata?: CustomIndicatorMetadata
|
||||
}
|
||||
|
||||
export const useIndicatorStore = defineStore('indicators', () => {
|
||||
|
||||
Reference in New Issue
Block a user