From 45b62a6f1c2714ca0e4fb2828e101afc4e419653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pr=C3=A9vost?= <998369+prevostc@users.noreply.github.com> Date: Sat, 23 Mar 2024 13:16:20 +0100 Subject: [PATCH] Add PnL util --- package.json | 4 +- src/mapping/price.ts | 43 ++----- src/utils/decimal.ts | 6 + src/utils/pnl.ts | 129 ++++++++++++++++++++ tests/.latest.json | 4 +- tests/utils/decimals.test.ts | 11 +- tests/utils/pnl.test.ts | 230 +++++++++++++++++++++++++++++++++++ 7 files changed, 388 insertions(+), 39 deletions(-) create mode 100644 src/utils/pnl.ts create mode 100644 tests/utils/pnl.test.ts diff --git a/package.json b/package.json index ad4e215..1bb309e 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "codegen": "rm -Rf generated && graph codegen", "build": "graph build", "format": "prettier . --write", - "test": "yarn run --silent test:lint && yarn run --silent test:graph", - "test:graph": "graph test", + "test": "yarn run --silent test:lint && yarn run --silent test:unit", + "test:unit": "graph test", "test:lint": "prettier . --check", "infra:start": "docker compose up -d", "infra:stop": "docker compose down", diff --git a/src/mapping/price.ts b/src/mapping/price.ts index a023ab1..18ab89c 100644 --- a/src/mapping/price.ts +++ b/src/mapping/price.ts @@ -112,41 +112,16 @@ export function getNativePriceUSD(): BigDecimal { } class PriceRange { - _min: BigDecimal - _max: BigDecimal - constructor(min: BigDecimal, max: BigDecimal) { - this._min = min - this._max = max - } - - get min(): BigDecimal { - return this._min - } - - get max(): BigDecimal { - return this._max - } + constructor( + public min: BigDecimal, + public max: BigDecimal, + ) {} } class VaultPrices { - _token0ToNative: BigDecimal - _token1ToNative: BigDecimal - _nativeToUsd: BigDecimal - constructor(_token0ToNative: BigDecimal, _token1ToNative: BigDecimal, _nativeToUsd: BigDecimal) { - this._token0ToNative = _token0ToNative - this._token1ToNative = _token1ToNative - this._nativeToUsd = _nativeToUsd - } - - get token0ToNative(): BigDecimal { - return this._token0ToNative - } - - get token1ToNative(): BigDecimal { - return this._token1ToNative - } - - get nativeToUsd(): BigDecimal { - return this._nativeToUsd - } + constructor( + public token0ToNative: BigDecimal, + public token1ToNative: BigDecimal, + public nativeToUsd: BigDecimal, + ) {} } diff --git a/src/utils/decimal.ts b/src/utils/decimal.ts index da1ff7f..809b75f 100644 --- a/src/utils/decimal.ts +++ b/src/utils/decimal.ts @@ -97,3 +97,9 @@ function safeDiv(amount0: BigDecimal, amount1: BigDecimal): BigDecimal { return amount0.div(amount1) } } + + +@inline +export function bigMin(a: BigDecimal, b: BigDecimal): BigDecimal { + return a.lt(b) ? a : b +} diff --git a/src/utils/pnl.ts b/src/utils/pnl.ts new file mode 100644 index 0000000..89f83b8 --- /dev/null +++ b/src/utils/pnl.ts @@ -0,0 +1,129 @@ +import { BigDecimal } from "@graphprotocol/graph-ts" +import { ZERO_BD, bigMin } from "./decimal" + +class PnLStateEntry { + constructor( + public boughtShares: BigDecimal, + public remainingShares: BigDecimal, + public entryPrice: BigDecimal, + ) {} +} + +export class PnLState { + constructor( + public realizedPnl: BigDecimal, + public sharesFifo: Array, + ) {} + + serialize(): Array { + let res = new Array() + res.push(this.realizedPnl) + for (let idx = 0; idx < this.sharesFifo.length; idx++) { + let entry = this.sharesFifo[idx] + res.push(entry.boughtShares) + res.push(entry.remainingShares) + res.push(entry.entryPrice) + } + + return res + } + + static deserialize(data: Array): PnLState { + let realizedPnl = data.shift() as BigDecimal + let sharesFifo = new Array() + while (data.length > 0) { + let boughtShares = data.shift() as BigDecimal + let remainingShares = data.shift() as BigDecimal + let entryPrice = data.shift() as BigDecimal + sharesFifo.push(new PnLStateEntry(boughtShares, remainingShares, entryPrice)) + } + + return new PnLState(realizedPnl, sharesFifo) + } +} + +// this one is a FIFO pnl calculator: +// https://money.stackexchange.com/a/144091 +export class PnLCalc { + public static from(state: Array): PnLCalc { + const pnlState = + state.length === 0 ? new PnLState(ZERO_BD, new Array()) : PnLState.deserialize(state) + return new PnLCalc(pnlState) + } + + private constructor(private state: PnLState) {} + + public addTransaction(trxShares: BigDecimal, trxPrice: BigDecimal): void { + if (trxShares.equals(ZERO_BD)) { + return + } + + if (trxShares.gt(ZERO_BD)) { + this.state.sharesFifo.push(new PnLStateEntry(trxShares, trxShares, trxPrice)) + return + } + + let remainingSharesToSell = trxShares.neg() + let trxPnl = ZERO_BD + for (let idx = 0; idx < this.state.sharesFifo.length; idx++) { + let entry = this.state.sharesFifo[idx] + if (entry.remainingShares.equals(ZERO_BD)) { + continue + } + + const sharesToSell = bigMin(remainingSharesToSell, entry.remainingShares) + const priceDiff = trxPrice.minus(entry.entryPrice) + trxPnl = trxPnl.plus(sharesToSell.times(priceDiff)) + remainingSharesToSell = remainingSharesToSell.minus(sharesToSell) + entry.remainingShares = entry.remainingShares.minus(sharesToSell) + + if (remainingSharesToSell.equals(ZERO_BD)) { + break + } + } + + this.state.realizedPnl = this.state.realizedPnl.plus(trxPnl) + return + } + + public getUnrealizedPnl(currentPrice: BigDecimal): BigDecimal { + let unrealizedPnl = ZERO_BD + for (let idx = 0; idx < this.state.sharesFifo.length; idx++) { + let entry = this.state.sharesFifo[idx] + if (entry.remainingShares.equals(ZERO_BD)) { + continue + } + + const priceDiff = currentPrice.minus(entry.entryPrice) + unrealizedPnl = unrealizedPnl.plus(entry.remainingShares.times(priceDiff)) + } + return unrealizedPnl + } + + public getRealizedPnl(): BigDecimal { + return this.state.realizedPnl + } + + public getRemainingShares(): BigDecimal { + let remainingShares = ZERO_BD + for (let idx = 0; idx < this.state.sharesFifo.length; idx++) { + let trx = this.state.sharesFifo[idx] + remainingShares = remainingShares.plus(trx.remainingShares) + } + return remainingShares + } + + public getRemainingSharesAvgEntryPrice(): BigDecimal { + let totalShares = ZERO_BD + let totalCost = ZERO_BD + for (let idx = 0; idx < this.state.sharesFifo.length; idx++) { + let entry = this.state.sharesFifo[idx] + totalShares = totalShares.plus(entry.remainingShares) + totalCost = totalCost.plus(entry.remainingShares.times(entry.entryPrice)) + } + if (totalShares.equals(ZERO_BD)) { + return ZERO_BD + } + return totalCost.div(totalShares) + } +} diff --git a/tests/.latest.json b/tests/.latest.json index 582bb09..fb3e43f 100644 --- a/tests/.latest.json +++ b/tests/.latest.json @@ -1,4 +1,4 @@ { "version": "0.6.0", - "timestamp": 1709327586745 -} + "timestamp": 1711192456694 +} \ No newline at end of file diff --git a/tests/utils/decimals.test.ts b/tests/utils/decimals.test.ts index 7e5e6a6..e67d757 100644 --- a/tests/utils/decimals.test.ts +++ b/tests/utils/decimals.test.ts @@ -1,6 +1,6 @@ import { assert, clearStore, test, describe, afterAll } from "matchstick-as/assembly/index" import { BigDecimal, BigInt, log } from "@graphprotocol/graph-ts" -import { decimalToTokenAmount, exponentToBigDecimal, tokenAmountToDecimal } from "../../src/utils/decimal" +import { bigMin, decimalToTokenAmount, exponentToBigDecimal, tokenAmountToDecimal } from "../../src/utils/decimal" describe("decimals.tokenAmountToDecimal", () => { afterAll(() => { @@ -109,3 +109,12 @@ describe("decimals.exponentToBigInt", () => { assert.stringEquals(res.toString(), "1", "Decimal value should match") }) }) + +describe("decimals.bigMin", () => { + test("Can return the minimum of two big decimals", () => { + const a = BigDecimal.fromString("1") + const b = BigDecimal.fromString("2") + const res = bigMin(a, b) + assert.stringEquals(res.toString(), "1", "Minimum value should match") + }) +}) diff --git a/tests/utils/pnl.test.ts b/tests/utils/pnl.test.ts new file mode 100644 index 0000000..76ea2f1 --- /dev/null +++ b/tests/utils/pnl.test.ts @@ -0,0 +1,230 @@ +import { assert, test, describe } from "matchstick-as/assembly/index" +import { BigDecimal } from "@graphprotocol/graph-ts" +import { PnLCalc, PnLState } from "../../src/utils/pnl" +import { ZERO_BD } from "../../src/utils/decimal" + +describe("PnLState", () => { + test("Can serialize and deserialize pnl state with no entries", () => { + const realizedPnl = BigDecimal.fromString("100") + const state = PnLState.deserialize([realizedPnl]) + assert.assertTrue(state.realizedPnl.equals(realizedPnl)) + }) + + test("Can serialize and deserialize pnl state with one entry", () => { + const realizedPnl = BigDecimal.fromString("100") + const boughtShares = BigDecimal.fromString("10") + const remainingShares = BigDecimal.fromString("5") + const entryPrice = BigDecimal.fromString("20") + const state = PnLState.deserialize([realizedPnl, boughtShares, remainingShares, entryPrice]) + assert.assertTrue(state.realizedPnl.equals(realizedPnl)) + assert.assertTrue(state.sharesFifo.length === 1) + assert.assertTrue(state.sharesFifo[0].boughtShares.equals(boughtShares)) + assert.assertTrue(state.sharesFifo[0].remainingShares.equals(remainingShares)) + assert.assertTrue(state.sharesFifo[0].entryPrice.equals(entryPrice)) + + const serialized = state.serialize() + assert.assertTrue(serialized.length === 4) + assert.assertTrue(serialized[0].equals(realizedPnl)) + assert.assertTrue(serialized[1].equals(boughtShares)) + assert.assertTrue(serialized[2].equals(remainingShares)) + assert.assertTrue(serialized[3].equals(entryPrice)) + }) + + test("Can serialize and deserialize pnl state with multiple entries", () => { + const realizedPnl = BigDecimal.fromString("100") + const boughtShares1 = BigDecimal.fromString("10") + const remainingShares1 = BigDecimal.fromString("5") + const entryPrice1 = BigDecimal.fromString("20") + const boughtShares2 = BigDecimal.fromString("20") + const remainingShares2 = BigDecimal.fromString("10") + const entryPrice2 = BigDecimal.fromString("30") + const state = PnLState.deserialize([ + realizedPnl, + boughtShares1, + remainingShares1, + entryPrice1, + boughtShares2, + remainingShares2, + entryPrice2, + ]) + assert.assertTrue(state.realizedPnl.equals(realizedPnl)) + assert.assertTrue(state.sharesFifo.length === 2) + assert.assertTrue(state.sharesFifo[0].boughtShares.equals(boughtShares1)) + assert.assertTrue(state.sharesFifo[0].remainingShares.equals(remainingShares1)) + assert.assertTrue(state.sharesFifo[0].entryPrice.equals(entryPrice1)) + assert.assertTrue(state.sharesFifo[1].boughtShares.equals(boughtShares2)) + assert.assertTrue(state.sharesFifo[1].remainingShares.equals(remainingShares2)) + assert.assertTrue(state.sharesFifo[1].entryPrice.equals(entryPrice2)) + + const serialized = state.serialize() + assert.assertTrue(serialized.length === 7) + assert.assertTrue(serialized[0].equals(realizedPnl)) + assert.assertTrue(serialized[1].equals(boughtShares1)) + assert.assertTrue(serialized[2].equals(remainingShares1)) + assert.assertTrue(serialized[3].equals(entryPrice1)) + assert.assertTrue(serialized[4].equals(boughtShares2)) + assert.assertTrue(serialized[5].equals(remainingShares2)) + assert.assertTrue(serialized[6].equals(entryPrice2)) + }) +}) + +describe("PnLCalc", () => { + test("should compute PnL properly", () => { + let pnl = PnLCalc.from(new Array()) + let currentPrice = BigDecimal.fromString("25") + assert.assertTrue(pnl.getRealizedPnl().equals(ZERO_BD)) + assert.assertTrue(pnl.getUnrealizedPnl(currentPrice).equals(ZERO_BD)) + + let trxShares = BigDecimal.fromString("2") + let trxPrice = BigDecimal.fromString("10") + pnl.addTransaction(trxShares, trxPrice) + assert.assertTrue(pnl.getRealizedPnl().equals(ZERO_BD)) + assert.assertTrue(pnl.getUnrealizedPnl(trxPrice).equals(ZERO_BD)) + assert.assertTrue(pnl.getUnrealizedPnl(BigDecimal.fromString("12")).equals(BigDecimal.fromString("4"))) + + trxShares = BigDecimal.fromString("2") + trxPrice = BigDecimal.fromString("15") + pnl.addTransaction(trxShares, trxPrice) + assert.assertTrue(pnl.getRealizedPnl().equals(ZERO_BD)) + assert.assertTrue(pnl.getUnrealizedPnl(trxPrice).equals(BigDecimal.fromString("10"))) + assert.assertTrue(pnl.getUnrealizedPnl(BigDecimal.fromString("17")).equals(BigDecimal.fromString("18"))) + + trxShares = BigDecimal.fromString("-3") + trxPrice = BigDecimal.fromString("20") + pnl.addTransaction(trxShares, trxPrice) + assert.assertTrue(pnl.getRealizedPnl().equals(BigDecimal.fromString("25"))) + assert.assertTrue(pnl.getUnrealizedPnl(trxPrice).equals(BigDecimal.fromString("5"))) + }) + + test("should compute PnL properly from a real world use case", () => { + const timeline = [ + new TimelineEntry( + "2022-11-17T14:48:03.000Z", + BigDecimal.fromString("1.0072088101378458"), + BigDecimal.fromString("314.89874339460806"), + BigDecimal.fromString("20.27876253618866"), + BigDecimal.fromString("20.4249482851425"), + BigDecimal.fromString("6431.790548891228"), + BigDecimal.fromString("20.27876253618866"), + BigDecimal.fromString("20.4249482851425"), + BigDecimal.fromString("6431.790548891228"), + ), + new TimelineEntry( + "2022-11-20T19:31:20.000Z", + BigDecimal.fromString("1.0075970905895832"), + BigDecimal.fromString("347.50914177774484"), + BigDecimal.fromString("20.891856301143548"), + BigDecimal.fromString("21.05057362604789"), + BigDecimal.fromString("7315.266774717132"), + BigDecimal.fromString("0.6130937649548895"), + BigDecimal.fromString("0.6177514938271605"), + BigDecimal.fromString("214.6742914517964"), + ), + + new TimelineEntry( + "2022-11-23T21:36:19.000Z", + BigDecimal.fromString("1.0079955733953208"), + BigDecimal.fromString("338.9687613836563"), + BigDecimal.fromString("21.873125794444825"), + BigDecimal.fromString("22.04801397711939"), + BigDecimal.fromString("7473.587988793701"), + BigDecimal.fromString("0.9812694933012761"), + BigDecimal.fromString("0.9891153055555556"), + BigDecimal.fromString("335.2791899897834"), + ), + + new TimelineEntry( + "2022-12-02T13:42:28.000Z", + BigDecimal.fromString("1.0091310138565124"), + BigDecimal.fromString("357.83013067482005"), + BigDecimal.fromString("23.214705725876705"), + BigDecimal.fromString("23.426679525534542"), + BigDecimal.fromString("8382.771795899156"), + BigDecimal.fromString("1.3415799314318817"), + BigDecimal.fromString("1.3538299163754048"), + BigDecimal.fromString("484.4411358880918"), + ), + + new TimelineEntry( + "2022-12-15T21:56:20.000Z", + BigDecimal.fromString("1.0109650927669185"), + BigDecimal.fromString("363.0589258226089"), + BigDecimal.fromString("0"), + BigDecimal.fromString("0"), + BigDecimal.fromString("0"), + BigDecimal.fromString("-23.214705725876705"), + BigDecimal.fromString("-23.469257127717658"), + BigDecimal.fromString("-8520.72328264378"), + ), + + new TimelineEntry( + "2023-02-02T19:03:44.000Z", + BigDecimal.fromString("1.0181657075053454"), + BigDecimal.fromString("489.8806056476361"), + BigDecimal.fromString("29.516921045823203"), + BigDecimal.fromString("30.0531168"), + BigDecimal.fromString("14722.439059583148"), + BigDecimal.fromString("29.516921045823203"), + BigDecimal.fromString("30.0531168"), + BigDecimal.fromString("14722.439059583148"), + ), + ] + + const currentShareToUnderlyingPrice = BigDecimal.fromString("1.021708071749255723") + const currentUnderlyingToUsdPrice = BigDecimal.fromString("477.57009640452765") + + const yieldPnL = PnLCalc.from(new Array()) + const usdPnL = PnLCalc.from(new Array()) + + for (let idx = 0; idx < timeline.length; idx++) { + const row = timeline[idx] + usdPnL.addTransaction(row.share_diff, row.share_to_underlying_price.times(row.underlying_to_usd_price)) + } + + for (let idx = 0; idx < timeline.length; idx++) { + const row = timeline[idx] + yieldPnL.addTransaction(row.share_diff, row.share_to_underlying_price) + } + + const currentUsdPrice = currentShareToUnderlyingPrice.times(currentUnderlyingToUsdPrice) + const currentYieldPrice = currentShareToUnderlyingPrice + + // ensures yield pnl + const precision = BigDecimal.fromString("0.00000001") + assertIsCloseTo(yieldPnL.getRemainingShares(), BigDecimal.fromString("29.516921045823203"), precision) + assertIsCloseTo(yieldPnL.getRemainingSharesAvgEntryPrice(), BigDecimal.fromString("1.0181657075053454"), precision) + assertIsCloseTo(yieldPnL.getRealizedPnl(), BigDecimal.fromString("0.08361212681703376"), precision) + assertIsCloseTo( + yieldPnL.getUnrealizedPnl(currentYieldPrice), + BigDecimal.fromString("0.10455968570304802"), + precision, + ) + + // ensures usd pnl + assertIsCloseTo(usdPnL.getRemainingShares(), BigDecimal.fromString("29.516921045823203"), precision) + assertIsCloseTo(usdPnL.getRemainingSharesAvgEntryPrice(), BigDecimal.fromString("498.7796334423725"), precision) + assertIsCloseTo(usdPnL.getRealizedPnl(), BigDecimal.fromString("1054.5381164228797"), precision) + assertIsCloseTo(usdPnL.getUnrealizedPnl(currentUsdPrice), BigDecimal.fromString("-320.0345929693857"), precision) + }) +}) + +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)) +} + +class TimelineEntry { + constructor( + public datetime: string, + public share_to_underlying_price: BigDecimal, + public underlying_to_usd_price: BigDecimal, + public share_balance: BigDecimal, + public underlying_balance: BigDecimal, + public usd_balance: BigDecimal, + public share_diff: BigDecimal, + public underlying_diff: BigDecimal, + public usd_diff: BigDecimal, + ) {} +}