/** * 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 (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 = 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, timeoutMs?: number): 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')) }, timeoutMs ?? 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 { // On reconnect the server sends a fresh 'connected' message. // Any pending requests were sent on the old socket and will never be answered, // so reject them immediately so TradingView can retry on the new connection. if (message.type === 'connected' && this.pendingRequests.size > 0) { console.warn('[TradingView Datafeed] WebSocket reconnected — rejecting', this.pendingRequests.size, 'stale pending request(s)') for (const [requestId, pending] of this.pendingRequests) { clearTimeout(pending.timeout) pending.reject(new Error('WebSocket reconnected')) this.pendingRequests.delete(requestId) } return } // 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) // Derive scale denominators from Nautilus precision fields. // price_precision=2 → tick divisor=100 (prices stored as integer cents) // size_precision=6 → base divisor=1_000_000 (volumes stored as integer micro-units) const symbolKey = response.symbol_info.ticker || response.symbol_info.name const tickDenom = Math.pow(10, response.symbol_info.price_precision ?? 0) const baseDenom = Math.pow(10, response.symbol_info.size_precision ?? 0) 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 } const doRequest = () => this.sendRequest({ 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]) console.log('[TradingView Datafeed] Denominators:', denoms) const rawBars: any[] = response.history.bars || [] // 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 })) bars.sort((a, b) => a.time - b.time) 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() }