data fixes, partial custom indicator support

This commit is contained in:
2026-04-08 21:28:31 -04:00
parent b701554996
commit a70dcd954f
81 changed files with 5438 additions and 1852 deletions

View File

@@ -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

View File

@@ -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;
}

View 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
}
}

View File

@@ -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])

View File

@@ -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

View File

@@ -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

View File

@@ -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', () => {