Files
ai/web/src/composables/useTradingViewDatafeed.ts
2026-04-09 17:00:43 -04:00

365 lines
12 KiB
TypeScript

/**
* 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<string, PendingRequest> = new Map()
private subscriptions: Map<string, Subscription> = 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<string, SymbolDenominators> = 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<T>(message: any, timeoutMs?: number): Promise<T> {
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<any>({
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<any>({
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<any>({
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<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])
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<any>({
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<any>({
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()
}