Skip to content

Commit 69c8ac1

Browse files
aristidesstaffieriCopilotCopilot
authored
fix(src): Fix 24h price gap calculation by widening retention and lookup window (#300)
* Fix 24h price gap calculation by widening retention and lookup window The Redis Time Series retention period (24h) and the 24h-ago lookup threshold (5min tolerance) created only a 5-minute overlap window for finding historical price data. As the token count grows and update cycles take longer, this narrow window frequently contains no data points, causing percentagePriceChange24h to return null. - Increase RETENTION_PERIOD from 24h to 25h - Increase DEFAULT_ONE_DAY_THRESHOLD_MS from 5min (300000ms) to 30min (1800000ms) This expands the overlap window from 5 minutes to 90 minutes, tolerating longer update cycles and transient Horizon outages without losing 24h price change data. * gap calculation by decoupling history gate from lookup tolerance The 24h price change calculation frequently returns null because a single threshold (oneDayThreshold) was used for both the minimum history requirement and the revRange lookup window. With a 5-minute tolerance and 24h retention, the overlap window for finding a historical price point was only 5 minutes — easily missed during update gaps. - Increase RETENTION_PERIOD from 24h to 25h for wider data retention - Add separate PRICE_LOOKUP_TOLERANCE_MS (30min) for the revRange lookup - Keep DEFAULT_ONE_DAY_THRESHOLD_MS (5min) for the strict ~24h history gate The history gate still requires ~23h55m of data before computing a 24h change (no stale data). The lookup now searches with a 90-minute overlap window instead of 5 minutes, tolerating gaps in price updates., wiring up the handoff from setup to live ingestion * Add boundary tests for decoupled history gate and lookup tolerance Add tests verifying that the 24h history gate and the lookup tolerance operate independently: - Token with 23h54m of history (1min under gate) returns null for percentagePriceChange24h and does not attempt the revRange lookup - Token with 23h56m of history (1min over gate) computes the 24h change using the wider 30min PRICE_LOOKUP_TOLERANCE_MS, not the strict 5min DEFAULT_ONE_DAY_THRESHOLD_MS * Update retention comments to match new window Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: express PRICE_LOOKUP_TOLERANCE_MS as 30 * 60 * 1000 and improve doc comment Agent-Logs-Url: https://github.com/stellar/freighter-backend/sessions/c6614a68-5178-48c6-911e-a16f20fb16b1 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 14bbe0a commit 69c8ac1

2 files changed

Lines changed: 95 additions & 16 deletions

File tree

src/service/prices/index.test.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe("Token Price Client", () => {
6969
// Use PriceClient static properties if available, otherwise redefine
7070
const ONE_DAY = 24 * 60 * 60 * 1000;
7171
const ONE_MINUTE = 60 * 1000;
72-
const twentyFourHoursAgo = mockNow - (ONE_DAY - 5 * ONE_MINUTE);
72+
const lookupCutoff = mockNow - ONE_DAY + 30 * ONE_MINUTE;
7373
const muchOlderTimestamp = mockNow - 2 * ONE_DAY; // Timestamp > 24h ago
7474

7575
// Mock ts.get to return current price data
@@ -89,7 +89,7 @@ describe("Token Price Client", () => {
8989
// Mock ts.revRange for the oldPrices check
9090
mockRedisClient.ts.revRange.mockResolvedValue([
9191
{
92-
timestamp: twentyFourHoursAgo, // Simulate finding a price exactly 24h ago
92+
timestamp: lookupCutoff, // Simulate finding a price at the lookup cutoff
9393
value: mockHistoricalPrice,
9494
},
9595
]);
@@ -112,7 +112,7 @@ describe("Token Price Client", () => {
112112
expect(mockRedisClient.ts.revRange).toHaveBeenCalledWith(
113113
token,
114114
"-",
115-
twentyFourHoursAgo, // Match the timestamp used in getPrice
115+
lookupCutoff, // Match the lookup cutoff used in getPrice
116116
{ COUNT: 1 },
117117
);
118118
// Check the counter increment
@@ -161,6 +161,73 @@ describe("Token Price Client", () => {
161161
);
162162
});
163163

164+
it("should return null when history is just under the 24h gate (23h54m)", async () => {
165+
const mockCurrentPrice = 50000;
166+
const mockNow = Date.now();
167+
const ONE_DAY = 24 * 60 * 60 * 1000;
168+
const ONE_MINUTE = 60 * 1000;
169+
// First entry is 23h54m ago — 1 minute short of the 23h55m gate
170+
const justUnderGate = mockNow - (ONE_DAY - 6 * ONE_MINUTE);
171+
172+
mockRedisClient.ts.get.mockResolvedValue({
173+
timestamp: mockNow,
174+
value: mockCurrentPrice,
175+
});
176+
177+
mockRedisClient.ts.range.mockResolvedValue([
178+
{ timestamp: justUnderGate, value: 48000 },
179+
]);
180+
181+
const token = "BOUNDARY:GUNDER";
182+
const result = await priceClient.getPrice(token);
183+
184+
expect(result).not.toBeNull();
185+
expect(result?.currentPrice.toNumber()).toBe(mockCurrentPrice);
186+
expect(result?.percentagePriceChange24h).toBeNull();
187+
expect(mockRedisClient.ts.revRange).not.toHaveBeenCalled();
188+
});
189+
190+
it("should compute 24h change when history just passes the gate (23h56m)", async () => {
191+
const mockCurrentPrice = 50000;
192+
const mockHistoricalPrice = 45000;
193+
const mockNow = Date.now();
194+
const ONE_DAY = 24 * 60 * 60 * 1000;
195+
const ONE_MINUTE = 60 * 1000;
196+
// First entry is 23h56m ago — 1 minute past the 23h55m gate
197+
const justOverGate = mockNow - (ONE_DAY - 4 * ONE_MINUTE);
198+
// Lookup cutoff uses the wider 30min tolerance: mockNow - 24h + 30min
199+
const lookupCutoff = mockNow - ONE_DAY + 30 * ONE_MINUTE;
200+
201+
mockRedisClient.ts.get.mockResolvedValue({
202+
timestamp: mockNow,
203+
value: mockCurrentPrice,
204+
});
205+
206+
mockRedisClient.ts.range.mockResolvedValue([
207+
{ timestamp: justOverGate, value: 48000 },
208+
]);
209+
210+
mockRedisClient.ts.revRange.mockResolvedValue([
211+
{ timestamp: lookupCutoff, value: mockHistoricalPrice },
212+
]);
213+
214+
const token = "BOUNDARY:GOVER";
215+
const result = await priceClient.getPrice(token);
216+
217+
expect(result).not.toBeNull();
218+
expect(result?.currentPrice.toNumber()).toBe(mockCurrentPrice);
219+
expect(result?.percentagePriceChange24h?.toFixed(2)).toBe("11.11");
220+
221+
// Verify the lookup uses the wider PRICE_LOOKUP_TOLERANCE_MS (30min),
222+
// NOT the history gate's DEFAULT_ONE_DAY_THRESHOLD_MS (5min)
223+
expect(mockRedisClient.ts.revRange).toHaveBeenCalledWith(
224+
token,
225+
"-",
226+
lookupCutoff,
227+
{ COUNT: 1 },
228+
);
229+
});
230+
164231
it("should handle new token request", async () => {
165232
// Setup mock - ts.get throws error for non-existent key
166233
mockRedisClient.ts.get.mockRejectedValue(new Error("Key does not exist"));

src/service/prices/index.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ export class PriceClient {
7878

7979
/**
8080
* The time period (in milliseconds) for which to retain price data in Redis time series.
81-
* Currently set to 1 day in milliseconds to support 24-hour price change calculations while managing storage usage.
81+
* Currently set to 25 hours in milliseconds to support 24-hour price change calculations while managing storage usage.
82+
* The extra hour of retention helps avoid gaps when computing 24-hour price changes from the time series data.
8283
*/
83-
private static readonly RETENTION_PERIOD = 24 * 60 * 60 * 1000;
84+
private static readonly RETENTION_PERIOD = 25 * 60 * 60 * 1000;
8485

8586
/**
8687
* Delay (in milliseconds) between processing batches of tokens during price updates.
@@ -106,6 +107,13 @@ export class PriceClient {
106107
*/
107108
private static readonly DEFAULT_ONE_DAY_THRESHOLD_MS = 300000;
108109

110+
/**
111+
* Tolerance window (in milliseconds) for the revRange lookup when searching
112+
* for a historical price point. Set to 30 minutes, wider than the history
113+
* gate threshold, to tolerate gaps in price updates within the retained data.
114+
*/
115+
private static readonly PRICE_LOOKUP_TOLERANCE_MS = 30 * 60 * 1000;
116+
109117
/**
110118
* Maximum number of tokens to fetch and track prices for initially.
111119
* Limits the total number of tokens to manage system resource usage.
@@ -211,27 +219,31 @@ export class PriceClient {
211219

212220
const currentPrice = new BigNumber(latestPrice.value);
213221
let percentagePriceChange24h: BigNumber | null = null;
214-
const oneDayThreshold = PriceClient.ONE_DAY - this.priceOneDayThresholdMs;
222+
const minHistoryRequired =
223+
PriceClient.ONE_DAY - this.priceOneDayThresholdMs;
215224

216-
// When calculating the 24h price change, we want to make sure the token has been tracked for at least 24 hours.
225+
// Ensure the token has been tracked for at least ~24 hours before computing a 24h change.
217226
const firstEntry = await this.redisClient.ts.range(tsKey, "-", "+", {
218227
COUNT: 1,
219228
});
220229
if (
221230
firstEntry &&
222231
firstEntry.length > 0 &&
223-
latestPrice.timestamp - oneDayThreshold >= firstEntry[0].timestamp
232+
latestPrice.timestamp - minHistoryRequired >= firstEntry[0].timestamp
224233
) {
225-
// revRange traverses the time series in reverse chronological order.
226-
// We use the "-" symbol to indicate the earliest/oldest timestamp of the time series.
227-
// We dont use the exact 1 day calculation but use an offset of few minutes to account for slight timing variations.
228-
const dayAgo = latestPrice.timestamp - oneDayThreshold;
234+
// Use a wider tolerance for the lookup to tolerate gaps in price updates.
235+
// The history gate above guarantees data ≥ ~24h old exists, so the wider
236+
// lookup window will reliably find a price point.
237+
const lookupCutoff =
238+
latestPrice.timestamp -
239+
PriceClient.ONE_DAY +
240+
PriceClient.PRICE_LOOKUP_TOLERANCE_MS;
229241
const oldPrices = await this.redisClient.ts.revRange(
230242
tsKey,
231-
"-", // Indicates the earliest/oldest timestamp of the time series
232-
dayAgo, // Indicates the timestamp roughly 24 hours ago from the latest price.
243+
"-",
244+
lookupCutoff,
233245
{
234-
COUNT: 1, // Get the single most recent entry at or before dayAgo
246+
COUNT: 1,
235247
},
236248
);
237249

@@ -256,7 +268,7 @@ export class PriceClient {
256268
);
257269
this.logger.info(`Earliest entry: ${JSON.stringify(firstEntry)}`);
258270
this.logger.info(
259-
`Time difference: ${latestPrice.timestamp - firstEntry[0].timestamp}, 1 day threshold: ${oneDayThreshold}`,
271+
`Time difference: ${latestPrice.timestamp - firstEntry[0].timestamp}, 1 day threshold: ${minHistoryRequired}`,
260272
);
261273
}
262274

0 commit comments

Comments
 (0)