diff --git a/api/v1_users_balance_history.go b/api/v1_users_balance_history.go index fa8f5341..91e81c16 100644 --- a/api/v1_users_balance_history.go +++ b/api/v1_users_balance_history.go @@ -96,27 +96,37 @@ func (app *ApiServer) v1UsersBalanceHistory(c *fiber.Ctx) error { return err } - // Build SQL query with granularity parameter - // Using a CTE to handle the conditional truncation based on granularity + // Build SQL query with granularity parameter. + // First sum balance_usd across mints at each hourly snapshot to get the + // total portfolio value per hour. For 'daily', pick the latest hour within + // each day (end-of-day balance) — do NOT sum balances across hours, since + // each hour is a point-in-time snapshot, not an additive quantity. sql := ` - WITH time_buckets AS ( + WITH hourly_totals AS ( + SELECT + timestamp, + SUM(balance_usd) AS balance_usd + FROM user_balance_history + WHERE user_id = @user_id + AND timestamp >= @start_time + AND timestamp <= @end_time + GROUP BY timestamp + ), + bucketed AS ( SELECT CASE WHEN @granularity::text = 'daily' THEN date_trunc('day', timestamp) ELSE timestamp END AS bucket_timestamp, + timestamp AS source_timestamp, balance_usd - FROM user_balance_history - WHERE user_id = @user_id - AND timestamp >= @start_time - AND timestamp <= @end_time + FROM hourly_totals ) - SELECT + SELECT DISTINCT ON (bucket_timestamp) bucket_timestamp AS timestamp, - SUM(balance_usd) AS balance_usd - FROM time_buckets - GROUP BY bucket_timestamp - ORDER BY bucket_timestamp ASC + balance_usd + FROM bucketed + ORDER BY bucket_timestamp ASC, source_timestamp DESC ` rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ diff --git a/api/v1_users_balance_history_test.go b/api/v1_users_balance_history_test.go index e4be1466..1bfd85d4 100644 --- a/api/v1_users_balance_history_test.go +++ b/api/v1_users_balance_history_test.go @@ -538,13 +538,15 @@ func TestUserBalanceHistoryDailyGranularityWithMultipleHours(t *testing.T) { "data.#": 3, // 3 hourly points }) - // Test daily granularity - should return 1 data point with summed balance + // Test daily granularity - should return 1 data point representing the + // balance at the latest hour of the day (end-of-day balance), not a sum + // across all hours (each row is a point-in-time portfolio snapshot). status, body = testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/balance/history?start_time="+startTime+"&end_time="+endTime+"&granularity=daily") assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.#": 1, // 1 daily point (all hours on same day grouped) - "data.0.balance_usd": 60.0, // Sum: 10 + 20 + 30 + "data.#": 1, // 1 daily point (latest hour of the day) + "data.0.balance_usd": 30.0, // Balance at 18:00 (the latest hour) }) }