|
| 1 | +import { Arg, Field, Float, Int, ObjectType, Query, Resolver } from 'type-graphql' |
| 2 | +import { EntityManager } from 'typeorm' |
| 3 | + |
| 4 | +const E18 = 'power(10::numeric, 18)' |
| 5 | +const E36 = 'power(10::numeric, 36)' |
| 6 | + |
| 7 | +/** |
| 8 | + * Per-address realized yield rate for a holder of an ARM vault or an OToken. |
| 9 | + * |
| 10 | + * Both rates are money-weighted: we sum the holder's actual daily yield and |
| 11 | + * divide by the sum of their daily position value (value-days), so each day is |
| 12 | + * weighted by the capital actually deployed. This is naturally robust to dust |
| 13 | + * (a position withdrawn down to a few wei contributes ~0 to both sums) — unlike |
| 14 | + * averaging a per-day `yield / value` ratio, which explodes on tiny denominators. |
| 15 | + * |
| 16 | + * d = Σ(yield) / Σ(value) -- mean daily fractional return |
| 17 | + * apr = d * 365 -- simple annualization |
| 18 | + * apy = (1 + d) ^ 365 - 1 -- compound (ARM/OToken yield auto-compounds on-chain) |
| 19 | + * |
| 20 | + * Both are returned as fractions (0.031 == 3.1%). |
| 21 | + */ |
| 22 | +@ObjectType() |
| 23 | +export class AddressApyResult { |
| 24 | + @Field(() => Float, { description: 'Simple annualized realized rate (APR), as a fraction.' }) |
| 25 | + apr!: number |
| 26 | + |
| 27 | + @Field(() => Float, { description: 'Compound annualized realized rate (APY), as a fraction.' }) |
| 28 | + apy!: number |
| 29 | + |
| 30 | + @Field(() => Int, { description: 'Number of days the address held a non-zero position (the sample size).' }) |
| 31 | + heldDays!: number |
| 32 | + |
| 33 | + constructor(props: Partial<AddressApyResult>) { |
| 34 | + Object.assign(this, props) |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +/** |
| 39 | + * Blended realized yield rate across every Origin position an address currently holds |
| 40 | + * (ARM vaults + OTokens, all chains). Each product contributes its own native |
| 41 | + * (denomination-local) APY; the products are combined as a weighted average using each |
| 42 | + * position's *current* USD value as the weight: |
| 43 | + * |
| 44 | + * portfolioApy = Σ(currentUsdValue_p × apy_p) / Σ currentUsdValue_p |
| 45 | + * |
| 46 | + * Native yield stays native — USD is only the common ruler used to weigh positions |
| 47 | + * against each other — so market price direction never inflates or deflates the rate; |
| 48 | + * it only shifts how positions are weighted right now. A position that has been fully |
| 49 | + * exited has a current value of ~0 and therefore drops out. |
| 50 | + */ |
| 51 | +@ObjectType() |
| 52 | +export class PortfolioApyResult { |
| 53 | + @Field(() => Float, { description: "USD-value-weighted blend of each position's simple APR, as a fraction." }) |
| 54 | + apr!: number |
| 55 | + |
| 56 | + @Field(() => Float, { description: "USD-value-weighted blend of each position's compound APY, as a fraction." }) |
| 57 | + apy!: number |
| 58 | + |
| 59 | + @Field(() => Int, { description: 'Number of currently-held products contributing to the blend.' }) |
| 60 | + productCount!: number |
| 61 | + |
| 62 | + @Field(() => Float, { description: 'Total current USD value of the positions included in the blend.' }) |
| 63 | + totalUsdValue!: number |
| 64 | + |
| 65 | + constructor(props: Partial<PortfolioApyResult>) { |
| 66 | + Object.assign(this, props) |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +@Resolver() |
| 71 | +export class AddressApyResolver { |
| 72 | + constructor(private tx: () => Promise<EntityManager>) {} |
| 73 | + |
| 74 | + /** |
| 75 | + * Realized APR/APY for an address's position in an ARM vault. |
| 76 | + * |
| 77 | + * @param chainId Chain ID (e.g. 1 for Ethereum, 146 for Sonic) |
| 78 | + * @param arm ARM vault address (checksummed or lowercase) |
| 79 | + * @param address Holder address (checksummed or lowercase) |
| 80 | + * |
| 81 | + * @example |
| 82 | + * { armAddressApy(chainId: 1, arm: "0x85B7...", address: "0x2199...") { apr apy heldDays } } |
| 83 | + */ |
| 84 | + @Query(() => AddressApyResult) |
| 85 | + async armAddressApy( |
| 86 | + @Arg('chainId', () => Int) chainId: number, |
| 87 | + @Arg('arm', () => String) arm: string, |
| 88 | + @Arg('address', () => String) address: string, |
| 89 | + ): Promise<AddressApyResult> { |
| 90 | + return this.computeApy('arm_address_yield', 'arm', 'value', chainId, arm, address) |
| 91 | + } |
| 92 | + |
| 93 | + /** |
| 94 | + * Realized APR/APY for an address's position in an OToken (OUSD, OETH, OS, superOETH, …). |
| 95 | + * The denominator is `balance` (OTokens are rebasing, so balance tracks position value). |
| 96 | + * Opted-out (non-rebasing) accounts earn no yield and therefore return 0. |
| 97 | + * |
| 98 | + * @param chainId Chain ID (e.g. 1 for Ethereum, 8453 for Base, 146 for Sonic) |
| 99 | + * @param otoken OToken address (checksummed or lowercase) |
| 100 | + * @param address Holder address (checksummed or lowercase) |
| 101 | + * |
| 102 | + * @example |
| 103 | + * { oTokenAddressApy(chainId: 1, otoken: "0x856c...", address: "0xa4c6...") { apr apy heldDays } } |
| 104 | + */ |
| 105 | + @Query(() => AddressApyResult) |
| 106 | + async oTokenAddressApy( |
| 107 | + @Arg('chainId', () => Int) chainId: number, |
| 108 | + @Arg('otoken', () => String) otoken: string, |
| 109 | + @Arg('address', () => String) address: string, |
| 110 | + ): Promise<AddressApyResult> { |
| 111 | + return this.computeApy('o_token_address_yield', 'otoken', 'balance', chainId, otoken, address) |
| 112 | + } |
| 113 | + |
| 114 | + /** |
| 115 | + * Blended realized APR/APY across every Origin position (ARM + OToken) an address |
| 116 | + * currently holds. Each position's native APY is weighted by its current USD value. |
| 117 | + * |
| 118 | + * @param address Holder address (checksummed or lowercase) |
| 119 | + * @param chainId Optional chain filter; omit to blend across all chains. |
| 120 | + * |
| 121 | + * @example |
| 122 | + * { addressApy(address: "0x2199...") { apr apy productCount totalUsdValue } } |
| 123 | + */ |
| 124 | + @Query(() => PortfolioApyResult) |
| 125 | + async addressApy( |
| 126 | + @Arg('address', () => String) address: string, |
| 127 | + @Arg('chainId', () => Int, { nullable: true }) chainId?: number | null, |
| 128 | + ): Promise<PortfolioApyResult> { |
| 129 | + const manager = await this.tx() |
| 130 | + const chainFilter = '($2::int IS NULL OR chain_id = $2)' |
| 131 | + const sql = ` |
| 132 | + WITH native AS ( |
| 133 | + -- per-product native all-time daily rate, over the days the position was held |
| 134 | + SELECT chain_id, product, sum_yield / sum_denom AS d_native |
| 135 | + FROM ( |
| 136 | + SELECT chain_id, arm AS product, |
| 137 | + sum("yield"::numeric) AS sum_yield, sum(value::numeric) AS sum_denom |
| 138 | + FROM arm_address_yield |
| 139 | + WHERE address = $1 AND value > 0 AND ${chainFilter} |
| 140 | + GROUP BY chain_id, arm |
| 141 | + UNION ALL |
| 142 | + SELECT chain_id, otoken, |
| 143 | + sum("yield"::numeric), sum(balance::numeric) |
| 144 | + FROM o_token_address_yield |
| 145 | + WHERE address = $1 AND balance > 0 AND ${chainFilter} |
| 146 | + GROUP BY chain_id, otoken |
| 147 | + ) s |
| 148 | + WHERE sum_denom > 0 |
| 149 | + ), |
| 150 | + weight AS ( |
| 151 | + -- current USD value per product: the latest daily row valued at that day's USD rate. |
| 152 | + -- A fully-exited position's latest value is ~0, so it contributes ~0 weight. |
| 153 | + SELECT chain_id, product, usd_value |
| 154 | + FROM ( |
| 155 | + SELECT DISTINCT ON (ay.chain_id, ay.arm) |
| 156 | + ay.chain_id, ay.arm AS product, |
| 157 | + (ay.value::numeric * ads.rate_usd / ${E18})::float8 AS usd_value |
| 158 | + FROM arm_address_yield ay |
| 159 | + JOIN arm_daily_stat ads |
| 160 | + ON ads.chain_id = ay.chain_id AND ads.address = ay.arm AND ads.date = ay.date |
| 161 | + WHERE ay.address = $1 AND ${chainFilter} |
| 162 | + ORDER BY ay.chain_id, ay.arm, ay.date DESC |
| 163 | + UNION ALL |
| 164 | + SELECT DISTINCT ON (oy.chain_id, oy.otoken) |
| 165 | + oy.chain_id, oy.otoken, |
| 166 | + (oy.balance::numeric * ods.rate_usd::numeric / ${E36})::float8 |
| 167 | + FROM o_token_address_yield oy |
| 168 | + JOIN o_token_daily_stat ods |
| 169 | + ON ods.chain_id = oy.chain_id AND ods.otoken = oy.otoken AND ods.date = oy.date |
| 170 | + WHERE oy.address = $1 AND ${chainFilter} |
| 171 | + ORDER BY oy.chain_id, oy.otoken, oy.date DESC |
| 172 | + ) w |
| 173 | + ) |
| 174 | + SELECT |
| 175 | + coalesce( |
| 176 | + sum(weight.usd_value * (power(1 + native.d_native::float8, 365) - 1)) |
| 177 | + / NULLIF(sum(weight.usd_value), 0), 0) AS apy, |
| 178 | + coalesce( |
| 179 | + sum(weight.usd_value * (native.d_native::float8 * 365)) |
| 180 | + / NULLIF(sum(weight.usd_value), 0), 0) AS apr, |
| 181 | + count(*) FILTER (WHERE weight.usd_value > 0) AS product_count, |
| 182 | + coalesce(sum(weight.usd_value) FILTER (WHERE weight.usd_value > 0), 0) AS total_usd_value |
| 183 | + FROM native |
| 184 | + JOIN weight ON weight.chain_id = native.chain_id AND weight.product = native.product |
| 185 | + ` |
| 186 | + const rows: Array<{ |
| 187 | + apr: number | null |
| 188 | + apy: number | null |
| 189 | + product_count: string | number | null |
| 190 | + total_usd_value: number | null |
| 191 | + }> = await manager.query(sql, [address.toLowerCase(), chainId ?? null]) |
| 192 | + const row = rows?.[0] |
| 193 | + return new PortfolioApyResult({ |
| 194 | + apr: row?.apr != null ? Number(row.apr) : 0, |
| 195 | + apy: row?.apy != null ? Number(row.apy) : 0, |
| 196 | + productCount: row?.product_count != null ? Number(row.product_count) : 0, |
| 197 | + totalUsdValue: row?.total_usd_value != null ? Number(row.total_usd_value) : 0, |
| 198 | + }) |
| 199 | + } |
| 200 | + |
| 201 | + /** |
| 202 | + * Money-weighted realized rate over a holder's full daily history. |
| 203 | + * |
| 204 | + * `table`, `filterColumn` and `denomColumn` are internal constants (never user |
| 205 | + * input), so interpolating them is safe; all caller-supplied values are bound |
| 206 | + * as query parameters. |
| 207 | + */ |
| 208 | + private async computeApy( |
| 209 | + table: 'arm_address_yield' | 'o_token_address_yield', |
| 210 | + filterColumn: 'arm' | 'otoken', |
| 211 | + denomColumn: 'value' | 'balance', |
| 212 | + chainId: number, |
| 213 | + filterValue: string, |
| 214 | + address: string, |
| 215 | + ): Promise<AddressApyResult> { |
| 216 | + const manager = await this.tx() |
| 217 | + const sql = ` |
| 218 | + WITH agg AS ( |
| 219 | + SELECT |
| 220 | + count(*) AS held_days, |
| 221 | + sum("yield"::numeric) AS sum_yield, |
| 222 | + sum(${denomColumn}::numeric) AS sum_denom |
| 223 | + FROM ${table} |
| 224 | + WHERE chain_id = $1 AND ${filterColumn} = lower($2) AND address = lower($3) |
| 225 | + AND ${denomColumn} > 0 |
| 226 | + ) |
| 227 | + SELECT |
| 228 | + held_days, |
| 229 | + CASE WHEN sum_denom IS NULL OR sum_denom = 0 THEN 0 |
| 230 | + ELSE (sum_yield / sum_denom)::float8 * 365 END AS apr, |
| 231 | + CASE WHEN sum_denom IS NULL OR sum_denom = 0 THEN 0 |
| 232 | + ELSE power(1 + (sum_yield / sum_denom)::float8, 365) - 1 END AS apy |
| 233 | + FROM agg |
| 234 | + ` |
| 235 | + const rows: Array<{ held_days: string | number | null; apr: number | null; apy: number | null }> = |
| 236 | + await manager.query(sql, [chainId, filterValue.toLowerCase(), address.toLowerCase()]) |
| 237 | + const row = rows?.[0] |
| 238 | + return new AddressApyResult({ |
| 239 | + apr: row?.apr != null ? Number(row.apr) : 0, |
| 240 | + apy: row?.apy != null ? Number(row.apy) : 0, |
| 241 | + heldDays: row?.held_days != null ? Number(row.held_days) : 0, |
| 242 | + }) |
| 243 | + } |
| 244 | +} |
0 commit comments