Skip to content

Commit 6fa0579

Browse files
authored
Ondo price smoothing (#4465)
1 parent cf19d57 commit 6fa0579

File tree

14 files changed

+5555
-20
lines changed

14 files changed

+5555
-20
lines changed

.changeset/chilled-weeks-punch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@chainlink/ondo-calculated-adapter': minor
3+
---
4+
5+
Price smoothing

packages/composites/ondo-calculated/src/endpoint/price.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BaseEndpointTypes as DataEngineResponse } from '@chainlink/data-engine-adapter/src/endpoint/deutscheBoerseV11'
22
import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter'
33
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
4+
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
45
import { config } from '../config'
56
import { priceTransport } from '../transport/transport'
67

@@ -31,6 +32,18 @@ export const inputParameters = new InputParameters(
3132
type: 'string',
3233
description: 'Data Streams overnight hour feed ID for the underlying asset',
3334
},
35+
sessionBoundaries: {
36+
required: true,
37+
type: 'string',
38+
array: true,
39+
description:
40+
'A list of time where market trasition from 1 session to the next in the format of HH:MM',
41+
},
42+
sessionBoundariesTimeZone: {
43+
required: true,
44+
type: 'string',
45+
description: 'ANA Time Zone Database format',
46+
},
3447
decimals: {
3548
type: 'number',
3649
description: 'Decimals of output result',
@@ -44,6 +57,8 @@ export const inputParameters = new InputParameters(
4457
regularStreamId: '0x0',
4558
extendedStreamId: '0x0',
4659
overnightStreamId: '0x0',
60+
sessionBoundaries: ['04:00', '16:00', '20:00'],
61+
sessionBoundariesTimeZone: 'America/New_York',
4762
decimals: 8,
4863
},
4964
],
@@ -55,6 +70,7 @@ export type BaseEndpointTypes = {
5570
Result: string
5671
Data: {
5772
result: string
73+
rawPrice: string
5874
decimals: number
5975
registry: {
6076
sValue: string
@@ -65,6 +81,12 @@ export type BaseEndpointTypes = {
6581
extended: DataEngineResponse['Response']['Data']
6682
overnight: DataEngineResponse['Response']['Data']
6783
}
84+
smoother: {
85+
price: string
86+
x: string
87+
p: string
88+
secondsFromTransition: number
89+
}
6890
}
6991
}
7092
Settings: typeof config.settings
@@ -75,4 +97,27 @@ export const endpoint = new AdapterEndpoint({
7597
aliases: [],
7698
transport: priceTransport,
7799
inputParameters,
100+
customInputValidation: (req): AdapterInputError | undefined => {
101+
const { sessionBoundaries, sessionBoundariesTimeZone } = req.requestContext.data
102+
103+
sessionBoundaries.forEach((s) => {
104+
if (!s.match(/^(?:[01]\d|2[0-3]):[0-5]\d$/)) {
105+
throw new AdapterInputError({
106+
statusCode: 400,
107+
message: `${s} in [Param: sessionBoundaries] does not match format HH:MM`,
108+
})
109+
}
110+
})
111+
112+
try {
113+
// eslint-disable-next-line new-cap
114+
Intl.DateTimeFormat(undefined, { timeZone: sessionBoundariesTimeZone })
115+
} catch (error) {
116+
throw new AdapterInputError({
117+
statusCode: 400,
118+
message: `[Param: sessionBoundariesTimeZone] is not valid timezone: ${error}`,
119+
})
120+
}
121+
return
122+
},
78123
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { TZDate } from '@date-fns/tz'
2+
3+
// Seconds relative to session boundary (-ve before, +ve after)
4+
export const calculateSecondsFromTransition = (
5+
sessionBoundaries: string[],
6+
sessionBoundariesTimeZone: string,
7+
) => {
8+
const now = new TZDate(new Date().getTime(), sessionBoundariesTimeZone)
9+
// Handle cases where we're close to midnight
10+
const offsets = [-1, 0, 1]
11+
12+
return offsets.reduce((minDiff, offset) => {
13+
const diff = calculateWithDayOffset(sessionBoundaries, sessionBoundariesTimeZone, now, offset)
14+
15+
return Math.abs(diff) < Math.abs(minDiff) ? diff : minDiff
16+
}, Number.MAX_SAFE_INTEGER)
17+
}
18+
19+
const calculateWithDayOffset = (
20+
sessionBoundaries: string[],
21+
sessionBoundariesTimeZone: string,
22+
now: TZDate,
23+
offset: number,
24+
) =>
25+
sessionBoundaries.reduce((minDiff, b) => {
26+
const [hour, minute] = b.split(':')
27+
const session = new TZDate(
28+
now.getFullYear(),
29+
now.getMonth(),
30+
now.getDate() + offset,
31+
Number(hour),
32+
Number(minute),
33+
0,
34+
0,
35+
sessionBoundariesTimeZone,
36+
)
37+
38+
const diff = (now.getTime() - session.getTime()) / 1000
39+
40+
return Math.abs(diff) < Math.abs(minDiff) ? diff : minDiff
41+
}, Number.MAX_SAFE_INTEGER)
Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,101 @@
1+
// Algorithm by @kalanyuz and @eshaqiri
2+
import { parseUnits } from 'ethers'
3+
4+
const PRECISION = 18 // Keep 18 decimals when converting number to bigint
5+
6+
const CONFIG = {
7+
KALMAN: {
8+
Q: parseUnits('0.000075107026567861', PRECISION), // Process noise
9+
ALPHA: parseUnits('0.9996386263245117', PRECISION), // Spread-to-noise multiplier
10+
INITIAL_P: parseUnits('1.5', PRECISION), // initial covariance
11+
MIN_R: parseUnits('0.002545840040746239', PRECISION), // Measurement noise floor
12+
DECAY_FACTOR: parseUnits('0.99', PRECISION), //Covariance decay
13+
},
14+
TRANSITION: {
15+
WINDOW_BEFORE: 10, // seconds
16+
WINDOW_AFTER: 60, // seconds
17+
},
18+
}
19+
20+
// 1D Kalman filter for price with measurement noise based on spread
21+
class KalmanFilter {
22+
private x = -1n
23+
private p = CONFIG.KALMAN.INITIAL_P
24+
25+
public smooth(price: bigint, spread: bigint) {
26+
const prevX = this.x
27+
const prevP = this.p
28+
29+
if (this.x < 0n) {
30+
this.x = price
31+
return { price: this.x, x: prevX, p: prevP }
32+
}
33+
34+
// Predict
35+
const x_pred = this.x
36+
const p_pred = deScale(this.p * CONFIG.KALMAN.DECAY_FACTOR) + CONFIG.KALMAN.Q
37+
38+
// Measurement noise from spread (handle None / <=0)
39+
const eff_spread = spread > CONFIG.KALMAN.MIN_R ? spread : CONFIG.KALMAN.MIN_R
40+
const r = deScale(CONFIG.KALMAN.ALPHA * eff_spread)
41+
// Update
42+
const k = (p_pred * scale(1)) / (p_pred + r)
43+
this.x = x_pred + deScale(k * (price - x_pred))
44+
this.p = deScale((scale(1) - k) * p_pred)
45+
46+
return { price: this.x, x: prevX, p: prevP }
47+
}
48+
}
49+
50+
/**
51+
* Session Aware Smoother
52+
*
53+
* Manages the transition state and applies the weighted blending
54+
* between raw and smoothed prices.
55+
*/
156
export class SessionAwareSmoother {
2-
// TODO: Implement this in a seperaate PR
3-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
4-
public processUpdate = (rawPrice: bigint, _secondsFromTransition: number) => {
5-
return rawPrice
57+
private filter: KalmanFilter = new KalmanFilter()
58+
59+
/**
60+
* Process a new price update
61+
* @param rawPrice The current raw median price
62+
* @param spread The current spread between ask and bid prices
63+
* @param secondsFromTransition Seconds relative to session boundary (-ve before, +ve after)
64+
*/
65+
public processUpdate(rawPrice: bigint, spread: bigint, secondsFromTransition: number) {
66+
// Calculate blending weight
67+
const w = this.calculateTransitionWeight(secondsFromTransition)
68+
69+
// Calculate smoothed price
70+
const smoothedPrice = this.filter.smooth(rawPrice, spread)
71+
72+
// Apply blending: price_output = smoothed * w + raw * (1 - w)
73+
return {
74+
price: deScale(smoothedPrice.price * scale(w) + rawPrice * (scale(1) - scale(w))),
75+
x: smoothedPrice.x,
76+
p: smoothedPrice.p,
77+
}
78+
}
79+
80+
// Calculates the raised cosine decay weight
81+
private calculateTransitionWeight(t: number): number {
82+
const { WINDOW_BEFORE, WINDOW_AFTER } = CONFIG.TRANSITION
83+
84+
// Outside window
85+
if (t < -WINDOW_BEFORE || t > WINDOW_AFTER) {
86+
return 0.0
87+
}
88+
89+
// Select window side
90+
const window = t < 0 ? WINDOW_BEFORE : WINDOW_AFTER
91+
92+
// Raised cosine function: 0.5 * (1 + cos(pi * t / window))
93+
// At t=0, cos(0)=1 -> w=1.0 (Fully smoothed)
94+
// At t=window, cos(pi)=-1 -> w=0.0 (Fully raw)
95+
// At t=-window, cos(-pi)=-1 -> w=0.0
96+
return 0.5 * (1 + Math.cos((Math.PI * t) / window))
697
}
798
}
99+
100+
const scale = (number: number) => parseUnits(number.toFixed(PRECISION), PRECISION)
101+
const deScale = (bigint: bigint) => bigint / 10n ** BigInt(PRECISION)

packages/composites/ondo-calculated/src/lib/streams.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const getPrice = async (
2424

2525
return {
2626
price: stream.mid,
27+
spread: BigInt(stream.ask) - BigInt(stream.bid),
2728
decimals: stream.decimals,
2829
data: {
2930
regular,

packages/composites/ondo-calculated/src/transport/price.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { JsonRpcProvider } from 'ethers'
33

44
import { AdapterError } from '@chainlink/external-adapter-framework/validation/error'
55
import { getRegistryData } from '../lib/registry'
6+
import { calculateSecondsFromTransition } from '../lib/session'
67
import { SessionAwareSmoother } from '../lib/smoother'
78
import { getPrice } from '../lib/streams'
89

@@ -19,6 +20,8 @@ export const calculatePrice = async (param: {
1920
overnightStreamId: string
2021
url: string
2122
requester: Requester
23+
sessionBoundaries: string[]
24+
sessionBoundariesTimeZone: string
2225
decimals: number
2326
}) => {
2427
const [price, { multiplier, paused }] = await Promise.all([
@@ -39,20 +42,32 @@ export const calculatePrice = async (param: {
3942
})
4043
}
4144

42-
const smoothedPrice = smoother.processUpdate(BigInt(price.price), 0)
45+
const secondsFromTransition = calculateSecondsFromTransition(
46+
param.sessionBoundaries,
47+
param.sessionBoundariesTimeZone,
48+
)
49+
50+
const smoothed = smoother.processUpdate(BigInt(price.price), price.spread, secondsFromTransition)
4351

4452
const result =
45-
(smoothedPrice * multiplier * 10n ** BigInt(param.decimals)) /
53+
(smoothed.price * multiplier * 10n ** BigInt(param.decimals)) /
4654
10n ** BigInt(price.decimals) /
4755
10n ** MULTIPLIER_DECIMALS
4856

4957
return {
5058
result: result.toString(),
59+
rawPrice: price.price,
5160
decimals: param.decimals,
5261
registry: {
5362
sValue: multiplier.toString(),
5463
paused,
5564
},
5665
stream: price.data,
66+
smoother: {
67+
price: smoothed.price.toString(),
68+
x: smoothed.x.toString(),
69+
p: smoothed.p.toString(),
70+
secondsFromTransition,
71+
},
5772
}
5873
}

packages/composites/ondo-calculated/test/integration/__snapshots__/adapter.test.ts.snap

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,43 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`execute price endpoint bad sessionBoundaries 1`] = `
4+
{
5+
"error": {
6+
"message": "99:88 in [Param: sessionBoundaries] does not match format HH:MM",
7+
"name": "AdapterError",
8+
},
9+
"status": "errored",
10+
"statusCode": 400,
11+
}
12+
`;
13+
14+
exports[`execute price endpoint bad sessionBoundariesTimeZone 1`] = `
15+
{
16+
"error": {
17+
"message": "[Param: sessionBoundariesTimeZone] is not valid timezone: RangeError: Invalid time zone specified: random",
18+
"name": "AdapterError",
19+
},
20+
"status": "errored",
21+
"statusCode": 400,
22+
}
23+
`;
24+
325
exports[`execute price endpoint should return success 1`] = `
426
{
527
"data": {
628
"decimals": 8,
29+
"rawPrice": "1",
730
"registry": {
831
"paused": false,
932
"sValue": "2000000000000000000",
1033
},
1134
"result": "20000000",
35+
"smoother": {
36+
"p": "1500000000000000000",
37+
"price": "1",
38+
"secondsFromTransition": 9007199254740991,
39+
"x": "-1",
40+
},
1241
"stream": {
1342
"extended": {
1443
"ask": "5",

0 commit comments

Comments
 (0)