Skip to content

Commit 99170af

Browse files
committed
feat(server-extension): add per-address and portfolio realized APY resolvers
Adds an AddressApyResolver exposing three GraphQL queries computed from the existing per-address yield tables (no schema/processor/reindex changes): - armAddressApy(chainId, arm, address) -> { apr, apy, heldDays } - oTokenAddressApy(chainId, otoken, addr) -> { apr, apy, heldDays } - addressApy(address, chainId?) -> { apr, apy, productCount, totalUsdValue } Per-product rates are money-weighted (Σyield / Σvalue over held days), which is robust to dust denominators and cost-basis resets. apr is simple (d*365); apy is compound ((1+d)^365-1), matching on-chain auto-compounding. Rates are native (denomination-local) so market price moves don't distort them. The portfolio query blends each currently-held position's native APY weighted by its current USD value (via each product's own daily-stat rate_usd), so USD is only a ruler for sizing positions, never injected into the yield itself. Exited positions have ~0 current value and drop out.
1 parent 9c2bf30 commit 99170af

2 files changed

Lines changed: 245 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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+
}

src/server-extension/resolvers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'reflect-metadata'
22
import 'tsconfig-paths/register'
33

4+
export { AddressApyResolver } from '../address-apy'
45
export { ERC20Resolver } from '../erc20'
56
export { MorphoVaultApyResolver } from '../morpho'
67
export { OGNStatsResolver } from '../ogn-stats'

0 commit comments

Comments
 (0)