360 lines
12 KiB
TypeScript
360 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
|
|
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): 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'))
|
|
}, 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<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)
|
|
|
|
// 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<any>({
|
|
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<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()
|
|
}
|