Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chilled-weeks-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/ondo-calculated-adapter': minor
---

Price smoothing
45 changes: 45 additions & 0 deletions packages/composites/ondo-calculated/src/endpoint/price.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseEndpointTypes as DataEngineResponse } from '@chainlink/data-engine-adapter/src/endpoint/deutscheBoerseV11'
import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter'
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
import { config } from '../config'
import { priceTransport } from '../transport/transport'

Expand Down Expand Up @@ -31,6 +32,18 @@ export const inputParameters = new InputParameters(
type: 'string',
description: 'Data Streams overnight hour feed ID for the underlying asset',
},
sessionBoundaries: {
required: true,
type: 'string',
array: true,
description:
'A list of time where market trasition from 1 session to the next in the format of HH:MM',
},
sessionBoundariesTimeZone: {
required: true,
type: 'string',
description: 'ANA Time Zone Database format',
},
decimals: {
type: 'number',
description: 'Decimals of output result',
Expand All @@ -44,6 +57,8 @@ export const inputParameters = new InputParameters(
regularStreamId: '0x0',
extendedStreamId: '0x0',
overnightStreamId: '0x0',
sessionBoundaries: ['04:00', '16:00', '20:00'],
sessionBoundariesTimeZone: 'America/New_York',
decimals: 8,
},
],
Expand All @@ -55,6 +70,7 @@ export type BaseEndpointTypes = {
Result: string
Data: {
result: string
rawPrice: string
decimals: number
registry: {
sValue: string
Expand All @@ -65,6 +81,12 @@ export type BaseEndpointTypes = {
extended: DataEngineResponse['Response']['Data']
overnight: DataEngineResponse['Response']['Data']
}
smoother: {
price: string
x: string
p: string
secondsFromTransition: number
}
}
}
Settings: typeof config.settings
Expand All @@ -75,4 +97,27 @@ export const endpoint = new AdapterEndpoint({
aliases: [],
transport: priceTransport,
inputParameters,
customInputValidation: (req): AdapterInputError | undefined => {
const { sessionBoundaries, sessionBoundariesTimeZone } = req.requestContext.data

sessionBoundaries.forEach((s) => {
if (!s.match(/^(?:[01]\d|2[0-3]):[0-5]\d$/)) {
throw new AdapterInputError({
statusCode: 400,
message: `${s} in [Param: sessionBoundaries] does not match format HH:MM`,
})
}
})

try {
// eslint-disable-next-line new-cap
Intl.DateTimeFormat(undefined, { timeZone: sessionBoundariesTimeZone })
} catch (error) {
throw new AdapterInputError({
statusCode: 400,
message: `[Param: sessionBoundariesTimeZone] is not valid timezone: ${error}`,
})
}
return
},
})
41 changes: 41 additions & 0 deletions packages/composites/ondo-calculated/src/lib/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { TZDate } from '@date-fns/tz'

// Seconds relative to session boundary (-ve before, +ve after)
export const calculateSecondsFromTransition = (
sessionBoundaries: string[],
sessionBoundariesTimeZone: string,
) => {
const now = new TZDate(new Date().getTime(), sessionBoundariesTimeZone)
// Handle cases where we're close to midnight
const offsets = [-1, 0, 1]

return offsets.reduce((minDiff, offset) => {
const diff = calculateWithDayOffset(sessionBoundaries, sessionBoundariesTimeZone, now, offset)

return Math.abs(diff) < Math.abs(minDiff) ? diff : minDiff
}, Number.MAX_SAFE_INTEGER)
}

const calculateWithDayOffset = (
sessionBoundaries: string[],
sessionBoundariesTimeZone: string,
now: TZDate,
offset: number,
) =>
sessionBoundaries.reduce((minDiff, b) => {
const [hour, minute] = b.split(':')
const session = new TZDate(
now.getFullYear(),
now.getMonth(),
now.getDate() + offset,
Number(hour),
Number(minute),
0,
0,
sessionBoundariesTimeZone,
)

const diff = (now.getTime() - session.getTime()) / 1000

return Math.abs(diff) < Math.abs(minDiff) ? diff : minDiff
}, Number.MAX_SAFE_INTEGER)
102 changes: 98 additions & 4 deletions packages/composites/ondo-calculated/src/lib/smoother.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,101 @@
// Algorithm by @kalanyuz and @eshaqiri
import { parseUnits } from 'ethers'

const PRECISION = 18 // Keep 18 decimals when converting number to bigint

const CONFIG = {
KALMAN: {
Q: parseUnits('0.000075107026567861', PRECISION), // Process noise
ALPHA: parseUnits('0.9996386263245117', PRECISION), // Spread-to-noise multiplier
INITIAL_P: parseUnits('1.5', PRECISION), // initial covariance
MIN_R: parseUnits('0.002545840040746239', PRECISION), // Measurement noise floor
DECAY_FACTOR: parseUnits('0.99', PRECISION), //Covariance decay
},
TRANSITION: {
WINDOW_BEFORE: 10, // seconds
WINDOW_AFTER: 60, // seconds
},
}

// 1D Kalman filter for price with measurement noise based on spread
class KalmanFilter {
private x = -1n
private p = CONFIG.KALMAN.INITIAL_P

public smooth(price: bigint, spread: bigint) {
const prevX = this.x
const prevP = this.p

if (this.x < 0n) {
this.x = price
return { price: this.x, x: prevX, p: prevP }
}

// Predict
const x_pred = this.x
const p_pred = deScale(this.p * CONFIG.KALMAN.DECAY_FACTOR) + CONFIG.KALMAN.Q

// Measurement noise from spread (handle None / <=0)
const eff_spread = spread > CONFIG.KALMAN.MIN_R ? spread : CONFIG.KALMAN.MIN_R
const r = deScale(CONFIG.KALMAN.ALPHA * eff_spread)
// Update
const k = (p_pred * scale(1)) / (p_pred + r)
this.x = x_pred + deScale(k * (price - x_pred))
this.p = deScale((scale(1) - k) * p_pred)

return { price: this.x, x: prevX, p: prevP }
}
}

/**
* Session Aware Smoother
*
* Manages the transition state and applies the weighted blending
* between raw and smoothed prices.
*/
export class SessionAwareSmoother {
// TODO: Implement this in a seperaate PR
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public processUpdate = (rawPrice: bigint, _secondsFromTransition: number) => {
return rawPrice
private filter: KalmanFilter = new KalmanFilter()

/**
* Process a new price update
* @param rawPrice The current raw median price
* @param spread The current spread between ask and bid prices
* @param secondsFromTransition Seconds relative to session boundary (-ve before, +ve after)
*/
public processUpdate(rawPrice: bigint, spread: bigint, secondsFromTransition: number) {
// Calculate blending weight
const w = this.calculateTransitionWeight(secondsFromTransition)

// Calculate smoothed price
const smoothedPrice = this.filter.smooth(rawPrice, spread)

// Apply blending: price_output = smoothed * w + raw * (1 - w)
return {
price: deScale(smoothedPrice.price * scale(w) + rawPrice * (scale(1) - scale(w))),
x: smoothedPrice.x,
p: smoothedPrice.p,
}
}

// Calculates the raised cosine decay weight
private calculateTransitionWeight(t: number): number {
const { WINDOW_BEFORE, WINDOW_AFTER } = CONFIG.TRANSITION

// Outside window
if (t < -WINDOW_BEFORE || t > WINDOW_AFTER) {
return 0.0
}

// Select window side
const window = t < 0 ? WINDOW_BEFORE : WINDOW_AFTER

// Raised cosine function: 0.5 * (1 + cos(pi * t / window))
// At t=0, cos(0)=1 -> w=1.0 (Fully smoothed)
// At t=window, cos(pi)=-1 -> w=0.0 (Fully raw)
// At t=-window, cos(-pi)=-1 -> w=0.0
return 0.5 * (1 + Math.cos((Math.PI * t) / window))
}
}

const scale = (number: number) => parseUnits(number.toFixed(PRECISION), PRECISION)
const deScale = (bigint: bigint) => bigint / 10n ** BigInt(PRECISION)
1 change: 1 addition & 0 deletions packages/composites/ondo-calculated/src/lib/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const getPrice = async (

return {
price: stream.mid,
spread: BigInt(stream.ask) - BigInt(stream.bid),
decimals: stream.decimals,
data: {
regular,
Expand Down
19 changes: 17 additions & 2 deletions packages/composites/ondo-calculated/src/transport/price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { JsonRpcProvider } from 'ethers'

import { AdapterError } from '@chainlink/external-adapter-framework/validation/error'
import { getRegistryData } from '../lib/registry'
import { calculateSecondsFromTransition } from '../lib/session'
import { SessionAwareSmoother } from '../lib/smoother'
import { getPrice } from '../lib/streams'

Expand All @@ -19,6 +20,8 @@ export const calculatePrice = async (param: {
overnightStreamId: string
url: string
requester: Requester
sessionBoundaries: string[]
sessionBoundariesTimeZone: string
decimals: number
}) => {
const [price, { multiplier, paused }] = await Promise.all([
Expand All @@ -39,20 +42,32 @@ export const calculatePrice = async (param: {
})
}

const smoothedPrice = smoother.processUpdate(BigInt(price.price), 0)
const secondsFromTransition = calculateSecondsFromTransition(
param.sessionBoundaries,
param.sessionBoundariesTimeZone,
)

const smoothed = smoother.processUpdate(BigInt(price.price), price.spread, secondsFromTransition)

const result =
(smoothedPrice * multiplier * 10n ** BigInt(param.decimals)) /
(smoothed.price * multiplier * 10n ** BigInt(param.decimals)) /
10n ** BigInt(price.decimals) /
10n ** MULTIPLIER_DECIMALS

return {
result: result.toString(),
rawPrice: price.price,
decimals: param.decimals,
registry: {
sValue: multiplier.toString(),
paused,
},
stream: price.data,
smoother: {
price: smoothed.price.toString(),
x: smoothed.x.toString(),
p: smoothed.p.toString(),
secondsFromTransition,
},
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`execute price endpoint bad sessionBoundaries 1`] = `
{
"error": {
"message": "99:88 in [Param: sessionBoundaries] does not match format HH:MM",
"name": "AdapterError",
},
"status": "errored",
"statusCode": 400,
}
`;

exports[`execute price endpoint bad sessionBoundariesTimeZone 1`] = `
{
"error": {
"message": "[Param: sessionBoundariesTimeZone] is not valid timezone: RangeError: Invalid time zone specified: random",
"name": "AdapterError",
},
"status": "errored",
"statusCode": 400,
}
`;

exports[`execute price endpoint should return success 1`] = `
{
"data": {
"decimals": 8,
"rawPrice": "1",
"registry": {
"paused": false,
"sValue": "2000000000000000000",
},
"result": "20000000",
"smoother": {
"p": "1500000000000000000",
"price": "1",
"secondsFromTransition": 9007199254740991,
"x": "-1",
},
"stream": {
"extended": {
"ask": "5",
Expand Down
Loading
Loading