diff --git a/apps/minifront/src/state/swap/helpers.ts b/apps/minifront/src/state/swap/helpers.ts index 4870021473..f273a76f90 100644 --- a/apps/minifront/src/state/swap/helpers.ts +++ b/apps/minifront/src/state/swap/helpers.ts @@ -50,11 +50,20 @@ export const sendSimulateTradeRequest = ({ return simulationClient.simulateTrade(req); }; -export const sendCandlestickDataRequest = async ( +/** + * Due to the way price data is recorded, symmetric comparisons do not return + * symmetric data. to get the complete picture, a client must combine both + * datasets. + * 1. query the intended comparison direction (start token -> end token) + * 2. query the inverse comparison direction (end token -> start token) + * 3. flip the inverse data (reciprocal values, high becomes low) + * 4. combine the data (use the highest high, lowest low, sum volumes) + */ +export const sendCandlestickDataRequests = async ( { startMetadata, endMetadata }: Pick, limit: bigint, signal?: AbortSignal, -): Promise => { +): Promise => { const start = startMetadata?.penumbraAssetId; const end = endMetadata?.penumbraAssetId; @@ -65,22 +74,73 @@ export const sendCandlestickDataRequest = async ( throw new Error('Asset pair equivalent'); } - try { - const { data } = await dexClient.candlestickData( - { - pair: { start, end }, - limit, - }, - { signal }, - ); - return data; - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - return; - } else { + const suppressAbort = (err: unknown) => { + if (!signal?.aborted) { throw err; } - } + }; + + const directReq = dexClient + .candlestickData({ pair: { start, end }, limit }, { signal }) + .catch(suppressAbort); + const inverseReq = dexClient + .candlestickData({ pair: { start: end, end: start }, limit }, { signal }) + .catch(suppressAbort); + + const directCandles = (await directReq)?.data ?? []; + const inverseCandles = (await inverseReq)?.data ?? []; + + // collect candles at each height + const collatedByHeight = Map.groupBy( + [ + ...directCandles, + ...inverseCandles.map( + // flip inverse data to match orientation of direct data + inverseCandle => { + const correctedCandle = inverseCandle.clone(); + // comparative values are reciprocal + correctedCandle.open = 1 / inverseCandle.open; + correctedCandle.close = 1 / inverseCandle.close; + // high and low swap places + correctedCandle.high = 1 / inverseCandle.low; + correctedCandle.low = 1 / inverseCandle.high; + return correctedCandle; + }, + ), + ], + ({ height }) => height, + ); + + // combine data at each height into a single candle + const combinedCandles = Array.from(collatedByHeight.entries()).map( + ([height, candlesAtHeight]) => { + // TODO: open/close don't diverge much, and when they do it seems to be due + // to inadequate number precision. it might be better to just pick one, but + // it's not clear which one is 'correct' + const combinedCandleAtHeight = candlesAtHeight.reduce((acc, cur) => { + // sum volumes + acc.directVolume += cur.directVolume; + acc.swapVolume += cur.swapVolume; + + // highest high, lowest low + acc.high = Math.max(acc.high, cur.high); + acc.low = Math.min(acc.low, cur.low); + + // these accumulate to be averaged + acc.open += cur.open; + acc.close += cur.close; + return acc; + }, new CandlestickData({ height })); + + // average accumulated open/close + combinedCandleAtHeight.open /= candlesAtHeight.length; + combinedCandleAtHeight.close /= candlesAtHeight.length; + + return combinedCandleAtHeight; + }, + ); + + return combinedCandles; }; const byBalanceDescending = (a: BalancesResponse, b: BalancesResponse) => { diff --git a/apps/minifront/src/state/swap/price-history.ts b/apps/minifront/src/state/swap/price-history.ts index 427b3675a6..48296176c3 100644 --- a/apps/minifront/src/state/swap/price-history.ts +++ b/apps/minifront/src/state/swap/price-history.ts @@ -2,7 +2,8 @@ import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/ import { CandlestickData } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb.js'; import { getMetadataFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response'; import { AllSlices, SliceCreator } from '..'; -import { sendCandlestickDataRequest } from './helpers'; +import { sendCandlestickDataRequests } from './helpers'; +import { PRICE_RELEVANCE_THRESHOLDS } from '@penumbra-zone/types/assets'; interface Actions { load: (ac?: AbortController) => AbortController['abort']; @@ -26,20 +27,18 @@ export const createPriceHistorySlice = (): SliceCreator => (s const { assetIn, assetOut } = get().swap; const startMetadata = getMetadataFromBalancesResponseOptional(assetIn); const endMetadata = assetOut; - void sendCandlestickDataRequest( + void sendCandlestickDataRequests( { startMetadata, endMetadata }, - // there's no UI to set limit yet, and most ranges don't always happen to - // include price records. 2500 at least scales well when there is data - 2500n, + // there's no UI to set limit yet, and any given range won't always happen + // to include price records. + PRICE_RELEVANCE_THRESHOLDS.default * 2n, ac.signal, - ).then(data => { - if (data) { - set(({ swap }) => { - swap.priceHistory.startMetadata = startMetadata; - swap.priceHistory.endMetadata = endMetadata; - swap.priceHistory.candles = data; - }); - } + ).then(candles => { + set(({ swap }) => { + swap.priceHistory.startMetadata = startMetadata; + swap.priceHistory.endMetadata = endMetadata; + swap.priceHistory.candles = candles; + }); }); return () => ac.abort('Returned slice abort');