Files
web/src/charts/chart.js
2025-04-09 20:57:29 -04:00

519 lines
18 KiB
JavaScript

import {useChartOrderStore} from "@/orderbuild.js";
import {invokeCallbacks, prototype} from "@/common.js";
import {DataFeed, defaultSymbol, feelessTickerKey, getAllSymbols, lookupSymbol} from "@/charts/datafeed.js";
import {intervalToSeconds, SingletonCoroutine, toHuman, toPrecision} from "@/misc.js";
import {usePrefStore, useStore} from "@/store/store.js";
import {tvCustomThemes} from "../../theme.js";
export let widget = null
export let chart = null
export let crosshairPoint = null
let symbolChangedCbs = [] // callbacks for TV's chart.onSymbolChanged()
const s = useStore()
const co = useChartOrderStore()
const prefs = usePrefStore()
export function addSymbolChangedCallback(cb) {
symbolChangedCbs.push(cb)
}
export function removeSymbolChangedCallback(cb) {
symbolChangedCbs = symbolChangedCbs.filter((i)=>i!==cb)
}
function symbolChanged(symbol) {
const info = symbol===null ? (defaultSymbol===null?'default':defaultSymbol) : lookupSymbol(symbol.ticker)
co.selectedSymbol = info
console.log('setting prefs ticker', info.ticker)
prefs.selectedTicker = info.ticker
symbolChangedCbs.forEach((cb) => cb(info))
updateFeeDropdown()
console.log('symbol changed', info)
}
export async function setSymbol(symbol, interval=null) {
if (interval===null)
interval = widget.symbolInterval().interval
await new Promise(resolve => {
if (co.selectedSymbol?.ticker !== symbol.ticker)
widget.setSymbol(symbol.ticker, interval, ()=>setTimeout(resolve,0))
else
resolve()
})
}
export async function setSymbolTicker(ticker) {
const found = getAllSymbols()[ticker]
if (!found) {
console.error('No symbol for ticker', ticker)
return
}
await setSymbol(found)
}
function changeInterval(interval) {
co.intervalSecs = intervalToSeconds(interval)
prefs.selectedTimeframe = interval
DataFeed.intervalChanged(co.intervalSecs)
}
function dataLoaded() {
const range = chartMeanRange()
console.log('new mean range', range,)
co.meanRange = range
}
/* TradingView event keystrings
const subscribeEvents = [
'toggle_sidebar', 'indicators_dialog', 'toggle_header', 'edit_object_dialog', 'chart_load_requested',
'chart_loaded', 'mouse_down', 'mouse_up', 'drawing', 'study', 'undo', 'redo', 'undo_redo_state_changed',
'reset_scales', 'compare_add', 'add_compare', 'load_study_template', 'onTick', 'onAutoSaveNeeded',
'onScreenshotReady', 'onMarkClick', 'onPlusClick', 'onTimescaleMarkClick', 'onSelectedLineToolChanged',
'layout_about_to_be_changed', 'layout_changed', 'activeChartChanged', 'series_event', 'study_event',
'drawing_event', 'study_properties_changed', 'series_properties_changed', 'panes_height_changed',
'panes_order_changed',
]
*/
let poolButtonTextElement = null
function initFeeDropdown() {
const button = widget.createButton()
button.setAttribute('title', 'See Pool Info and Choose Fee');
button.addEventListener('click', function () {
co.showPoolSelection = true
});
button.id = 'pool-button'
button.style.height = '34px';
button.style.display = 'flex';
button.style.alignItems = 'center';
button.addEventListener('mouseover', () => {
button.style.backgroundColor = 'rgb(60,60,60)';
});
button.addEventListener('mouseout', () => {
button.style.backgroundColor = '';
});
button.style.margin = '2px 0';
button.style.borderRadius = '4px';
button.classList.add('pool-button')
let img = document.createElement('img');
img.src = '/arbitrum-logo.svg';
img.style.width = '1em';
img.style.marginRight = '0.2em';
button.appendChild(img);
img = document.createElement('img');
img.src = '/uniswap-logo.svg';
img.style.height = '1.25em';
img.style.marginRight = '0.2em';
img.style.backgroundColor = 'white';
img.style.borderRadius = '50%';
button.appendChild(img);
const span = document.createElement('span');
span.style.marginY = 'auto';
button.appendChild(span);
poolButtonTextElement = span
updateFeeDropdown()
}
export function updateFeeDropdown() {
if (poolButtonTextElement===null) return
const symbolItem = useChartOrderStore().selectedSymbol
let text = ''
text += (symbolItem.fee / 10000).toFixed(2) + '%'
const index = symbolItem.feeGroup.findIndex((p) => p[1] === symbolItem.fee)
if (symbolItem.liquiditySymbol) {
const liq = symbolItem.liquidities[index]
if (symbolItem.liquiditySymbol === 'USD')
text += ` $${toHuman(liq)}`
else
text = ` ${toHuman(liq)} ${symbolItem.liquiditySymbol}`
}
poolButtonTextElement.textContent = text
}
export function initTVButtons() {
initFeeDropdown();
}
export function initWidget(el) {
const symbol = prefs.selectedTicker === null ? 'default' : prefs.selectedTicker
const interval = prefs.selectedTimeframe === null ? '15' : prefs.selectedTimeframe
widget = window.tvWidget = new TradingView.widget({
// Widget Options
// https://www.tradingview.com/charting-library-docs/latest/api/interfaces/Charting_Library.ChartingLibraryWidgetOptions
library_path: "/charting_library/",
// debug: true,
autosize: true,
symbol,
interval,
container: el,
datafeed: DataFeed, // use this for ohlc
locale: "en",
disabled_features: ['main_series_scale_menu',],
enabled_features: ['saveload_separate_drawings_storage',],
// drawings_access: {type: 'white', tools: [],}, // show no tools
custom_themes: tvCustomThemes,
theme: useStore().theme,
timezone: prefs.timezone,
// Chart Overrides
// https://www.tradingview.com/charting-library-docs/latest/customization/overrides/chart-overrides
overrides: {
"mainSeriesProperties.priceAxisProperties.log": false,
}
});
// debug dump all events
// for( const event of subscribeEvents )
// widget.subscribe(event, ()=>console.log('event', event, arguments))
widget.subscribe('drawing_event', handleDrawingEvent)
widget.subscribe('onSelectedLineToolChanged', onSelectedLineToolChanged)
widget.subscribe('mouse_down', mouseDown)
widget.subscribe('mouse_up', mouseUp)
widget.headerReady().then(()=>initTVButtons())
widget.onChartReady(initChart)
console.log('tv widget initialized')
}
function initChart() {
console.log('init chart')
chart = widget.activeChart()
const themeName = useStore().theme;
widget.changeTheme(themeName).catch((e)=>console.warn(`Could not change theme to ${themeName}`, e))
chart.crossHairMoved().subscribe(null, (point)=>setTimeout(()=>handleCrosshairMovement(point),0) )
chart.onSymbolChanged().subscribe(null, symbolChanged)
chart.onIntervalChanged().subscribe(null, changeInterval)
chart.onDataLoaded().subscribe(null, dataLoaded)
const tzapi = chart.getTimezoneApi();
tzapi.onTimezoneChanged().subscribe(null, (tz)=>{if (tz==='exchange') tz='Etc/UTC'; s.timeZone=tz})
s.timeZone = tzapi.getTimezone().id
// chart.onHoveredSourceChanged().subscribe(null, ()=>console.log('hovered source changed', arguments))
// chart.selection().onChanged().subscribe(null, s => console.log('selection', chart.selection().allSources()));
const symbolExt = chart.symbolExt();
// console.log('symbolExt', symbolExt);
if(symbolExt) {
symbolChanged(symbolExt)
}
else {
symbolChanged(null)
}
changeInterval(widget.symbolInterval().interval)
co.chartReady = true
console.log('chart ready')
}
// noinspection JSUnusedLocalSymbols
export const ShapeCallback = {
onDraw: () => {}, // start drawing a new shape for the builder
onRedraw: () => {}, // the mouse moved while in drawing mode
onUndraw: () => {}, // drawing was canceled by clicking on a different tool
onAddPoint: () => {}, // the user clicked a point while drawing (that point is added to the points list)
onCreate: (shapeId, shape, points, props) => {}, // the user has finished creating all the control points. drawing mode is exited and the initial shape is created.
onPoints: (shapeId, shape, points) => {}, // the control points of an existing shape were changed
onProps: (shapeId, shape, props) => {}, // the display properties of an existing shape were changed
onMove: (shapeId, shape, points) => {}, // the entire shape was moved without dragging control points
onDrag: (shapeId, shape, points) => {}, // the shape is being dragged: this gets called on every mouse movement update during a drag
onHide: (shapeId, shape, props) => {},
onShow: (shapeId, shape, props) => {},
onClick: (shapeId, shape) => {}, // the shape was selected
onDelete: (shapeId) => {},
}
// noinspection JSUnusedLocalSymbols
export const VerboseCallback = prototype(ShapeCallback, {
onDraw: ()=>{console.log('onDraw')},
// onRedraw: ()=>{console.log('onRedraw')},
onUndraw: ()=>{console.log('onUndraw')},
onAddPoint: ()=>{console.log('onAddPoint')},
onCreate: (shapeId, shape, points, props)=>{console.log('onCreate')},
onPoints: (shapeId, shape, points)=>{console.log('onPoints')},
onProps: (shapeId, shape, props)=>{console.log('onProps')},
onMove: (shapeId, shape, points)=>{console.log('onMove')},
onDrag: (shapeId, shape, points) => {console.log('onDrag')},
onHide: (shapeId, shape, props)=>{console.log('onHide')},
onShow: (shapeId, shape, props)=>{console.log('onShow')},
onClick: (shapeId, shape)=>{console.log('onClick')},
onDelete: (shapeId)=>{console.log('onDelete')},
})
let drawingTool = null
let previousDrawingTool = null
let drawingCallbacks = null
export function drawShape(shapeType, ...callbacks) {
// puts the chart into a line-drawing mode for a new shape
console.log('drawShape', callbacks, shapeType.name, shapeType.code)
if( drawingCallbacks )
invokeCallbacks(drawingCallbacks, 'onUndraw')
drawingCallbacks = callbacks
drawingTool = null
previousDrawingTool = widget.selectedLineTool()
co.drawing = true
widget.selectLineTool(shapeType.code)
invokeCallbacks(callbacks, 'onDraw')
}
export function createShape(shapeType, points, options={}, ...callbacks) {
const chart = widget.activeChart()
drawingCallbacks = null
cancelDrawing()
if (typeof shapeType === 'string' )
options.shape = shapeType
else
options.shape = shapeType.code
// programatically adds a shape to the chart
let shapeId
try {
// console.log('creating shape', points, options)
if (points.time || points.price)
shapeId = chart.createShape(points, options)
else if (points.length === 1)
shapeId = chart.createShape(points[0], options)
else
shapeId = chart.createMultipointShape(points, options)
}
catch (e) {
console.error('tvShape could not create', shapeType, points, options, e)
return null
}
if (shapeId===null) {
console.error('could not create shape', points, options)
}
allShapeIds.push(shapeId)
if( callbacks.length )
shapeCallbacks[shapeId] = callbacks
const shape = chart.getShapeById(shapeId)
// console.log('tvShape created', shapeId)
shape.bringToFront()
const props = shape.getProperties()
invokeCallbacks(callbacks, 'onCreate', shapeId, shape, points, props)
return shapeId
}
export let allShapeIds = []
export function cancelDrawing() {
if (co.drawing) {
co.drawing = false
widget.selectLineTool(previousDrawingTool)
invokeCallbacks(drawingCallbacks, 'onUndraw')
}
}
const shapeCallbacks = {}
function onSelectedLineToolChanged() {
const tool = widget.selectedLineTool();
console.log('line tool changed', tool)
if (drawingTool===null)
drawingTool = tool
else if (tool!==drawingTool && co.drawing)
cancelDrawing();
}
export let dragging = false
export let draggingShapeIds = []
function mouseDown() {
// console.log('mouseDown')
// todo push into drawing event queue instead, then set dragging there
dragging = true
}
function mouseUp() {
// console.log('mouseUp')
dragging = false
draggingShapeIds = []
}
function handleCrosshairMovement(point) {
crosshairHandler.invoke(point) // delayed invocation to await selection to register later in the tv loop
}
const crosshairHandler = new SingletonCoroutine(doHandleCrosshairMovement, 0)
function doHandleCrosshairMovement(point) {
// console.log('crosshair moved')
crosshairPoint = point
if (co.drawing)
invokeCallbacks(drawingCallbacks, 'onRedraw')
else if (dragging) {
const selection = chart.selection().allSources();
if (selection.length)
draggingShapeIds = selection
// console.log('dragging selected', draggingShapeIds)
for (const shapeId of draggingShapeIds) {
let shape
try {
shape = chart.getShapeById(shapeId);
}
catch (e) {
continue
}
const points = structuredClone(shape.getPoints());
const lpbe = shape._model._linePointBeingEdited
points[lpbe===null?0:lpbe] = point
// console.log('calling onDrag', points, shape)
invokeCallbacks(shapeCallbacks[shapeId], 'onDrag', shapeId, shape, points)
}
}
else if (draggingShapeIds.length > 0) {
draggingShapeIds = []
}
}
let drawingEventQueue = []
let eventLock = false // this is set to true before events are processed, in order to avoid any loops
let propsEvents = {}
function handleDrawingEvent(id, event) {
if (eventLock && event !== 'properties_changed' && event !== 'remove') { // properties has its own locking mechanism
console.log('ignoring event', id, event)
return
}
// it's important to decouple our actions from the TV thread. we must wait until the TV loop is completed
// before interacting with the chart
if (drawingEventQueue.length===0)
setTimeout(drawingEventWorker,0)
drawingEventQueue.push([id,event])
}
function drawingEventWorker() {
eventLock = true
try {
const queue = drawingEventQueue
drawingEventQueue = []
propsEvents = {}
for (const [id, event] of queue) {
if (event === 'properties_changed')
propsEvents[id] = event
else
doHandleDrawingEvent(id, event)
}
for (const [k,v] of Object.entries(propsEvents))
doHandleDrawingEvent(k, v)
}
finally {
eventLock = false
// console.log('events unlocked')
}
}
function doHandleDrawingEvent(id, event) {
// console.log('drawing event', id, event)
let shape
if (event==='remove')
shape = null
else {
try {
shape = chart.getShapeById(id)
}
catch {
return
}
}
if (event === 'create') {
allShapeIds.push(id)
const callbacks = drawingCallbacks
// console.log('drawing callbacks', callbacks)
if (callbacks !== null) {
shapeCallbacks[id] = callbacks
co.drawing = false
const points = shape.getPoints()
const props = shape.getProperties()
invokeCallbacks(shapeCallbacks[id], 'onCreate', id, shape, points, props)
invokeCallbacks(shapeCallbacks[id], 'onPoints', id, shape, points)
invokeCallbacks(shapeCallbacks[id], 'onProps', id, shape, props)
}
} else if (event === 'points_changed') {
if (dragging) return
if (id in shapeCallbacks) {
const points = shape.getPoints()
// console.log('points', id, points, dragStartPoints)
invokeCallbacks(shapeCallbacks[id], 'onPoints', id, shape, points)
}
} else if (event === 'properties_changed') {
const props = shape.getProperties()
if (id in shapeCallbacks)
invokeCallbacks(shapeCallbacks[id], 'onProps', id, shape, props)
else
// otherwise it's an event on a shape we don't "own"
console.log('warning: ignoring setProperties on TV shape', id, props)
} else if (event === 'move') {
if (id in shapeCallbacks) {
invokeCallbacks(shapeCallbacks[id], 'onMove', id, shape)
}
} else if (event === 'remove') {
allShapeIds = allShapeIds.filter((v)=>v!==id)
if (id in shapeCallbacks) {
invokeCallbacks(shapeCallbacks[id], 'onDelete', id)
}
} else if (event === 'click') {
if (id in shapeCallbacks) {
invokeCallbacks(shapeCallbacks[id], 'onClick', id, shape)
}
} else if (event === 'hide') {
if (id in shapeCallbacks) {
invokeCallbacks(shapeCallbacks[id], 'onHide', id, shape)
}
} else if (event === 'showDialog') {
if (id in shapeCallbacks) {
invokeCallbacks(shapeCallbacks[id], 'onShow', id, shape)
}
} else
console.log('unknown drawing event', event)
}
export function deleteShapeId(id) {
if( id in shapeCallbacks )
delete shapeCallbacks[id]
// console.log('removing entity', id)
chart.removeEntity(id)
}
const MEAN_RANGE_MULTIPLIER = 3
function chartMeanRange() {
let range = 0
const series = chart.getSeries()
const bars = series.data().bars();
const final = Math.max(bars.size() - 50, 0)
let count = 0
for (let barIndex = bars.size() - 1; barIndex >= final; barIndex--) {
count++
const [_time, _open, high, low, _close, _volume, _ms] = bars.valueAt(barIndex)
range += Math.abs(high - low)
}
if (count > 0)
range /= count
else
range = 1
return range * MEAN_RANGE_MULTIPLIER
}