Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Get trailing avg from graphql api #296

Merged
merged 21 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/api/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type Query {
positions(chainId: Int, marketAddress: String, owner: String): [PositionType!]!
resource(slug: String!): ResourceType
resourceCandles(from: Int!, interval: Int!, slug: String!, to: Int!): [CandleType!]!
resourceTrailingAverageCandles(from: Int!, interval: Int!, slug: String!, to: Int!, trailingTime: Int!): [CandleType!]!
resources: [ResourceType!]!
transactions(positionId: Int): [TransactionType!]!
}
Expand Down
159 changes: 159 additions & 0 deletions packages/api/src/graphql/resolvers/CandleResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ interface PricePoint {
value: string;
}

interface ResourcePricePoint {
timestamp: number;
value: string;
used: string;
feePaid: string;
}

const groupPricesByInterval = (
prices: PricePoint[],
intervalSeconds: number,
Expand Down Expand Up @@ -72,6 +79,92 @@ const groupPricesByInterval = (
return candles;
};

const getTrailingAveragePricesByInterval = (
prices: ResourcePricePoint[],
trailingIntervalSeconds: number,
intervalSeconds: number,
startTimestamp: number,
endTimestamp: number,
lastKnownPrice?: string
): CandleType[] => {
const candles: CandleType[] = [];

// If we have no prices and no reference price, return empty array
if (prices.length === 0 && !lastKnownPrice) return [];

// Normalize timestamps to interval boundaries
const normalizedStartTimestamp =
Math.floor(startTimestamp / intervalSeconds) * intervalSeconds;
const normalizedEndTimestamp =
Math.floor(endTimestamp / intervalSeconds) * intervalSeconds;

// Initialize lastClose with lastKnownPrice if available, otherwise use first price
let lastClose = lastKnownPrice || prices[0].value;

// Ensure it's ordered (it should come ordered from the query, then the sort is just a sanity check)
const orderedPrices = prices.sort((a, b) => a.timestamp - b.timestamp);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't they already sorted?

let searchStartIdx = 0; // Add this to track where to start searching from
let lastStartIdx = 0;
let lastEndIdx = 0;

let totalGasUsed: bigint = 0n;
let totalBaseFeesPaid: bigint = 0n;

for (
let timestamp = normalizedStartTimestamp;
timestamp <= normalizedEndTimestamp;
timestamp += intervalSeconds
) {
// Search for start index from the last known position
while (searchStartIdx < orderedPrices.length &&
orderedPrices[searchStartIdx].timestamp < timestamp - trailingIntervalSeconds) {
searchStartIdx++;
}
const startIdx = searchStartIdx < orderedPrices.length ? searchStartIdx : -1;

// Search for end index from the start index
let searchEndIdx = Math.max(searchStartIdx, lastEndIdx);
while (searchEndIdx < orderedPrices.length &&
orderedPrices[searchEndIdx].timestamp <= timestamp) {
searchEndIdx++;
}
const endIdx = searchEndIdx - 1; // No need for correction since we're getting the last valid index directly

// Remove from the sliding window trailing average the prices that are no longer in the interval
if (startIdx != -1) {
for (let i = lastStartIdx; i <= startIdx; i++) {
totalGasUsed -= BigInt(orderedPrices[i].used);
totalBaseFeesPaid -= BigInt(orderedPrices[i].feePaid);
}
}
lastStartIdx = startIdx;

// Add to the sliding window trailing average the prices that are now in the interval
for (let i = lastEndIdx; i <= endIdx; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could try to make the calculations faster; right now the runtime is approx. O(n^2) since we have a nested loop. If the performance is not too big of a concern we should be good though

totalGasUsed += BigInt(orderedPrices[i].used);
totalBaseFeesPaid += BigInt(orderedPrices[i].feePaid);
}
lastEndIdx = endIdx;

// Calculate the average price for the interval
if (totalGasUsed > 0n) {
const averagePrice: bigint = totalBaseFeesPaid / totalGasUsed;
lastClose = averagePrice.toString();
}

// Create candle with last known closing price (calculated in the loop or previous candle)
candles.push({
timestamp,
open: lastClose,
high: lastClose,
low: lastClose,
close: lastClose,
});
}

return candles;
};

@Resolver()
export class CandleResolver {
@Query(() => [CandleType])
Expand Down Expand Up @@ -126,6 +219,72 @@ export class CandleResolver {
}
}

@Query(() => [CandleType])
async resourceTrailingAverageCandles(
@Arg('slug', () => String) slug: string,
@Arg('from', () => Int) from: number,
@Arg('to', () => Int) to: number,
@Arg('interval', () => Int) interval: number,
@Arg('trailingTime', () => Int) trailingTime: number
): Promise<CandleType[]> {
try {
const trailingFrom = from - trailingTime;
const resource = await dataSource.getRepository(Resource).findOne({
where: { slug },
});

if (!resource) {
throw new Error(`Resource not found with slug: ${slug}`);
}

// First get the most recent price before the trailingFrom timestamp
const lastPriceBefore = await dataSource
.getRepository(ResourcePrice)
.createQueryBuilder('price')
.where('price.resourceId = :resourceId', { resourceId: resource.id })
.andWhere('price.timestamp < :from', { from: trailingFrom })
.orderBy('price.timestamp', 'DESC')
.take(1)
.getOne();

// Then get all prices within the range
const pricesInRange = await dataSource.getRepository(ResourcePrice).find({
where: {
resource: { id: resource.id },
timestamp: Between(trailingFrom, to),
},
order: { timestamp: 'ASC' },
});

// Combine the results, putting the last price before first if it exists
const prices = pricesInRange;

const lastKnownPrice =
lastPriceBefore?.feePaid && lastPriceBefore?.used
? (
BigInt(lastPriceBefore?.feePaid) / BigInt(lastPriceBefore?.used)
).toString()
: lastPriceBefore?.value;

return getTrailingAveragePricesByInterval(
prices.map((p) => ({
timestamp: Number(p.timestamp),
value: p.value,
used: p.used,
feePaid: p.feePaid,
})),
trailingTime,
interval,
from,
to,
lastKnownPrice
);
} catch (error) {
console.error('Error fetching resource candles:', error);
throw new Error('Failed to fetch resource candles');
}
}

@Query(() => [CandleType])
async indexCandles(
@Arg('chainId', () => Int) chainId: number,
Expand Down
124 changes: 78 additions & 46 deletions packages/app/src/lib/hooks/useChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,27 @@ const RESOURCE_CANDLES_QUERY = gql`
}
`;

const TRAILING_RESOURCE_CANDLES_QUERY = gql`
query TrailingResourceCandles(
$slug: String!
$from: Int!
$to: Int!
$interval: Int!
$trailingTime: Int!
) {
resourceTrailingAverageCandles(
slug: $slug
from: $from
to: $to
interval: $interval
trailingTime: $trailingTime
) {
timestamp
close
}
}
`;

export const useChart = ({
resourceSlug,
market,
Expand Down Expand Up @@ -306,6 +327,53 @@ export const useChart = ({
(seriesVisibility?.trailing ?? true)),
});

const { data: trailingResourcePrices, isLoading: isTrailingResourceLoading } =
useQuery<ResourcePricePoint[]>({
queryKey: [
'trailingResourcePrices',
resourceSlug,
market?.epochId,
selectedInterval,
],
queryFn: async () => {
if (!resourceSlug) {
return [];
}
const now = Math.floor(Date.now() / 1000);
const from = now - 28 * 24 * 60 * 60 * 2; // Two periods ago
const interval = getIntervalSeconds(selectedInterval);

// TODO Adjust `interval`, or `from` to limit the amount of data fetched to some reasonable amount (i.e. 2000 candles)

const response = await fetch(`${API_BASE_URL}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: print(TRAILING_RESOURCE_CANDLES_QUERY),
variables: {
slug: resourceSlug,
from,
to: now,
interval,
trailingTime: 28 * 24 * 60 * 60, // 28 days in seconds
},
}),
});

const { data } = await response.json();
return data.resourceTrailingAverageCandles.map((candle: any) => ({
timestamp: timeToLocal(candle.timestamp * 1000),
price: Number(formatUnits(BigInt(candle.close), 9)),
}));
},
enabled:
!!resourceSlug &&
((seriesVisibility?.resource ?? true) ||
(seriesVisibility?.trailing ?? true)),
});

// Effect for chart creation/cleanup
useEffect(() => {
if (!containerRef.current) return;
Expand Down Expand Up @@ -470,51 +538,14 @@ export const useChart = ({
};

const updateTrailingAverageData = () => {
if (resourcePrices?.length && trailingPriceSeriesRef.current) {
const windowSize = 28 * 24 * 60 * 60 * 1000; // 28 days in milliseconds
const sortedPrices = [...resourcePrices].sort(
(a, b) => a.timestamp - b.timestamp
);

// Initialize sliding window sums
let windowSum = 0;
let windowCount = 0;
let startIdx = 0;

const trailingData = sortedPrices
.map((current, i) => {
const currentTime = current.timestamp;
const windowStart = currentTime - windowSize;

// Remove points that are now outside the window
while (
startIdx < i &&
sortedPrices[startIdx].timestamp <= windowStart
) {
windowSum -= sortedPrices[startIdx].price;
windowCount--;
startIdx++;
}

// Add current point to the window
windowSum += current.price;
windowCount++;

// Only return a point if we have enough data
if (windowCount > 0) {
const avgPrice = windowSum / windowCount;
return {
time: (currentTime / 1000) as UTCTimestamp,
value: useMarketUnits
? Number((stEthPerToken || 1) * (avgPrice / 1e9))
: avgPrice,
};
}
return null;
})
.filter((point): point is NonNullable<typeof point> => point !== null);

trailingPriceSeriesRef.current.setData(trailingData);
if (trailingResourcePrices?.length && trailingPriceSeriesRef.current) {
const trailingLineData = trailingResourcePrices.map((trp) => ({
time: (trp.timestamp / 1000) as UTCTimestamp,
value: useMarketUnits
? Number((stEthPerToken || 1) * (trp.price / 1e9))
: trp.price,
}));
trailingPriceSeriesRef.current.setData(trailingLineData);
}
};

Expand Down Expand Up @@ -573,6 +604,7 @@ export const useChart = ({
resourcePrices,
indexPrices,
marketPrices,
trailingResourcePrices,
isBeforeStart,
selectedWindow,
]);
Expand Down Expand Up @@ -609,7 +641,7 @@ export const useChart = ({
candles: !marketPrices && !!market,
index: isIndexLoading && !!market,
resource: isResourceLoading && !!resourceSlug,
trailing: isResourceLoading && !!resourceSlug,
trailing: isTrailingResourceLoading && !!resourceSlug,
}),
[isIndexLoading, isResourceLoading, market, resourceSlug]
);
Expand Down