@@ -4,6 +4,44 @@ import { EntityManager } from 'typeorm'
44const E18 = 'power(10::numeric, 18)'
55const E36 = 'power(10::numeric, 36)'
66
7+ /**
8+ * Cap on a single day's fractional return, used to discard corrupt per-day yield rows.
9+ *
10+ * Exit-day / cost-basis-reset checkpoints in the per-address yield series can book a
11+ * `yield` that is large relative to the (collapsed, near-zero) denominator that produced
12+ * it, giving per-day returns from ~0.4%/day up to 1e9+/day. Money-weighting does NOT
13+ * dilute these — Σyield stays large while Σdenom collapses — so the aggregate daily rate
14+ * `d` inflates: severe spikes push d past ~6, where power(1 + d, 365) overflows float8
15+ * (error 22003) and 500s the whole query; milder ones (~0.4–1%/day) yield absurd-but-
16+ * finite rates (150%–6000% APY).
17+ *
18+ * Empirically, legitimate per-day |yield|/denom tops out around 7e-4/day (0.07%) across
19+ * every product (ARM/OToken/wOToken/xOGN, p99 ≤ 3e-4), and the artifact band starts near
20+ * 2e-3/day (0.2%) with an empty gap between. 0.2%/day (a ~73% simple-APR-equivalent
21+ * single day — far above anything these products actually pay) sits in that gap: it drops
22+ * every artifact while leaving real days untouched, so healthy APY/APR is unchanged. The
23+ * filter is symmetric (|yield|) so negative artifacts are caught too, and it bounds
24+ * |d| ≤ MAX_DAILY_RATE, keeping the annualization finite.
25+ */
26+ const MAX_DAILY_RATE = 0.002
27+ /** `WHERE`-clause fragment dropping corrupt (large-|yield|-vs-dust-denom) days. */
28+ const dailyRateFilter = ( yieldCol : string , denomCol : string ) =>
29+ `abs(${ yieldCol } ::numeric) <= ${ denomCol } ::numeric * ${ MAX_DAILY_RATE } `
30+
31+ /**
32+ * Minimum current USD value for a position to count as "held" in the portfolio blend.
33+ *
34+ * The portfolio APY is meant to describe the holder's *current* positions. A withdrawal
35+ * leaves behind share/rounding dust (a few wei), and because a weighted average of a
36+ * single position equals that position's rate regardless of how small its absolute weight
37+ * is, a holder whose only remaining position is dust would otherwise be assigned that
38+ * (now meaningless) position's APY. Excluding positions below one cent drops fully-exited
39+ * holdings entirely — a dust-only holder correctly reports productCount 0 / apy 0 — and
40+ * matches the frontend already hiding dust from the Yield Positions list. Any genuine
41+ * position is worth far more than a cent, so real holdings are never excluded.
42+ */
43+ const DUST_USD = 0.01
44+
745/**
846 * Per-address realized yield rate for a holder of an ARM vault or an OToken.
947 *
@@ -174,25 +212,32 @@ export class AddressApyResolver {
174212 const chainFilter = ( col : string ) => `($2::int IS NULL OR ${ col } = $2)`
175213 const sql = `
176214 WITH native AS (
177- -- per-product native all-time daily rate, over the days the position was held
178- SELECT chain_id, product, sum_yield / sum_denom AS d_native
215+ -- per-product native all-time daily rate, over the days the position was held.
216+ -- d_native is clamped to ±MAX_DAILY_RATE as a defensive backstop; the per-day
217+ -- filter (${ MAX_DAILY_RATE } /day) below already bounds it there by dropping corrupt
218+ -- exit-day rows, so the clamp only bites if a future change removes the filter.
219+ SELECT chain_id, product,
220+ least(greatest((sum_yield / sum_denom)::float8, -${ MAX_DAILY_RATE } ), ${ MAX_DAILY_RATE } ) AS d_native
179221 FROM (
180222 SELECT chain_id, arm AS product,
181223 sum("yield"::numeric) AS sum_yield, sum(value::numeric) AS sum_denom
182224 FROM arm_address_yield
183225 WHERE address = $1 AND value > 0 AND ${ chainFilter ( 'chain_id' ) }
226+ AND ${ dailyRateFilter ( '"yield"' , 'value' ) }
184227 GROUP BY chain_id, arm
185228 UNION ALL
186229 SELECT chain_id, otoken,
187230 sum("yield"::numeric), sum(balance::numeric)
188231 FROM o_token_address_yield
189232 WHERE address = $1 AND balance > 0 AND ${ chainFilter ( 'chain_id' ) }
233+ AND ${ dailyRateFilter ( '"yield"' , 'balance' ) }
190234 GROUP BY chain_id, otoken
191235 UNION ALL
192236 SELECT chain_id, wotoken,
193237 sum("yield"::numeric), sum(value::numeric)
194238 FROM wo_token_address_yield
195239 WHERE address = $1 AND value > 0 AND ${ chainFilter ( 'chain_id' ) }
240+ AND ${ dailyRateFilter ( '"yield"' , 'value' ) }
196241 GROUP BY chain_id, wotoken
197242 UNION ALL
198243 -- xOGN staking: holder is the account column, product is the staking-contract address; the
@@ -201,6 +246,7 @@ export class AddressApyResolver {
201246 sum("yield"::numeric), sum(staked_balance::numeric)
202247 FROM es_address_yield
203248 WHERE account = $1 AND staked_balance > 0 AND ${ chainFilter ( 'chain_id' ) }
249+ AND ${ dailyRateFilter ( '"yield"' , 'staked_balance' ) }
204250 GROUP BY chain_id, address
205251 ) s
206252 WHERE sum_denom > 0
@@ -257,18 +303,33 @@ export class AddressApyResolver {
257303 ORDER BY ey.chain_id, ey.address, ey.date DESC
258304 )
259305 ) w
306+ ),
307+ blend AS (
308+ -- One row: the USD-weighted daily rate across the holder's current positions.
309+ -- The dust floor (>= ${ DUST_USD } ) drops fully-exited positions so their leftover
310+ -- wei can't define the blend; a holder with no position above the floor yields no
311+ -- row here, and the final SELECT then returns 0 / 0.
312+ SELECT
313+ sum(weight.usd_value * native.d_native) AS sum_wd,
314+ sum(weight.usd_value) AS sum_w,
315+ count(*) AS product_count,
316+ sum(weight.usd_value) AS total_usd_value
317+ FROM native
318+ JOIN weight ON weight.chain_id = native.chain_id AND weight.product = native.product
319+ WHERE weight.usd_value >= ${ DUST_USD }
260320 )
261321 SELECT
262- coalesce(
263- sum(weight.usd_value * (power(1 + native.d_native::float8, 365) - 1))
264- / NULLIF(sum(weight.usd_value), 0), 0) AS apy,
265- coalesce(
266- sum(weight.usd_value * (native.d_native::float8 * 365))
267- / NULLIF(sum(weight.usd_value), 0), 0) AS apr,
268- count(*) FILTER (WHERE weight.usd_value > 0) AS product_count,
269- coalesce(sum(weight.usd_value) FILTER (WHERE weight.usd_value > 0), 0) AS total_usd_value
270- FROM native
271- JOIN weight ON weight.chain_id = native.chain_id AND weight.product = native.product
322+ -- Blend the native daily rates by current USD weight, THEN annualize once. This
323+ -- compounds the *blended* daily rate rather than averaging each product's already-
324+ -- compounded APY (which overstates the blend when the per-product rates diverge).
325+ -- d is bounded to ±MAX_DAILY_RATE upstream, so power(1 + d, 365) cannot overflow.
326+ CASE WHEN sum_w IS NULL OR sum_w = 0 THEN 0
327+ ELSE power(1 + (sum_wd / sum_w), 365) - 1 END AS apy,
328+ CASE WHEN sum_w IS NULL OR sum_w = 0 THEN 0
329+ ELSE (sum_wd / sum_w) * 365 END AS apr,
330+ coalesce(product_count, 0) AS product_count,
331+ coalesce(total_usd_value, 0) AS total_usd_value
332+ FROM blend
272333 `
273334 const rows : Array < {
274335 apr : number | null
@@ -313,13 +374,18 @@ export class AddressApyResolver {
313374 FROM ${ table }
314375 WHERE chain_id = $1 AND ${ filterColumn } = lower($2) AND ${ holderColumn } = lower($3)
315376 AND ${ denomColumn } > 0
377+ AND ${ dailyRateFilter ( '"yield"' , denomColumn ) }
316378 )
317379 SELECT
318380 held_days,
381+ -- The daily-rate filter bounds |d| ≤ MAX_DAILY_RATE; LEAST/GREATEST is a
382+ -- defensive backstop so power(1 + d, 365) can never overflow float8.
319383 CASE WHEN sum_denom IS NULL OR sum_denom = 0 THEN 0
320- ELSE (sum_yield / sum_denom)::float8 * 365 END AS apr,
384+ ELSE least(greatest((sum_yield / sum_denom)::float8, -${ MAX_DAILY_RATE } ), ${ MAX_DAILY_RATE } ) * 365 END
385+ AS apr,
321386 CASE WHEN sum_denom IS NULL OR sum_denom = 0 THEN 0
322- ELSE power(1 + (sum_yield / sum_denom)::float8, 365) - 1 END AS apy
387+ ELSE power(1 + least(greatest((sum_yield / sum_denom)::float8, -${ MAX_DAILY_RATE } ), ${ MAX_DAILY_RATE } ), 365) - 1 END
388+ AS apy
323389 FROM agg
324390 `
325391 const rows : Array < { held_days : string | number | null ; apr : number | null ; apy : number | null } > =
0 commit comments