365 lines
12 KiB
Vue
365 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
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 {
|
|
if (seconds % 86400 === 0) return `${seconds / 86400}D`
|
|
if (seconds % 3600 === 0) return `${seconds / 3600}H`
|
|
return `${seconds / 60}` // plain number = minutes
|
|
}
|
|
|
|
const chartContainer = ref<HTMLDivElement | null>(null)
|
|
const chartStore = useChartStore()
|
|
let tvWidget: IChartingLibraryWidget | null = null
|
|
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(() => {
|
|
// 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
|
|
function initChart() {
|
|
if (!window.TradingView) {
|
|
setTimeout(initChart, 100)
|
|
return
|
|
}
|
|
|
|
try {
|
|
datafeed = createTradingViewDatafeed()
|
|
|
|
tvWidget = new window.TradingView.widget({
|
|
symbol: chartStore.symbol, // Use symbol from store
|
|
datafeed: datafeed,
|
|
interval: secondsToInterval(chartStore.period) as any,
|
|
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.
|
|
// 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' },
|
|
{ name: 'Weighted Moving Average' },
|
|
{ name: 'DEMA' },
|
|
{ name: 'TEMA' },
|
|
{ name: 'Triangular Moving Average' },
|
|
{ name: 'KAMA' },
|
|
{ name: 'MESA Adaptive Moving Average' },
|
|
{ name: 'T3' },
|
|
{ name: 'Bollinger Bands' },
|
|
{ name: 'Midpoint' },
|
|
{ name: 'Midprice' },
|
|
{ name: 'Parabolic SAR' },
|
|
{ name: 'Hilbert Transform - Instantaneous Trendline' },
|
|
// Momentum Indicators (21)
|
|
{ name: 'Relative Strength Index' },
|
|
{ name: 'Momentum' },
|
|
{ name: 'Rate of Change' },
|
|
{ name: 'TRIX' },
|
|
{ name: 'Chande Momentum Oscillator' },
|
|
{ name: 'Directional Movement Index' },
|
|
{ name: 'Average Directional Movement Index' },
|
|
{ name: 'Average Directional Movement Index Rating' },
|
|
{ name: 'Absolute Price Oscillator' },
|
|
{ name: 'Percentage Price Oscillator' },
|
|
{ name: 'MACD' },
|
|
{ name: 'Money Flow Index' },
|
|
{ name: 'Stochastic' },
|
|
{ name: 'Stochastic Fast' },
|
|
{ name: 'Stochastic RSI' },
|
|
{ name: 'Williams %R' },
|
|
{ name: 'Commodity Channel Index' },
|
|
{ name: 'Aroon' },
|
|
{ name: 'Aroon Oscillator' },
|
|
{ name: 'Balance Of Power' },
|
|
{ name: 'Ultimate Oscillator' },
|
|
// Volume Indicators (3)
|
|
{ name: 'Chaikin A/D Line' },
|
|
{ name: 'Chaikin A/D Oscillator' },
|
|
{ name: 'On Balance Volume' },
|
|
// Volatility Indicators (3)
|
|
{ name: 'Average True Range' },
|
|
{ name: 'Normalized Average True Range' },
|
|
{ name: 'True Range' },
|
|
// Price Transform (4)
|
|
{ name: 'Average Price' },
|
|
{ name: 'Median Price' },
|
|
{ name: 'Typical Price' },
|
|
{ name: 'Weighted Close Price' },
|
|
// Cycle Indicators (5)
|
|
{ name: 'Hilbert Transform - Dominant Cycle Period' },
|
|
{ name: 'Hilbert Transform - Dominant Cycle Phase' },
|
|
{ name: 'Hilbert Transform - Phasor Components' },
|
|
{ name: 'Hilbert Transform - SineWave' },
|
|
{ name: 'Hilbert Transform - Trend vs Cycle Mode' },
|
|
// Statistic Functions (9)
|
|
{ name: 'Beta' },
|
|
{ name: 'Pearson\'s Correlation Coefficient' },
|
|
{ name: 'Linear Regression' },
|
|
{ name: 'Linear Regression Angle' },
|
|
{ name: 'Linear Regression Intercept' },
|
|
{ name: 'Linear Regression Slope' },
|
|
{ name: 'Standard Deviation' },
|
|
{ name: 'Time Series Forecast' },
|
|
{ name: 'Variance' },
|
|
// Custom Indicators (12)
|
|
{ name: 'VWAP' },
|
|
{ name: 'VWMA' },
|
|
{ name: 'Hull Moving Average' },
|
|
{ name: 'SuperTrend' },
|
|
{ name: 'Donchian Channels' },
|
|
{ name: 'Keltner Channels' },
|
|
{ name: 'Chaikin Money Flow' },
|
|
{ name: 'Vortex Indicator' },
|
|
{ name: 'Awesome Oscillator' },
|
|
{ name: 'Accelerator Oscillator' },
|
|
{ name: 'Choppiness Index' },
|
|
{ name: 'Mass Index' }
|
|
]
|
|
},
|
|
fullscreen: false,
|
|
autosize: true,
|
|
theme: 'Dark',
|
|
timezone: 'Etc/UTC'
|
|
})
|
|
|
|
tvWidget.onChartReady(() => {
|
|
console.log('TradingView chart ready')
|
|
setupChartListeners()
|
|
setupStoreWatchers()
|
|
// Initialize visible range on chart load
|
|
initializeVisibleRange()
|
|
// Setup shape synchronization
|
|
if (tvWidget) {
|
|
shapeCleanup = useTradingViewShapes(tvWidget)
|
|
indicatorCleanup = useTradingViewIndicators(tvWidget)
|
|
customIndicatorCleanup = useCustomIndicators(tvWidget)
|
|
}
|
|
})
|
|
} catch (error) {
|
|
console.error('Failed to initialize TradingView widget:', error)
|
|
}
|
|
}
|
|
|
|
function initializeVisibleRange() {
|
|
if (!tvWidget) return
|
|
|
|
const chart = tvWidget.activeChart()
|
|
const timeScale = chart.getVisibleRange()
|
|
|
|
if (timeScale) {
|
|
const startTime = Math.floor(timeScale.from)
|
|
const endTime = Math.floor(timeScale.to)
|
|
|
|
console.log('[ChartView] Initial visible range:', {
|
|
from: new Date(startTime * 1000).toISOString(),
|
|
to: new Date(endTime * 1000).toISOString()
|
|
})
|
|
|
|
chartStore.start_time = startTime
|
|
chartStore.end_time = endTime
|
|
}
|
|
}
|
|
|
|
function setupChartListeners() {
|
|
if (!tvWidget) return
|
|
|
|
const chart = tvWidget.activeChart()
|
|
|
|
// Listen for symbol changes
|
|
chart.onSymbolChanged().subscribe(null, () => {
|
|
const symbolInfo = chart.symbolExt()
|
|
if (symbolInfo && symbolInfo.ticker) {
|
|
console.log('[ChartView] Symbol changed to:', symbolInfo.ticker)
|
|
isUpdatingFromChart = true
|
|
chartStore.symbol = symbolInfo.ticker
|
|
isUpdatingFromChart = false
|
|
}
|
|
})
|
|
|
|
// Listen for period changes
|
|
chart.onIntervalChanged().subscribe(null, (interval: string) => {
|
|
const seconds = intervalToSeconds(interval)
|
|
console.log('[ChartView] Period changed to:', interval, `(${seconds}s)`)
|
|
isUpdatingFromChart = true
|
|
chartStore.period = seconds
|
|
isUpdatingFromChart = false
|
|
})
|
|
|
|
// Listen for visible range changes (when user pans/zooms)
|
|
chart.onVisibleRangeChanged().subscribe(null, () => {
|
|
const timeScale = chart.getVisibleRange()
|
|
if (timeScale) {
|
|
// Convert from seconds to seconds (TradingView uses seconds for visible range)
|
|
const startTime = Math.floor(timeScale.from)
|
|
const endTime = Math.floor(timeScale.to)
|
|
|
|
console.log('[ChartView] Visible range changed:', {
|
|
from: new Date(startTime * 1000).toISOString(),
|
|
to: new Date(endTime * 1000).toISOString()
|
|
})
|
|
|
|
isUpdatingFromChart = true
|
|
chartStore.start_time = startTime
|
|
chartStore.end_time = endTime
|
|
isUpdatingFromChart = false
|
|
}
|
|
})
|
|
}
|
|
|
|
function setupStoreWatchers() {
|
|
if (!tvWidget) return
|
|
|
|
const chart = tvWidget.activeChart()
|
|
|
|
// Watch for external changes to symbol (e.g., from backend/agent)
|
|
watch(
|
|
() => chartStore.symbol,
|
|
(newSymbol) => {
|
|
if (isUpdatingFromChart) return // Ignore updates that came from the chart itself
|
|
|
|
console.log('[ChartView] Store symbol changed externally to:', newSymbol)
|
|
const currentSymbol = chart.symbolExt()
|
|
if (currentSymbol && currentSymbol.ticker !== newSymbol) {
|
|
chart.setSymbol(newSymbol, () => {
|
|
console.log('[ChartView] Chart symbol updated to:', newSymbol)
|
|
})
|
|
}
|
|
}
|
|
)
|
|
|
|
// Watch for external changes to period
|
|
watch(
|
|
() => chartStore.period,
|
|
(newPeriod) => {
|
|
if (isUpdatingFromChart) return
|
|
|
|
const tvInterval = secondsToInterval(newPeriod)
|
|
console.log('[ChartView] Store period changed externally to:', newPeriod, `-> ${tvInterval}`)
|
|
if (chart.resolution() !== tvInterval) {
|
|
chart.setResolution(tvInterval, () => {
|
|
console.log('[ChartView] Chart period updated to:', tvInterval)
|
|
})
|
|
}
|
|
}
|
|
)
|
|
|
|
// Note: Visible range is typically only set by user interaction,
|
|
// but we could add a watcher here if we want the backend to be able
|
|
// to change the visible range programmatically
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
// Cleanup shape synchronization
|
|
if (shapeCleanup) {
|
|
shapeCleanup()
|
|
shapeCleanup = null
|
|
}
|
|
|
|
// Cleanup indicator synchronization
|
|
if (indicatorCleanup) {
|
|
indicatorCleanup()
|
|
indicatorCleanup = null
|
|
}
|
|
|
|
// Cleanup custom TV studies
|
|
if (customIndicatorCleanup) {
|
|
customIndicatorCleanup()
|
|
customIndicatorCleanup = null
|
|
}
|
|
|
|
if (tvWidget) {
|
|
tvWidget.remove()
|
|
tvWidget = null
|
|
}
|
|
if (datafeed && typeof datafeed.destroy === 'function') {
|
|
datafeed.destroy()
|
|
datafeed = null
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Card class="chart-card">
|
|
<template #content>
|
|
<div ref="chartContainer" class="chart-container"></div>
|
|
</template>
|
|
</Card>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.chart-card {
|
|
height: 100% !important;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
border: none;
|
|
border-radius: 0 !important;
|
|
background: var(--p-surface-0);
|
|
}
|
|
|
|
.chart-card :deep(.p-card) {
|
|
border-radius: 0 !important;
|
|
}
|
|
|
|
.chart-card :deep(.p-card-body) {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.chart-card :deep(.p-card-content) {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
padding: 0;
|
|
}
|
|
|
|
.chart-container {
|
|
flex: 1;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
</style>
|