diff --git a/src/charts/chart-misc.js b/src/charts/chart-misc.js index 7c7bd73..bf91c8a 100644 --- a/src/charts/chart-misc.js +++ b/src/charts/chart-misc.js @@ -1,17 +1,24 @@ import {useChartOrderStore} from "@/orderbuild.js"; -const OHLC_START = new Date(1231027200*1000) // Sunday January 4th, 2009 just before Bitcoin Genesis +// Sunday January 4th, 2009 just before Bitcoin Genesis +const OHLC_START = 1231027200 -export function nearestOhlcStart(time, periodSeconds=null) { +export function ohlcStart(timestamp, periodSeconds=null) { if (periodSeconds===null) periodSeconds = useChartOrderStore().intervalSecs - return Math.round((time-OHLC_START) / periodSeconds) * periodSeconds + OHLC_START + return Math.floor((timestamp-OHLC_START) / periodSeconds) * periodSeconds + OHLC_START } -export function pointsToOhlcStart(points) { +export function nearestOhlcStart(timestamp, periodSeconds=null) { + if (periodSeconds===null) + periodSeconds = useChartOrderStore().intervalSecs + return Math.round((timestamp-OHLC_START) / periodSeconds) * periodSeconds + OHLC_START +} + +export function pointsToTvOhlcStart(points) { return points === null ? null : points.map((p) => { - return {time: nearestOhlcStart(p.time), price: p.price} + return {time: nearestOhlcStart(p.time/1000)*1000, price: p.price} }) } diff --git a/src/charts/chart.js b/src/charts/chart.js index c056d7d..1fbf5ea 100644 --- a/src/charts/chart.js +++ b/src/charts/chart.js @@ -36,6 +36,7 @@ function changeSymbol(symbol) { function changeInterval(interval, _timeframe) { co.intervalSecs = intervalToSeconds(interval) + DataFeed.intervalChanged(co.intervalSecs) } diff --git a/src/charts/datafeed.js b/src/charts/datafeed.js index 6f45a6c..0298f75 100644 --- a/src/charts/datafeed.js +++ b/src/charts/datafeed.js @@ -1,15 +1,36 @@ -// import {subscribeOnStream, unsubscribeFromStream,} from './streaming.js'; - -import {loadOHLC} from './ohlc.js'; +import {convertTvResolution, loadOHLC} from './ohlc.js'; import {metadata} from "@/version.js"; import FlexSearch from "flexsearch"; import {useChartOrderStore} from "@/orderbuild.js"; import {useStore} from "@/store/store.js"; import {subOHLC, unsubOHLC} from "@/blockchain/ohlcs.js"; -import {socket} from "@/socket.js"; +import {ohlcStart} from "@/charts/chart-misc.js"; +import {timestamp} from "@/misc.js"; -// disable debug messages logging -let console = { log: function() {} } +const DEBUG_LOGGING = false +const log = DEBUG_LOGGING ? console.log : ()=>{} + +// this file manages connecting data to TradingView using their DataFeed API. +// https://www.tradingview.com/charting-library-docs/latest/connecting_data/Datafeed-API/#integrate-datafeed-api +// see ohlc.js for fetching dexorder ohlc files + + +// in order of priority +const quoteSymbols = [ + 'USDT', + 'USDC', + 'TUSD', + 'GUSD', + 'BUSD', + 'MUSD', + 'DAI', + 'CRVUSD', + 'EURC', + 'EURS', + 'EURI', + 'WBTC', + 'WETH', +] let feeDropdown = null let widget = null @@ -27,7 +48,7 @@ export function initFeeDropdown(w) { { title: 'Fees', tooltip: 'Choose Fee Tier', - items: [/*{title: 'Automatic Fee Selection', onSelect: () => {console.log('autofees')}}*/], + items: [/*{title: 'Automatic Fee Selection', onSelect: () => {log('autofees')}}*/], icon: ``, } ).then(dropdown => {feeDropdown = dropdown; updateFeeDropdown()}) @@ -54,7 +75,7 @@ function updateFeeDropdown() { function selectPool(p) { const co = useChartOrderStore(); - if ( co.selectedPool === null || co.selectedPool[0] !== p[0] || co.selectedPool[1] !== p[0]) { + if ( co.selectedPool === null || co.selectedPool[0] !== p[0] || co.selectedPool[1] !== p[1]) { co.selectedPool = p } } @@ -91,7 +112,7 @@ const configurationData = { const tokenMap = {} const poolMap = {} -let _symbols = null +let _symbols = null // keyed by the concatenated hex addrs of the token pair e.g. '0xf3Ed85D882b5d9A67fC10dBf8f9AA991212983aA' + '0x6cdC5106DC100115E6C310539Fe44a61b3EEa6C4' const indexer = new FlexSearch.Document({ document: {id: 'id', index: ['fn', 'as[]', 'b', 'q', 'bs', 'qs', 'e', 'd']}, // this must match what is generated for the index object in addSymbol() @@ -102,18 +123,19 @@ const indexer = new FlexSearch.Document({ const indexes = {} const symbolsSeen = {} // keyed by (base,quote) so we only list one pool per pair even if there are many fee tiers +// todo add chainIds to all these keys function addSymbol(p, base, quote, inverted) { - const symbol = base.s + '/' + quote.s + const symbol = base.s + quote.s const exchange = ['UNIv2', 'UNIv3'][p.e] const full_name = exchange + ':' + symbol // + '%' + formatFee(fee) - let key = `${base.a}${quote.a}` - console.log('addSymbol', p, base, quote, inverted, key) + const key = `${base.a}/${quote.a}` + log('addSymbol', p, base, quote, inverted, key) if (symbolsSeen[key]) { // add this pool's address to the existing symbol as an additional fee tier const symbolInfo = _symbols[key]; symbolInfo.pools.push([p.a, p.f]) symbolInfo.pools.sort((a,b)=>a[1]-b[1]) - console.log('integrated symbol', _symbols[key]) + log('integrated symbol', symbolInfo) indexes[key].as.push(p.a) return } @@ -129,7 +151,7 @@ function addSymbol(p, base, quote, inverted) { } if (defaultSymbol===null) defaultSymbol = _symbols[key] - console.log('new symbol', key, _symbols[key]) + log('new symbol', key, _symbols[key]) indexes[key] = { // key id: key, @@ -156,12 +178,12 @@ function addSymbol(p, base, quote, inverted) { // return str // } -async function getAllSymbols() { +function getAllSymbols() { if (_symbols===null) { const chainId = useStore().chainId; const md = metadata[chainId] if(!md) { - console.log('could not get metadata for chain', chainId) + log('could not get metadata for chain', chainId) return [] } _symbols = {} @@ -172,47 +194,35 @@ async function getAllSymbols() { const base = tokenMap[p.b]; const quote = tokenMap[p.q]; if (!base) { - console.log(`No token ${p.b} found`) + log(`No token ${p.b} found`) return } if (!quote) { - console.log(`No token ${p.q} found`) + log(`No token ${p.q} found`) return } - addSymbol(p, base, quote, false); - addSymbol(p, quote, base, true); + // todo check quotes symbol list for inversion hint + let basePriority = quoteSymbols.indexOf(base.s) + let quotePriority = quoteSymbols.indexOf(quote.s) + if (basePriority === -1) + basePriority = Number.MAX_SAFE_INTEGER + if (quotePriority === -1) + quotePriority = Number.MAX_SAFE_INTEGER + const showInverted = basePriority < quotePriority + if (showInverted) + addSymbol(p, quote, base, true); + else + addSymbol(p, base, quote, false); }) - console.log('indexes', indexes) + log('indexes', indexes) Object.values(indexes).forEach(indexer.add.bind(indexer)) } - console.log('symbols', _symbols) + log('symbols', _symbols) return _symbols } export function lookupSymbol(key) { // lookup by fullname - return _symbols[key] -} - -export function lookupBaseQuote(baseAddr, quoteAddr) { - return _symbols[`${baseAddr}${quoteAddr}`] -} - -function poolIsInverted() { - return useChartOrderStore().selectedSymbol.inverted -} - -export function maybeInvertBar (bar) { - if (poolIsInverted()) { - bar.open = 1/bar.open - let high = bar.high - bar.high = 1/bar.low - bar.low = 1/high - bar.close = 1/bar.close - console.log("bar inverted") - } else { - console.log("bar NOT inverted") - } - return bar + return getAllSymbols()[key] } function checkBar(bar, msg) { @@ -226,39 +236,81 @@ function checkBar(bar, msg) { let h_l = bar.high - bar.low if (o_l<0||c_l<0||h_o<0||h_c<0||h_l<0) { - console.log(msg, "bar.high/low inconsistent:", bar) - if (o_l<0) console.log("bar inconsistent: open-low: ", o_l) - if (c_l<0) console.log("bar inconsistent: close-low: ", c_l) - if (h_o<0) console.log("bar inconsistent: high-open: ", h_o) - if (h_c<0) console.log("bar inconsistent: high-close:", h_c) - if (h_l<0) console.log("bar inconsistent: high-low: ", h_l) + log(msg, "bar.high/low inconsistent:", bar) + if (o_l<0) log("bar inconsistent: open-low: ", o_l) + if (c_l<0) log("bar inconsistent: close-low: ", c_l) + if (h_o<0) log("bar inconsistent: high-open: ", h_o) + if (h_c<0) log("bar inconsistent: high-close:", h_c) + if (h_l<0) log("bar inconsistent: high-low: ", h_l) } else { - console.log(msg, "bar diffs:", bar) - console.log("bar diff: open-low: ", o_l) - console.log("bar diff: close-low: ", c_l) - console.log("bar diff: high-open: ", h_o) - console.log("bar diff: high-close:", h_c) - console.log("bar diff: high-low: ", h_l) + log(msg, "bar diffs:", bar) + log("bar diff: open-low: ", o_l) + log("bar diff: close-low: ", c_l) + log("bar diff: high-open: ", h_o) + log("bar diff: high-close:", h_c) + log("bar diff: high-low: ", h_l) } } + +function invertOhlcs(bars) { + const result = [] + for (const bar of bars) { + const h = bar.high + result.push({ + time: bar.time, + open: 1/bar.open, + high: 1/bar.low, + low: 1/h, + close: 1/bar.close, + }) + } + return result +} + + +const subByTvSubId = {} +const subByKey = {} + +class RealtimeSubscription { + + constructor(chainId, poolAddr, res, symbol, tvSubId, onRealtimeCb, onResetCacheCb ) { + this.chainId = chainId + this.poolAddr = poolAddr + this.res = res + this.symbol = symbol + this.tvSubId = tvSubId + this.onRealtimeCb = onRealtimeCb + this.onResetCacheCb = onResetCacheCb + this.key = `${chainId}|${poolAddr}|${res.name}` + subByTvSubId[this.tvSubId] = this + subByKey[this.key] = this + } + + close() { + delete subByTvSubId[this.tvSubId] + delete subByKey[this.key] + } +} + + export const DataFeed = { - onReady: (callback) => { - console.log('[onReady]: Method call'); + onReady(callback) { + log('[onReady]: Method call'); setTimeout(() => callback(configurationData)); }, - searchSymbols: async ( + async searchSymbols( userInput, exchange, symbolType, onResultReadyCallback, - ) => { - console.log('[searchSymbols]: Method call'); + ) { + log('[searchSymbols]: Method call'); // todo limit results by exchange. use a separate indexer per exchange? const found = indexer.search(userInput, 10) - console.log('found', found) + log('found', found) const result = [] for (const f of found) for (const key of f.result) @@ -266,18 +318,29 @@ export const DataFeed = { onResultReadyCallback(result); }, - resolveSymbol: async ( + async resolveSymbol( symbolName, onSymbolResolvedCallback, onResolveErrorCallback, extension - ) => { - console.log('[resolveSymbol]: Method call', symbolName); - const symbols = await getAllSymbols(); + ) { + setTimeout(async ()=> + await this.doResolveSymbol(symbolName,onSymbolResolvedCallback,onResolveErrorCallback,extension), + 0) + }, + + async doResolveSymbol( + symbolName, + onSymbolResolvedCallback, + onResolveErrorCallback, + extension + ) { + log('[resolveSymbol]: Method call', symbolName); + const symbols = getAllSymbols(); const symbolItem = symbolName === 'default' ? defaultSymbol : symbols[symbolName] - console.log('symbol resolved?', symbolItem) + log('symbol resolved?', symbolItem) if (!symbolItem) { - console.log('[resolveSymbol]: Cannot resolve symbol', symbolName); + log('[resolveSymbol]: Cannot resolve symbol', symbolName); onResolveErrorCallback('cannot resolve symbol'); return; } @@ -310,143 +373,298 @@ export const DataFeed = { // volume_precision: 2, data_status: 'streaming', }; - console.log('[resolveSymbol]: Symbol resolved', symbolName); - onSymbolResolvedCallback(symbolInfo); + log('[resolveSymbol]: Symbol resolved', symbolName); + onSymbolResolvedCallback(symbolInfo) }, - getBars: async (symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) => { + async getBars(symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) { const { from, to, firstDataRequest } = periodParams; - console.log('[getBars]: Method call', symbolInfo, resolution, from, to); + log('[getBars]: Method call', symbolInfo, resolution, from, to); try { // todo need to consider the selected fee tier - let bars, metadata; - const pool = useChartOrderStore().selectedPool; - [bars, metadata] = await loadOHLC(lookupSymbol(symbolInfo.ticker), pool[0], from, to, resolution); // This is the one that does all the work - if (firstDataRequest) { - lastBarsCache.set(symbolInfo.full_name, { - ...bars[bars.length - 1], - }); - } - console.log(`[getBars]: returned ${bars.length} bar(s), and metadata ${metadata}`); - console.log('[getBars]: bars=', bars); - onHistoryCallback(bars, metadata); + const [poolAddr, poolFee] = useChartOrderStore().selectedPool; + await getAllSymbols() + const symbol = lookupSymbol(symbolInfo.ticker); + const res = convertTvResolution(resolution) + const key = `${useStore().chainId}|${poolAddr}|${res.name}` + let bars = await loadOHLC(symbol, poolAddr, from, to, resolution); // This is the one that does all the work + this.updatePushedCache(key, bars) + if (symbol.inverted) + bars = invertOhlcs(bars) + log(`[getBars]: returned ${bars.length} bar(s), and metadata ${metadata}`); + log('[getBars]: bars=', bars); + // noData should be set only if no bars are in the requested period and earlier. + // In our case, we are guaranteed to have contiguous samples. + // So we only return no bars (bars.length==0) if: + // 1. period is entirely before first data available. + // 2. period is entirely after last data available. + // Returning noData based on bars.length works perfectly assuming that TV never asks for case 2. + // This is probably not a safe assumption. The alternative would be to search + // backward to find beginning of history. How far to search? + const noData = bars.length === 0; + onHistoryCallback(bars, {noData}); } catch (error) { - console.log('[getBars]: Get error', error); + log('[getBars]: Get error', error); onErrorCallback(error); } }, subscribeBarsOnRealtimeCallback: null, - subscribeBars: ( + subscribeBars( symbolInfo, resolution, - onRealtimeCallback, + onRealtimeCallback2, subscriberUID, onResetCacheNeededCallback, - ) => { - console.log('[subscribeBars]', symbolInfo, resolution, subscriberUID); - const chainId = useStore().chainId; - const poolAddr = useChartOrderStore().selectedPool[0]; - const period = tvResolutionToPeriodString(resolution); - subscriptions[subscriberUID] = [chainId, poolAddr, period, onRealtimeCallback, onResetCacheNeededCallback] - const key = `${chainId}|${poolAddr}|${period}` - if (key in subscriptionCallbacks) - subscriptionCallbacks[key].push(subscriberUID) - else - subscriptionCallbacks[key] = [subscriberUID] - console.log('sub', key) - subOHLC(chainId, poolAddr, period) - }, + ) { - unsubscribeBars: (subscriberUID) => { - console.log('[unsubscribeBars]', subscriberUID); - const [chainId, poolAddr, period] = subscriptions[subscriberUID] - unsubOHLC(chainId, poolAddr, period) - delete subscriptions[subscriberUID] - const key = `${chainId}|${poolAddr}|${period}`; - console.log('unsub',key) - const remainingSubs = subscriptionCallbacks[key].filter((v)=>v!==subscriberUID) - if (remainingSubs.length===0) - delete subscriptionCallbacks[key] - else - subscriptionCallbacks[key] = remainingSubs - }, - - poolCallbackState : {lastBar:{'chain|pool|period':null}}, - - poolCallback(chainId, poolPeriod, ohlcs) { - console.log("poolCallback: chainId, pool, ohlcs:", chainId, poolPeriod, ohlcs) - if (ohlcs == null) { - console.log("poolCallback: ohlcs == null, nothing to do.") - return; + const oldCb = onRealtimeCallback2 + function onRealtimeCallback() { + log('push bar', ...arguments) + oldCb(...arguments) } - const key = `${chainId}|${poolPeriod}`; - const subscriptionUIDs = subscriptionCallbacks[key]; - if (subscriptionUIDs===undefined) { - console.log('unsubbing abandoned subscription', poolPeriod) - socket.emit('unsubOHLCs', chainId, [poolPeriod]) + + log('[subscribeBars]', symbolInfo, resolution, subscriberUID); + const symbol = getAllSymbols()[symbolInfo.full_name] + const co = useChartOrderStore() + + + // todo redo symbolInfo: the full_name passed to TV should just be the pool addr. when searching a symbol, go ahead and select the first/most liquid automatically + let poolAddr = null + const selectedFee = co.selectedPool[1]; + for (const [addr, fee] of symbol.pools) { + if (fee === selectedFee) { + poolAddr = addr + break + } + } + if (poolAddr===null) { + console.error(`Could not find pool for fee ${selectedFee}: ${symbol.pools}`) return } - function onRealtimeCallback() { - for (const subId of subscriptionUIDs) { - const [_chainId, _poolAddr, _period, _onRealtimeCallback, _onResetCacheNeededCallback] = subscriptions[subId] - _onRealtimeCallback(...arguments) + + + const chainId = useStore().chainId; + + const res = convertTvResolution(resolution) + const period = res.name + console.log('subscription symbol', symbol, getAllSymbols()) + const sub = new RealtimeSubscription(chainId, poolAddr, res, symbol, subscriberUID, onRealtimeCallback, onResetCacheNeededCallback) + log('sub', sub.key) + subOHLC(chainId, poolAddr, period) + this.startOHLCBumper() + }, + + unsubscribeBars(subscriberUID) { + log('[unsubscribeBars]', subscriberUID); + const sub = subByTvSubId[subscriberUID] + if (sub===undefined) { + console.log(`warning: no subscription found for tvSubId ${subscriberUID}`) + return + } + unsubOHLC(sub.chainId, sub.poolAddr, sub.res.name) + log('unsub',sub.key) + sub.close() + delete this.pushedBars[sub.key] + delete this.recentBars[sub.key] + }, + + + // key-value format: + // 'chain|pool|period': [[javatime, open, high, low, close], ...] + pushedBars: {}, // bars actually sent to TradingView + recentBars:{}, // bars received from server notifications + + + pushRecentBars(key, recent) { + this.recentBars[key] = recent + const historical = this.pushedBars[key] + if (historical!==undefined) + this.overlapAndPush(key, recent) + }, + + + overlapAndPush(key, ohlcs) { + log('overlapAndPush',key,ohlcs) + if (!ohlcs || ohlcs.length === 0) return + + const sub = subByKey[key] + // do not check reorgs on mock symbols because the dev environment price will be different from the main chain + // price and reorgs would happen every time. + const checkReorg = sub.symbol.x?.data === undefined + const res = sub.res + const period = res.seconds * 1000 + const bars = [] // to push + const pushed = this.pushedBars[key] + log('got pushed bars', pushed) + let pi = 0 // pushed index + let time + let price + if (pushed === undefined) { + time = ohlcs[0].time + price = ohlcs[0].close + } + else { + const last = pushed.length - 1 + time = pushed[last].time + price = pushed[last].close + } + log('last time/price', time, price) + for( const ohlc of ohlcs ) { + log('handle ohlc', ohlc) + // forward the pi index to at least the current ohlc time + while( pi < pushed.length && pushed[pi].time < ohlc.time ) pi++ + + if (pi < pushed.length-1) { + // finalized bars must match the previous push exactly or else there was a reorg and we need to reset + const p = pushed[pi] + log('check reorg', checkReorg, pi, p, ohlc) + if (checkReorg && + (p.time !== ohlc.time || p.open !== ohlc.open || p.high !== ohlc.high || + p.low !== ohlc.low || p.close !== ohlc.close) ) + { + console.log('RESET TV CACHE') + return this.resetCache(key) + } + } + else { + // the last pushed bar and anything after it can be sent to TV + while (time + period < ohlc.time) { + // fill gap + time += period + const bar = {time, open: price, high: price, low: price, close: price}; + log('fill', bar) + bars.push(bar) + } + bars.push(ohlc) + time = ohlc.time + price = ohlc.close } } + return this.pushToTV(key, bars) + }, - function onResetCacheNeededCallback() { - for (const subId of subscriptionUIDs) { - const [_chainId, _poolAddr, _period, _onRealtimeCallback, _onResetCacheNeededCallback] = subscriptions[subId] - _onResetCacheNeededCallback(...arguments) - } + + resetCache(key) { + log('resetting TV data cache') + const sub = subByKey[key] + if (sub===undefined) { + console.log(`warning: no subscription found for dexorder key ${key}`) + return } + sub.onResetCacheCb() + }, - let ohlc = ohlcs.at(-1); - console.log("poolCallBack ohlc:", new Date(Number(ohlc[0])*1000).toGMTString(), ohlc) - for (let i = 0; iolder<= than the ones in the pushedBars cache. This happens + // because TV can request data pages (using getBars()) out of chronological order. + } + }, + + + pushToTV(key, ohlcs) { + if (ohlcs.length===0) return + log('pushing bars to tv', ohlcs) + this.updatePushedCache(key, ohlcs); // we cache the raw bars before inversion so they match dexorder data sources + const sub = subByKey[key] + if (sub===undefined) { + console.log(`warning: could not find subscription for dexorder sub key ${key}`) + return + } + if (sub.symbol.inverted) + ohlcs = invertOhlcs(ohlcs) + for (const ohlc of ohlcs) + sub.onRealtimeCb(ohlc) + }, + + + poolCallback(chainId, poolPeriod, ohlcs) { + const key = `${chainId}|${poolPeriod}`; + const bars = [] + for (const ohlc of ohlcs) { + let close = parseFloat(ohlc[4]) // close + const bar = { + time: ohlc[0] * 1000, + open: ohlc[1] ? parseFloat(ohlc[1]) : close, // open + high: ohlc[2] ? parseFloat(ohlc[2]) : close, // high + low: ohlc[3] ? parseFloat(ohlc[3]) : close, // low close: close, } - checkBar(bar, "poolCallback, before inversion:") - bar = maybeInvertBar(bar) - checkBar(bar, "poolCallback, after inversion:") - console.log('DataFeed.poolCallback', date.toGMTString(), ohlcs, bar) - let lastBar = DataFeed.poolCallbackState.lastBar[key] - // No last bar then initialize bar - if (lastBar===undefined) { - console.log('DataFeed.poolCallback', new Date(bar.time).toGMTString(), 'lastBar=', bar) - onRealtimeCallback(bar) - DataFeed.poolCallbackState.lastBar[key] = bar + bars.push(bar) + } + return this.pushRecentBars(key, bars) + }, + + intervalChanged(seconds) { + // rollover bumper + // this timer function creates a new bar when the period rolls over + this.startOHLCBumper() + }, + + // The OHLC bumper advances bars at the end of the period. If the price doesn't change, no new data will arrive, so the + // new bar is implicit and must be generated dynamically. + + startOHLCBumper() { + const co = useChartOrderStore() + if (_rolloverBumper !== null) + clearTimeout(_rolloverBumper) + const period = co.intervalSecs; + if (period === 0) + return + const now = Date.now() + const nextRollover = ohlcStart(now/1000 + period)*1000 + 2000 // two second delay to wait for server data + const delay = nextRollover - now + _rolloverBumper = setTimeout(this.bumpOHLC.bind(this), delay) + }, + + bumpOHLC() { + log('bumpOHLC') + _rolloverBumper = null + const secs = useChartOrderStore().intervalSecs; + for (const sub of Object.values(subByKey)) { + log('check bump', sub.res.seconds, secs) + if (sub.res.seconds === secs) { + const pushed = this.pushedBars[sub.key] + log('check pushed', pushed) + if (pushed !== undefined && pushed.length > 0) { + const lastBar = pushed[pushed.length - 1] + const price = lastBar.close + const now = timestamp() * 1000 + const period = sub.res.seconds * 1000 + const fills = [] + for( let time=lastBar.time + period; time < now; time += period ) { + log('pushing bump', time, price) + const bar = {time, open: price, high: price, low: price, close: price} + fills.push(bar) + } + this.pushToTV(sub.key, fills) + } + else { + log('warning: bumpOHLC() found no previous bars') + } } - // bar time is less than last bar then ignore - else if (bar.time < lastBar.time ) { - } - // bar time equal to last bar then replace last bar - else if (bar.time === lastBar.time ) { - console.log('DataFeed.poolCallback', new Date(bar.time).toGMTString(), 'lastBar=', bar) - if (bar.high < lastBar.high) console.log("bar.high < lastBar.high (lastbar=)") - if (bar.low > lastBar.low) console.log("bar.low > lastBar.low (lastbar=)") - onRealtimeCallback(bar) - DataFeed.poolCallbackState.lastBar[key] = bar - } - // new bar, then render last and replace last bar - else { - console.log('DataFeed.poolCallback', new Date(bar.time).toGMTString(), 'lastBar=', bar) - onRealtimeCallback(bar) - DataFeed.poolCallbackState.lastBar[key] = bar - } - // } - } + } + this.startOHLCBumper() + }, + } + +let _rolloverBumper = null let defaultSymbol = null -const subscriptions = {} -const subscriptionCallbacks = {} diff --git a/src/charts/ohlc.js b/src/charts/ohlc.js index 71b4e51..01311b7 100644 --- a/src/charts/ohlc.js +++ b/src/charts/ohlc.js @@ -1,5 +1,8 @@ import {useStore} from "@/store/store.js"; -import {nearestOhlcStart} from "@/charts/chart-misc.js"; +import {ohlcStart} from "@/charts/chart-misc.js"; + + +// support for Dexorder OHLC data files function dailyFile(resName) { @@ -61,27 +64,32 @@ function never(_timestamp) { } +// noinspection PointlessArithmeticExpressionJS const resolutions = [ - { period: 1, tvRes: '1', filename: dailyFile( '1m'), nextStart: nextDay, }, - { period: 3, tvRes: '3', filename: dailyFile( '3m'), nextStart: nextDay, }, - { period: 5, tvRes: '5', filename: dailyFile( '5m'), nextStart: nextDay, }, - { period: 10, tvRes: '10', filename: dailyFile('10m'), nextStart: nextDay, }, - { period: 15, tvRes: '15', filename: dailyFile('15m'), nextStart: nextDay, }, - { period: 30, tvRes: '30', filename: dailyFile('30m'), nextStart: nextDay, }, - { period: 60, tvRes: '60', filename: monthlyFile( '1H'), nextStart: nextMonth, }, - { period: 120, tvRes: '120', filename: monthlyFile( '2H'), nextStart: nextMonth, }, - { period: 240, tvRes: '240', filename: monthlyFile( '4H'), nextStart: nextMonth, }, - { period: 480, tvRes: '480', filename: monthlyFile( '8H'), nextStart: nextMonth, }, - { period: 720, tvRes: '720', filename: monthlyFile('12H'), nextStart: nextMonth, }, - { period: 1440, tvRes: '1D', filename: yearlyFile( '1D'), nextStart: nextYear, }, - { period: 2880, tvRes: '2D', filename: yearlyFile( '2D'), nextStart: nextYear, }, - { period: 4320, tvRes: '3D', filename: yearlyFile( '3D'), nextStart: nextYear, }, - { period: 10080, tvRes: '1W', filename: singleFile( '1W'), nextStart: never, }, + { seconds: 1 * 60, name: '1m', tvRes: '1', filename: dailyFile( '1m'), nextStart: nextDay, }, + { seconds: 3 * 60, name: '3m', tvRes: '3', filename: dailyFile( '3m'), nextStart: nextDay, }, + { seconds: 5 * 60, name: '5m', tvRes: '5', filename: dailyFile( '5m'), nextStart: nextDay, }, + { seconds: 10 * 60, name: '10m', tvRes: '10', filename: dailyFile('10m'), nextStart: nextDay, }, + { seconds: 15 * 60, name: '15m', tvRes: '15', filename: dailyFile('15m'), nextStart: nextDay, }, + { seconds: 30 * 60, name: '30m', tvRes: '30', filename: dailyFile('30m'), nextStart: nextDay, }, + { seconds: 60 * 60, name: '1H', tvRes: '60', filename: monthlyFile( '1H'), nextStart: nextMonth, }, + { seconds: 120 * 60, name: '2H', tvRes: '120', filename: monthlyFile( '2H'), nextStart: nextMonth, }, + { seconds: 240 * 60, name: '4H', tvRes: '240', filename: monthlyFile( '4H'), nextStart: nextMonth, }, + { seconds: 480 * 60, name: '8H', tvRes: '480', filename: monthlyFile( '8H'), nextStart: nextMonth, }, + { seconds: 720 * 60, name: '12H', tvRes: '720', filename: monthlyFile('12H'), nextStart: nextMonth, }, + { seconds: 1440 * 60, name: '1D', tvRes: '1D', filename: yearlyFile( '1D'), nextStart: nextYear, }, + { seconds: 2880 * 60, name: '2D', tvRes: '2D', filename: yearlyFile( '2D'), nextStart: nextYear, }, + { seconds: 4320 * 60, name: '3D', tvRes: '3D', filename: yearlyFile( '3D'), nextStart: nextYear, }, + { seconds: 10080 * 60, name: '1W', tvRes: '1W', filename: singleFile( '1W'), nextStart: never, }, ] -const resMap = {} +const tvResMap = {} for (const res of resolutions) - resMap[res.tvRes] = res + tvResMap[res.tvRes] = res + +const dxoResMap = {} +for (const res of resolutions) + dxoResMap[res.name] = res const seriesStarts = {} @@ -107,15 +115,19 @@ export async function loadOHLC (symbol, contract, from, to, tvRes) { // console.log('loadOHLC', tvRes, new Date(1000*from), new Date(1000*to), symbol, contract); let chainId let bars = []; - let inverted = symbol.inverted; + let inverted = false; let baseURL let latest = null // latest time, price function fill(end, period) { if (latest===null) return const [start, price] = latest - for (let now=nearestOhlcStart(start, period*60); now < end; now += period ) + const periodSecs = period * 60 + end = ohlcStart(end, periodSecs) + for (let now=ohlcStart(start+periodSecs, periodSecs); now < end; now += periodSecs ) { bars.push({time:now * 1000, open:price, high:price, low:price, close:price}) + latest = [now, price] + } } if (symbol.x?.data) { @@ -130,7 +142,7 @@ export async function loadOHLC (symbol, contract, from, to, tvRes) { } baseURL += `${chainId}/${contract}/` - const res = resMap[tvRes] + const res = tvResMap[tvRes] const fetches = [] let start = from if (!(baseURL in seriesStarts)) { @@ -164,7 +176,6 @@ export async function loadOHLC (symbol, contract, from, to, tvRes) { let lineNum = 0 response.split('\n').forEach((line) => { lineNum++ - // console.log(`processing line ${lineNum}`, line) const row = line.split(',') let time, open, high, low, close=null switch (row.length) { @@ -204,8 +215,9 @@ export async function loadOHLC (symbol, contract, from, to, tvRes) { close = parseFloat(row[4]) if (inverted) { open = 1/open - high = 1/high - low = 1/low + const h = high + high = 1/low + low = 1/h close = 1/close } break @@ -214,10 +226,8 @@ export async function loadOHLC (symbol, contract, from, to, tvRes) { break } if (close!==null) { - // console.log(`filling up to ${time}`) - fill(time, res.period) + fill(time, res.seconds) const bar = {time:time*1000, open, high, low, close}; - // console.log('pushing bar', bar) bars.push(bar) latest = [time, close] } @@ -226,20 +236,21 @@ export async function loadOHLC (symbol, contract, from, to, tvRes) { } // else { console.log('response was empty') } } - - fill(to, res.period) - - // noData should be set only if no bars are in the requested period and earlier. - // In our case, we are guaranteed to have contiguous samples. - // So we only return no bars (bars.length==0) if: - // 1. period is entirely before first data available. - // 2. period is entirely after last data available. - // Returning noData based on bars.length works perfectly assuming that TV never asks for case 2. - // This is probably not a safe assumption. The alternative would be to search - // backward to find beginning of history. How far to search? - - let noData = bars.length === 0; - // if (noData) console.log("noData == true!"); - // console.log('bars', bars) - return [bars, {noData}]; + fill(to, res.seconds) + return bars +} + + +export function tvResolutionToPeriodString(res) { + return tvResMap[res].name +} + + +export function convertTvResolution(res) { + return tvResMap[res] +} + + +export function resForName(name) { + return dxoResMap[name] } diff --git a/src/charts/shape.js b/src/charts/shape.js index c40bfc4..88a225d 100644 --- a/src/charts/shape.js +++ b/src/charts/shape.js @@ -5,7 +5,7 @@ import {chart, createShape, deleteShapeId, dragging, draggingShapeIds, drawShape import {unique} from "@/misc.js"; import {allocationText} from "@/orderbuild.js"; import Color from "color"; -import {pointsToOhlcStart} from "@/charts/chart-misc.js"; +import {pointsToTvOhlcStart} from "@/charts/chart-misc.js"; // @@ -202,7 +202,7 @@ export class Shape { // createShape(this.type, this.points, {overrides:this.props}, new ShapeTVCallbacks(this)) options = {...options} options['overrides'] = props - this.tvPoints = pointsToOhlcStart(points) + this.tvPoints = pointsToTvOhlcStart(points) this.tvCallbacks = new ShapeTVCallbacks(this); const id = createShape(this.type, this.tvPoints, options, this.tvCallbacks) // todo set id? @@ -247,7 +247,7 @@ export class Shape { if (this.id === null) this.create() else { - points = pointsToOhlcStart(points) + points = pointsToTvOhlcStart(points) if (dirtyPoints(this.tvPoints, points)) { const s = this.tvShape(); const lbe = s._model._lineBeingEdited diff --git a/src/charts/streaming.js b/src/charts/streaming.js deleted file mode 100644 index 6aaccef..0000000 --- a/src/charts/streaming.js +++ /dev/null @@ -1,172 +0,0 @@ -// import { parseFullSymbol } from './helpers.js'; -// import { subPrices } from '@/blockchain/prices.js'; - -// const socket = io('wss://streamer.cryptocompare.com'); -// const channelToSubscription = new Map(); - -// socket.on('connect', () => { -// console.log('[socket] Connected'); -// }); - -// socket.on('disconnect', (reason) => { -// console.log('[socket] Disconnected:', reason); -// }); - -// socket.on('error', (error) => { -// console.log('[socket] Error:', error); -// }); - -// socket.on('m', data => { -// console.log('[socket] Message:', data); -// const [ -// eventTypeStr, -// exchange, -// fromSymbol, -// toSymbol, -// , -// , -// tradeTimeStr, -// , -// tradePriceStr, -// ] = data.split('~'); - -// if (parseInt(eventTypeStr) !== 0) { -// // Skip all non-trading events -// return; -// } -// const tradePrice = parseFloat(tradePriceStr); -// const tradeTime = parseInt(tradeTimeStr); -// const channelString = `0~${exchange}~${fromSymbol}~${toSymbol}`; -// const subscriptionItem = channelToSubscription.get(channelString); -// if (subscriptionItem === undefined) { -// return; -// } -// const lastDailyBar = subscriptionItem.lastDailyBar; -// const nextDailyBarTime = getNextDailyBarTime(lastDailyBar.time, subscriptionItem.resolution); - -// console.log("tradeTime ", tradeTime, new Date(tradeTime)) -// console.log("lastDailyBar.time", lastDailyBar.time, new Date(lastDailyBar.time)) -// console.log("nextDailyBarTime ", nextDailyBarTime, new Date(nextDailyBarTime)) - -// let bar; -// if (tradeTime >= nextDailyBarTime) { -// bar = { -// time: nextDailyBarTime, -// open: tradePrice, -// high: tradePrice, -// low: tradePrice, -// close: tradePrice, -// }; -// console.log('[socket] Generate new bar', bar); -// console.log("time:", bar.time.toString(), new Date(bar.time).toUTCString()) -// } else { -// bar = { -// ...lastDailyBar, -// high: Math.max(lastDailyBar.high, tradePrice), -// low: Math.min(lastDailyBar.low, tradePrice), -// close: tradePrice, -// }; -// console.log('[socket] Update the latest bar by price', tradePrice); -// } -// subscriptionItem.lastDailyBar = bar; - -// // Send data to every subscriber of that symbol -// subscriptionItem.handlers.forEach(handler => handler.callback(bar)); -// }); - -// function getNextDailyBarTime(barTime, res) { -// const date = new Date(barTime); -// const resDigits = res.slice(0, -1) -// if (res.endsWith("W")) { -// date.setDate(date.getDate() + parseInt(resDigits)*7); -// } else if (res.endsWith("D")) { -// date.setDate(date.getDate() + parseInt(resDigits)); -// } else { -// date.setMinutes(date.getMinutes() + parseInt(res)) -// } -// return date.getTime(); -// } - -// export function subscribeOnStream( -// symbolInfo, -// resolution, -// onRealtimeCallback, -// subscriberUID, -// onResetCacheNeededCallback, -// lastDailyBar, -// ) { -// // return; -// const parsedSymbol = parseFullSymbol(symbolInfo.full_name); -// const channelString = `0~${parsedSymbol.exchange}~${parsedSymbol.fromSymbol}~${parsedSymbol.toSymbol}`; -// const handler = { -// id: subscriberUID, -// callback: onRealtimeCallback, -// }; -// let subscriptionItem = channelToSubscription.get(channelString); -// if (subscriptionItem) { -// // Already subscribed to the channel, use the existing subscription -// subscriptionItem.handlers.push(handler); -// return; -// } -// subscriptionItem = { -// subscriberUID, -// resolution, -// lastDailyBar, -// handlers: [handler], -// }; -// channelToSubscription.set(channelString, subscriptionItem); -// console.log('[subscribeBars]: Subscribe to streaming. Channel:', channelString); -// socket.emit('SubAdd', { subs: [channelString] }); -// } - -// export function unsubscribeFromStream(subscriberUID) { -// // return; -// // Find a subscription with id === subscriberUID -// for (const channelString of channelToSubscription.keys()) { -// const subscriptionItem = channelToSubscription.get(channelString); -// const handlerIndex = subscriptionItem.handlers -// .findIndex(handler => handler.id === subscriberUID); - -// if (handlerIndex !== -1) { -// // Remove from handlers -// subscriptionItem.handlers.splice(handlerIndex, 1); - -// if (subscriptionItem.handlers.length === 0) { -// // Unsubscribe from the channel if it was the last handler -// console.log('[unsubscribeBars]: Unsubscribe from streaming. Channel:', channelString); -// socket.emit('SubRemove', { subs: [channelString] }); -// channelToSubscription.delete(channelString); -// break; -// } -// } -// } -// } - -// function sim() { -// // Assuming these variables hold the data you extracted earlier -// const eventTypeStr = "0"; -// const exchange = "Uniswap"; -// const fromSymbol = "WETH"; -// const toSymbol = "USD"; -// const tradeTimeStr = (Date.now()).toString(); -// const tradePriceStr = (55+Date.now()%23).toString(); - -// // Constructing the original string -// const data = [ -// eventTypeStr, -// exchange, -// fromSymbol, -// toSymbol, -// '', // Placeholder for the fifth element -// '', // Placeholder for the sixth element -// tradeTimeStr, -// '', // Placeholder for the eighth element -// tradePriceStr, -// ].join('~'); -// socket._callbacks['$m'][0](data); -// } - -// window.sim = sim; -// socket._callbacks['$connect'][0](); -// setInterval(sim, 10*1000); -; diff --git a/src/socket.js b/src/socket.js index dd73dc3..e9ef064 100644 --- a/src/socket.js +++ b/src/socket.js @@ -15,7 +15,7 @@ socket.on('disconnect', () => { }) socket.on('p', async (chainId, pool, price) => { - // console.log('pool price from message', chainId, pool, price) + console.log('pool price from message', chainId, pool, price) const s = useStore() if( s.chainId !== chainId ) return @@ -27,7 +27,7 @@ socket.on('ohlc', async (chainId, poolPeriod, ohlcs) => { if (ohlcs && ohlcs.length) { const split = poolPeriod.indexOf('|') const pool = poolPeriod.slice(0,split) - useStore().poolPrices[[chainId, pool]] = ohlcs[ohlcs.length - 1][4] // closing price + useStore().poolPrices[[chainId, pool]] = parseFloat(ohlcs[ohlcs.length - 1][4]) // closing price } DataFeed.poolCallback(chainId, poolPeriod, ohlcs) })