From 7be040103478d76be01da74c8f5501f9623c2f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment?= <998369+prevostc@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:43:37 +0200 Subject: [PATCH] Fix daily moving avg (#4) * Add a daily moving average utility * Update 30D avg on deposit and withdraw * Ignore test run file --- .gitignore | 2 + schema.graphql | 10 +-- src/clock.ts | 36 +++++---- src/entity/investor.ts | 2 +- src/entity/position.ts | 2 +- src/utils/daily-avg.ts | 101 +++++++++++++++++++++++++ src/utils/time.ts | 1 + src/vault-interaction.ts | 9 +++ tests/.latest.json | 4 - tests/utils/daily-avg.test.ts | 138 ++++++++++++++++++++++++++++++++++ 10 files changed, 274 insertions(+), 31 deletions(-) create mode 100644 src/utils/daily-avg.ts delete mode 100644 tests/.latest.json create mode 100644 tests/utils/daily-avg.test.ts diff --git a/.gitignore b/.gitignore index 3639eb8..7fc02fe 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ node_modules subgraph.yaml src/config.ts + +tests/.latest.json \ No newline at end of file diff --git a/schema.graphql b/schema.graphql index 8cd9acd..ebfa2dc 100644 --- a/schema.graphql +++ b/schema.graphql @@ -649,12 +649,11 @@ type Investor @entity { "Current position value in USD as the sum of all the investor ongoing positions" totalPositionValueUSD: BigDecimal! + "Internal state to calculate the moving averages of the total position value" + averageDailyTotalPositionValueUSDState: [BigDecimal!]! "Sum of the investor total positions value at the end of the day for the last 30 days in USD divided by the number of active days" averageDailyTotalPositionValueUSD30D: BigDecimal! - "The last daily total position values in USD for the last 30 days. Most recent last." - last30DailyTotalPositionValuesUSD: [BigDecimal!]! - "The transactions count that the investor was the sender of" cumulativeInteractionsCount: Int! "All deposits of the investor" @@ -765,12 +764,11 @@ type InvestorPosition @entity { "Current position value in USD" positionValueUSD: BigDecimal! + "Internal state to calculate the moving averages of the position value" + averageDailyPositionValueUSDState: [BigDecimal!]! "Sum of the daily position value for the last 30 days in USD divided by the number of active days" averageDailyPositionValueUSD30D: BigDecimal! - "The last daily position values in USD for the last 30 days. Most recent last." - last30DailyPositionValuesUSD: [BigDecimal!]! - "The compounded amount of the first token" cumulativeCompoundedAmount0: BigDecimal! "The compounded amount of the second token" diff --git a/src/clock.ts b/src/clock.ts index 6dd2070..0145db9 100644 --- a/src/clock.ts +++ b/src/clock.ts @@ -1,4 +1,4 @@ -import { Address, BigDecimal, Bytes, ethereum, log } from "@graphprotocol/graph-ts" +import { Address, BigDecimal, BigInt, Bytes, ethereum, log } from "@graphprotocol/graph-ts" import { ClockTick, Investor } from "../generated/schema" import { DAY, MINUTES_15, SNAPSHOT_PERIODS } from "./utils/time" import { getClockTick } from "./entity/clock" @@ -10,6 +10,7 @@ import { getBeefyCLStrategy, getBeefyCLVaultSnapshot, isVaultRunning } from "./e import { getInvestorPositionSnapshot } from "./entity/position" import { getInvestorSnapshot } from "./entity/investor" import { BeefyVaultConcLiq as BeefyCLVaultContract } from "../generated/templates/BeefyCLVault/BeefyVaultConcLiq" +import { DailyAvgCalc, DailyAvgState } from "./utils/daily-avg" export function handleClockTick(block: ethereum.Block): void { const timestamp = block.timestamp @@ -143,17 +144,15 @@ function updateDataOnClockTick(tick: ClockTick, isNewDay: boolean): void { position.underlyingBalance0USD = position.underlyingBalance0.times(token0PriceInUSD) position.underlyingBalance1USD = position.underlyingBalance1.times(token1PriceInUSD) position.positionValueUSD = position.underlyingBalance0USD.plus(position.underlyingBalance1USD) + let state = DailyAvgState.deserialize(position.averageDailyPositionValueUSDState) if (isNewDay) { - let last30DailyPositionValuesUSD = position.last30DailyPositionValuesUSD // required by thegraph - last30DailyPositionValuesUSD.push(position.positionValueUSD) // most recent value last - while (last30DailyPositionValuesUSD.length > 30) { - last30DailyPositionValuesUSD.shift() // remove oldest value - } - position.last30DailyPositionValuesUSD = last30DailyPositionValuesUSD - position.averageDailyPositionValueUSD30D = last30DailyPositionValuesUSD - .reduce((acc, val) => acc.plus(val), ZERO_BD) - .div(BigDecimal.fromString(last30DailyPositionValuesUSD.length.toString())) + state.addValue(position.positionValueUSD) + state.resetPendingValue() + } else { + state.updatePendingValueTimestamp(tick.timestamp) } + position.averageDailyPositionValueUSD30D = DailyAvgCalc.avg(DAY.times(BigInt.fromU32(30)), state) + position.averageDailyPositionValueUSDState = state.serialize() position.save() // update position snapshot for (let k = 0; k < periods.length; k++) { @@ -201,17 +200,16 @@ function updateDataOnClockTick(tick: ClockTick, isNewDay: boolean): void { // @ts-ignore const tvl: BigDecimal = investorTVL.get(investorIdStr) investor.totalPositionValueUSD = tvl + + let state = DailyAvgState.deserialize(investor.averageDailyTotalPositionValueUSDState) if (isNewDay) { - let last30DailyTotalPositionValuesUSD = investor.last30DailyTotalPositionValuesUSD - last30DailyTotalPositionValuesUSD.push(tvl) // most recent value last - while (last30DailyTotalPositionValuesUSD.length > 30) { - last30DailyTotalPositionValuesUSD.shift() // remove oldest value - } - investor.last30DailyTotalPositionValuesUSD = last30DailyTotalPositionValuesUSD - investor.averageDailyTotalPositionValueUSD30D = last30DailyTotalPositionValuesUSD - .reduce((acc, val) => acc.plus(val), ZERO_BD) - .div(BigDecimal.fromString(last30DailyTotalPositionValuesUSD.length.toString())) + state.addValue(tvl) + state.resetPendingValue() + } else { + state.updatePendingValueTimestamp(tick.timestamp) } + investor.averageDailyTotalPositionValueUSD30D = DailyAvgCalc.avg(DAY.times(BigInt.fromU32(30)), state) + investor.averageDailyTotalPositionValueUSDState = state.serialize() investor.save() for (let j = 0; j < periods.length; j++) { const period = periods[j] diff --git a/src/entity/investor.ts b/src/entity/investor.ts index c118f58..e217f61 100644 --- a/src/entity/investor.ts +++ b/src/entity/investor.ts @@ -19,7 +19,7 @@ export function getInvestor(accountAddress: Bytes): Investor { investor.lastInteractionAt = ZERO_BI investor.totalPositionValueUSD = ZERO_BD investor.averageDailyTotalPositionValueUSD30D = ZERO_BD - investor.last30DailyTotalPositionValuesUSD = new Array() + investor.averageDailyTotalPositionValueUSDState = new Array() investor.cumulativeInteractionsCount = 0 investor.cumulativeDepositCount = 0 investor.cumulativeWithdrawCount = 0 diff --git a/src/entity/position.ts b/src/entity/position.ts index c022846..e3feb3a 100644 --- a/src/entity/position.ts +++ b/src/entity/position.ts @@ -32,7 +32,7 @@ export function getInvestorPosition(vault: BeefyCLVault, investor: Investor): In position.underlyingBalance1USD = ZERO_BD position.positionValueUSD = ZERO_BD position.averageDailyPositionValueUSD30D = ZERO_BD - position.last30DailyPositionValuesUSD = new Array() + position.averageDailyPositionValueUSDState = new Array() position.cumulativeCompoundedAmount0 = ZERO_BD position.cumulativeCompoundedAmount1 = ZERO_BD position.cumulativeCompoundedAmount0USD = ZERO_BD diff --git a/src/utils/daily-avg.ts b/src/utils/daily-avg.ts new file mode 100644 index 0000000..5455d1b --- /dev/null +++ b/src/utils/daily-avg.ts @@ -0,0 +1,101 @@ +import { BigDecimal, BigInt, log } from "@graphprotocol/graph-ts" +import { ZERO_BD, ZERO_BI } from "./decimal" +import { DAY } from "./time" + +export class DailyAvgState { + constructor( + public pendingValue: BigDecimal, + public pendingValueTimestamp: BigInt, + // a circular buffer of the last n values + // a closed value is a value that is not updated anymore + public closedValues: Array, + ) {} + + serialize(): Array { + let res = new Array() + res.push(this.pendingValue) + res.push(this.pendingValueTimestamp.toBigDecimal()) + for (let idx = 0; idx < this.closedValues.length; idx++) { + let entry = this.closedValues[idx] + res.push(entry) + } + return res + } + + static deserialize(data: Array): DailyAvgState { + let values = new Array() + if (data.length === 0) { + return new DailyAvgState(ZERO_BD, ZERO_BI, values) + } + + let pendingValue = data.shift() as BigDecimal + let pendingValueTimestamp = BigInt.fromString((data.shift() as BigDecimal).truncate(0).toString()) + + while (data.length > 0) { + let value = data.shift() as BigDecimal + values.push(value) + } + return new DailyAvgState(pendingValue, pendingValueTimestamp, values) + } + + public addValue(entry: BigDecimal): void { + this.closedValues.push(entry) + } + + public setPendingValue(value: BigDecimal, timestamp: BigInt): void { + this.pendingValue = value + this.pendingValueTimestamp = timestamp + } + + public resetPendingValue(): void { + this.pendingValue = ZERO_BD + this.pendingValueTimestamp = ZERO_BI + } + + public updatePendingValueTimestamp(timestamp: BigInt): void { + this.pendingValueTimestamp = timestamp + } +} + +export class DailyAvgCalc { + public static avg(entriesToUse: BigInt, state: DailyAvgState): BigDecimal { + // first, eliminate the entries that are not in the period anymore + state = DailyAvgCalc.evictOldEntries(entriesToUse, state) + + // we need at least 1 entry to compute the apr + if (state.closedValues.length === 0) { + return state.pendingValue + } + + const closedSeconds = DAY.times(BigInt.fromI32(state.closedValues.length)) + const pendingSeconds = state.pendingValueTimestamp.mod(DAY) + let res = ZERO_BD + for (let idx = 0; idx < state.closedValues.length; idx++) { + const entry = state.closedValues[idx] + res = res.plus(entry.times(DAY.toBigDecimal())) + } + res = res.plus(state.pendingValue.times(pendingSeconds.toBigDecimal())) + return res.div(closedSeconds.plus(pendingSeconds).toBigDecimal()) + } + + /** + * Evict entries that do not belong to the period anymore + */ + public static evictOldEntries(entriesToUse: BigInt, state: DailyAvgState): DailyAvgState { + if (entriesToUse.lt(ZERO_BI) || entriesToUse.equals(ZERO_BI)) { + log.error("DailyAvgCalc: entriesToUse cannot be negative or zero, got {}", [entriesToUse.toString()]) + throw new Error("DailyAvgCalc: entriesToUse cannot be negative or zero") + } + + let lastEntryIdx = state.closedValues.length - 1 + let firstEntryIdx = lastEntryIdx - entriesToUse.toI32() + 1 + let firstEntryIdxClamped = Math.max(firstEntryIdx, 0) as i32 + + const newEntries = new Array() + for (let idx = firstEntryIdxClamped; idx <= lastEntryIdx; idx++) { + newEntries.push(state.closedValues[idx]) + } + + return new DailyAvgState(state.pendingValue, state.pendingValueTimestamp, newEntries) + } +} diff --git a/src/utils/time.ts b/src/utils/time.ts index 0397a27..165f60d 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,6 +1,7 @@ import { BigInt } from "@graphprotocol/graph-ts" export const MINUTES_15 = BigInt.fromI32(60 * 15) +export const HOUR = BigInt.fromI32(60 * 60) export const DAY = BigInt.fromI32(60 * 60 * 24) export const WEEK = BigInt.fromI32(60 * 60 * 24 * 7) export const YEAR = BigInt.fromI32(60 * 60 * 24 * 365) diff --git a/src/vault-interaction.ts b/src/vault-interaction.ts index f32350c..1286da2 100644 --- a/src/vault-interaction.ts +++ b/src/vault-interaction.ts @@ -19,6 +19,7 @@ import { InvestorPositionInteraction } from "../generated/schema" import { getEventIdentifier } from "./utils/event" import { SHARE_TOKEN_MINT_ADDRESS } from "./config" import { isBoostAddress } from "./entity/boost" +import { DailyAvgCalc, DailyAvgState } from "./utils/daily-avg" export function handleVaultDeposit(event: DepositEvent): void { updateUserPosition(event, event.params.user, true, false) @@ -223,6 +224,10 @@ function updateUserPosition( position.initialUnderlyingBalance1USD = position.initialUnderlyingBalance1USD.times(mult) position.initialPositionValueUSD = position.initialPositionValueUSD.times(mult) } + let dailyAvgState = DailyAvgState.deserialize(position.averageDailyPositionValueUSDState) + dailyAvgState.setPendingValue(position.positionValueUSD, event.block.timestamp) + position.averageDailyPositionValueUSD30D = DailyAvgCalc.avg(BigInt.fromI32(30), dailyAvgState) + position.averageDailyPositionValueUSDState = dailyAvgState.serialize() position.save() for (let i = 0; i < periods.length; i++) { log.debug("updateUserPosition: updating investor position snapshot of investor {} for vault {} and period {}", [ @@ -352,6 +357,10 @@ function updateUserPosition( investor.cumulativeInteractionsCount += 1 if (!isTransfer && isDeposit) investor.cumulativeDepositCount += 1 if (!isTransfer && !isDeposit) investor.cumulativeWithdrawCount += 1 + dailyAvgState = DailyAvgState.deserialize(investor.averageDailyTotalPositionValueUSDState) + dailyAvgState.setPendingValue(investor.totalPositionValueUSD, event.block.timestamp) + investor.averageDailyTotalPositionValueUSD30D = DailyAvgCalc.avg(BigInt.fromI32(30), dailyAvgState) + investor.averageDailyTotalPositionValueUSDState = dailyAvgState.serialize() investor.save() for (let i = 0; i < periods.length; i++) { log.debug("updateUserPosition: updating investor snapshot for investor {} and period {}", [ diff --git a/tests/.latest.json b/tests/.latest.json deleted file mode 100644 index 46aacd7..0000000 --- a/tests/.latest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": "0.6.0", - "timestamp": 1711654541275 -} \ No newline at end of file diff --git a/tests/utils/daily-avg.test.ts b/tests/utils/daily-avg.test.ts new file mode 100644 index 0000000..ef04951 --- /dev/null +++ b/tests/utils/daily-avg.test.ts @@ -0,0 +1,138 @@ +import { assert, test, describe } from "matchstick-as/assembly/index" +import { BigDecimal, BigInt, log } from "@graphprotocol/graph-ts" +import { DailyAvgCalc, DailyAvgState } from "../../src/utils/daily-avg" +import { ZERO_BD } from "../../src/utils/decimal" +import { DAY, HOUR, WEEK } from "../../src/utils/time" + +describe("DailyAvgState", () => { + test("Can serialize and deserialize a moving avg state with no entries", () => { + const state = DailyAvgState.deserialize([]) + assert.assertTrue(state.closedValues.length === 0) + }) + + test("Can serialize and deserialize a moving avg state with zero entries", () => { + const pendingValue = BigDecimal.fromString("101.1235") + const pendingValueTimestamp = BigInt.fromI32(123) + const state = DailyAvgState.deserialize([pendingValue, pendingValueTimestamp.toBigDecimal()]) + assert.assertTrue(state.closedValues.length === 0) + assert.assertTrue(state.pendingValue.equals(pendingValue)) + assert.assertTrue(state.pendingValueTimestamp.equals(pendingValueTimestamp)) + + const serialized = state.serialize() + assert.assertTrue(serialized.length === 2) + assert.assertTrue(serialized[0].equals(pendingValue)) + assert.assertTrue(serialized[1].equals(pendingValueTimestamp.toBigDecimal())) + }) + + test("Can serialize and deserialize a moving avg state", () => { + const pendingValue = BigDecimal.fromString("101.1235") + const pendingValueTimestamp = BigInt.fromI32(123) + const value0 = BigDecimal.fromString("102") + const value1 = BigDecimal.fromString("103.235") + const state = DailyAvgState.deserialize([pendingValue, pendingValueTimestamp.toBigDecimal(), value0, value1]) + assert.assertTrue(state.closedValues.length === 2) + assert.assertTrue(state.pendingValue.equals(pendingValue)) + assert.assertTrue(state.pendingValueTimestamp.equals(pendingValueTimestamp)) + assert.assertTrue(state.closedValues[0].equals(value0)) + assert.assertTrue(state.closedValues[1].equals(value1)) + + const serialized = state.serialize() + assert.assertTrue(serialized.length === 4) + assert.assertTrue(serialized[0].equals(pendingValue)) + assert.assertTrue(serialized[1].equals(pendingValueTimestamp.toBigDecimal())) + assert.assertTrue(serialized[2].equals(value0)) + assert.assertTrue(serialized[3].equals(value1)) + }) +}) + +describe("DailyAvgCalc", () => { + test("Can create moving avg calc with no state", () => { + const state = DailyAvgState.deserialize(new Array()) + const res = DailyAvgCalc.avg(BigInt.fromU32(3), state) + assert.assertTrue(res.equals(ZERO_BD)) + }) + + test("do not crash when avg is zero now", () => { + let state = DailyAvgState.deserialize(new Array()) + + state.addValue(BigDecimal.fromString("100")) + state.addValue(BigDecimal.fromString("0")) + const res = DailyAvgCalc.avg(BigInt.fromU32(3), state) + + assert.assertTrue(res.equals(BigDecimal.fromString("50"))) + }) + + test("should evict old entries", () => { + let state = DailyAvgState.deserialize(new Array()) + + state.addValue(BigDecimal.fromString("100")) + state.addValue(BigDecimal.fromString("200")) + state.addValue(BigDecimal.fromString("300")) + state.addValue(BigDecimal.fromString("400")) + state.addValue(BigDecimal.fromString("500")) + state = DailyAvgCalc.evictOldEntries(BigInt.fromU32(3), state) + + assert.assertTrue(state.closedValues.length === 3) + assert.assertTrue(state.closedValues[0].equals(BigDecimal.fromString("300"))) + }) + + test("should compute moving avg properly with one entry", () => { + let state = DailyAvgState.deserialize(new Array()) + + state.addValue(BigDecimal.fromString("100")) + const res = DailyAvgCalc.avg(BigInt.fromU32(3), state) + log.debug("res: {}", [res.toString()]) + + assertIsCloseTo(res, BigDecimal.fromString("100"), BigDecimal.fromString("100")) + }) + + test("should compute moving avg in the simplest case", () => { + let state = DailyAvgState.deserialize(new Array()) + + // we have 2 simple values and a pending value + state.addValue(BigDecimal.fromString("10")) + state.addValue(BigDecimal.fromString("30")) + const res = DailyAvgCalc.avg(BigInt.fromU32(3), state) + log.debug("res: {}", [res.toString()]) + + assert.assertTrue(res.equals(BigDecimal.fromString("20"))) + }) + + test("should compute moving avg after eviction of old values", () => { + let state = DailyAvgState.deserialize(new Array()) + + // those values will be evicted + state.addValue(BigDecimal.fromString("100")) + state.addValue(BigDecimal.fromString("100")) + state.addValue(BigDecimal.fromString("100")) + state.addValue(BigDecimal.fromString("100")) + // will only keep the last 3 values + state.addValue(BigDecimal.fromString("20")) + state.addValue(BigDecimal.fromString("10")) + state.addValue(BigDecimal.fromString("30")) + const res = DailyAvgCalc.avg(BigInt.fromU32(3), state) + log.debug("res: {}", [res.toString()]) + + assert.assertTrue(res.equals(BigDecimal.fromString("20"))) + }) + + test("should compute moving avg with a pending value", () => { + let state = DailyAvgState.deserialize(new Array()) + + // we have 2 simple values and a pending value + state.addValue(BigDecimal.fromString("10")) + state.addValue(BigDecimal.fromString("30")) + state.setPendingValue(BigDecimal.fromString("100"), HOUR) // 1h with a big value + const res = DailyAvgCalc.avg(BigInt.fromU32(3), state) + log.debug("res: {}", [res.toString()]) + + assertIsCloseTo(res, BigDecimal.fromString("21.6326530612"), BigDecimal.fromString("0.000001")) + }) +}) + +function assertIsCloseTo(actual: BigDecimal, expected: BigDecimal, precision: BigDecimal): void { + const upperBound = expected.plus(precision) + const lowerBound = expected.minus(precision) + assert.assertTrue(actual.gt(lowerBound)) + assert.assertTrue(actual.lt(upperBound)) +}