519 lines
18 KiB
JavaScript
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
|
|
}
|