/** * TradingView Datafeed implementation using WebSocket backend * * This adapter translates TradingView's Datafeed API calls into * WebSocket messages that match the backend protocol defined in * backend/src/datasource/websocket_protocol.py * * Uses the shared WebSocket connection for all communication. */ import { wsManager, type MessageHandler } from './useWebSocket' import { intervalToSeconds } from '../utils' import type { IBasicDataFeed, DatafeedConfiguration, LibrarySymbolInfo, Bar, SearchSymbolResultItem, HistoryMetadata } from '../types/tradingview' interface PendingRequest { resolve: (value: any) => void reject: (reason: any) => void timeout: number } interface Subscription { symbolInfo: LibrarySymbolInfo resolution: string onTick: (bar: Bar) => void subscriptionId: string } interface SymbolDenominators { tick: number base: number } export class WebSocketDatafeed implements IBasicDataFeed { private pendingRequests: Map = new Map() private subscriptions: Map = new Map() private requestTimeout = 10000 // 10 seconds private configuration: DatafeedConfiguration | null = null private messageHandler: MessageHandler private symbolDenominators: Map = new Map() // Track denominators per symbol constructor() { // Use the shared WebSocket connection (managed by App.vue authentication) // Register message handler for datafeed messages this.messageHandler = this.handleMessage.bind(this) wsManager.addHandler(this.messageHandler) } private generateRequestId(): string { return `tv_${Date.now()}_${Math.random().toString(36).substring(7)}` } private sendRequest(message: any): Promise { const requestId = message.request_id || this.generateRequestId() message.request_id = requestId console.log('[TradingView Datafeed] Sending request:', requestId, message.type, message) return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { console.error('[TradingView Datafeed] Request timeout:', requestId, message.type) this.pendingRequests.delete(requestId) reject(new Error('Request timeout')) }, this.requestTimeout) this.pendingRequests.set(requestId, { resolve, reject, timeout }) const wsState = wsManager.getWebSocket()?.readyState console.log('[TradingView Datafeed] WebSocket state before send:', wsState, 'OPEN=' + WebSocket.OPEN) wsManager.send(message) }) } private handleMessage(message: any): void { // Handle responses to pending requests if (message.request_id && this.pendingRequests.has(message.request_id)) { console.log('[TradingView Datafeed] Found pending request for:', message.request_id) const pending = this.pendingRequests.get(message.request_id)! this.pendingRequests.delete(message.request_id) clearTimeout(pending.timeout) if (message.type === 'error') { console.error('[TradingView Datafeed] Resolving with error:', message.error_message) pending.reject(new Error(message.error_message || 'Unknown error')) } else { console.log('[TradingView Datafeed] Resolving with response:', message.type) pending.resolve(message) } } else if (message.request_id) { console.warn('[TradingView Datafeed] No pending request found for:', message.request_id, 'Available:', Array.from(this.pendingRequests.keys())) } // Handle real-time bar updates if (message.type === 'bar_update') { const subscription = this.subscriptions.get(message.subscription_id) if (subscription && message.bar) { const symbolKey = subscription.symbolInfo.ticker || subscription.symbolInfo.name const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 } const bar: Bar = { time: message.bar.time * 1000, // Convert to milliseconds open: parseFloat(message.bar.open) / denoms.tick, high: parseFloat(message.bar.high) / denoms.tick, low: parseFloat(message.bar.low) / denoms.tick, close: parseFloat(message.bar.close) / denoms.tick, volume: parseFloat(message.bar.volume) / denoms.base } subscription.onTick(bar) } } } onReady(callback: (configuration: DatafeedConfiguration) => void): void { // TradingView requires callback to be called asynchronously if (this.configuration) { setTimeout(() => callback(this.configuration!), 0) return } this.sendRequest({ type: 'get_config' }) .then((response) => { this.configuration = response.config setTimeout(() => callback(this.configuration!), 0) }) .catch((error) => { console.error('Failed to get datafeed configuration:', error) // Provide default configuration const defaultConfig: DatafeedConfiguration = { supported_resolutions: ['1', '5', '15', '30', '60', '240', '1D', '1W'], supports_search: true, supports_group_request: false, supports_marks: false, supports_timescale_marks: false, supports_time: false } this.configuration = defaultConfig setTimeout(() => callback(defaultConfig), 0) }) } searchSymbols( userInput: string, exchange: string, symbolType: string, onResult: (items: SearchSymbolResultItem[]) => void ): void { this.sendRequest({ type: 'search_symbols', query: userInput, exchange: exchange || undefined, symbol_type: symbolType || undefined, limit: 30 }) .then((response) => { onResult(response.results || []) }) .catch((error) => { console.error('Symbol search failed:', error) onResult([]) }) } resolveSymbol( symbolName: string, onResolve: (symbolInfo: LibrarySymbolInfo) => void, onError: (reason: string) => void ): void { console.log('[TradingView Datafeed] Resolving symbol:', symbolName) console.log('[TradingView Datafeed] WebSocket state:', wsManager.getWebSocket()?.readyState) this.sendRequest({ type: 'resolve_symbol', symbol: symbolName }) .then((response) => { console.log('[TradingView Datafeed] Received response:', response) if (response.symbol_info) { console.log('[TradingView Datafeed] Resolved symbol info:', response.symbol_info) // Store the denominators for this symbol const symbolKey = response.symbol_info.ticker || response.symbol_info.name const tickDenom = response.symbol_info.tick_denominator || 1 const baseDenom = response.symbol_info.base_denominator || 1 this.symbolDenominators.set(symbolKey, { tick: tickDenom, base: baseDenom }) console.log('[TradingView Datafeed] Stored denominators:', symbolKey, { tick: tickDenom, base: baseDenom }) onResolve(response.symbol_info) } else { console.error('[TradingView Datafeed] No symbol_info in response') onError('Symbol not found') } }) .catch((error) => { console.error('[TradingView Datafeed] Failed to resolve symbol:', symbolName, error) console.error('[TradingView Datafeed] Error details:', { message: error.message, stack: error.stack, pendingRequests: this.pendingRequests.size }) onError(error instanceof Error ? error.message : 'Unknown error') }) } getBars( symbolInfo: LibrarySymbolInfo, resolution: string, periodParams: { from: number to: number firstDataRequest: boolean countBack?: number }, onResult: (bars: Bar[], meta: HistoryMetadata) => void, onError: (reason: string) => void ): void { const symbolKey = symbolInfo.ticker || symbolInfo.name const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 } this.sendRequest({ type: 'get_bars', symbol: symbolKey, resolution: resolution, from_time: periodParams.from, to_time: periodParams.to, countback: periodParams.countBack }) .then((response) => { if (response.history) { console.log('[TradingView Datafeed] Raw bar sample:', response.history.bars?.[0]) console.log('[TradingView Datafeed] Denominators:', denoms) 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 } }) 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]) } console.log('[TradingView Datafeed] Scaled bar sample:', bars[0]) const meta: HistoryMetadata = { noData: bars.length === 0, nextTime: response.history.next_time } onResult(bars, meta) } else { onResult([], { noData: true }) } }) .catch((error) => { console.error('Failed to get bars:', error) onError(error instanceof Error ? error.message : 'Unknown error') }) } subscribeBars( symbolInfo: LibrarySymbolInfo, resolution: string, onTick: (bar: Bar) => void, listenerGuid: string, onResetCacheNeededCallback: () => void ): void { this.sendRequest({ type: 'subscribe_bars', symbol: symbolInfo.ticker || symbolInfo.name, resolution: resolution, subscription_id: listenerGuid }) .then((response) => { if (response.success) { this.subscriptions.set(listenerGuid, { symbolInfo, resolution, onTick, subscriptionId: listenerGuid }) } }) .catch((error) => { console.error('Failed to subscribe to bars:', error) }) } unsubscribeBars(listenerGuid: string): void { this.sendRequest({ type: 'unsubscribe_bars', subscription_id: listenerGuid }) .then(() => { this.subscriptions.delete(listenerGuid) }) .catch((error) => { console.error('Failed to unsubscribe from bars:', error) }) } destroy(): void { // Clean up all subscriptions for (const [guid] of this.subscriptions) { this.unsubscribeBars(guid) } this.subscriptions.clear() // Clear pending requests for (const [requestId, pending] of this.pendingRequests) { clearTimeout(pending.timeout) pending.reject(new Error('Datafeed destroyed')) } this.pendingRequests.clear() // Remove message handler from shared WebSocket wsManager.removeHandler(this.messageHandler) } } export function createTradingViewDatafeed(): IBasicDataFeed { return new WebSocketDatafeed() }