Browse Source

feat(pyth-lazer-sdk): add funding rate (#2423)

* add funding rate and refactor publisher count

* handle old price feed data

* feat: add funding timestamp

* feat: add from_f64 method for Price conversion

* feat: update PriceFeedData structure and version to 0.6.0

* feat: refactor PriceFeedData serialization tests and update funding rate handling

* feat: update funding rate and timestamp types to use Rate and TimestampUs

* feat: remove precision check for price value in Rate conversion

* feat: update pyth-lazer-protocol version to 0.6.1
Keyvan Khademi 8 months ago
parent
commit
6d6a06f9bc

+ 2 - 2
lazer/Cargo.lock

@@ -3772,7 +3772,7 @@ dependencies = [
  "futures-util",
  "hex",
  "libsecp256k1 0.7.1",
- "pyth-lazer-protocol 0.6.0",
+ "pyth-lazer-protocol 0.6.1",
  "serde",
  "serde_json",
  "tokio",
@@ -3799,7 +3799,7 @@ dependencies = [
 
 [[package]]
 name = "pyth-lazer-protocol"
-version = "0.6.0"
+version = "0.6.1"
 dependencies = [
  "alloy-primitives",
  "anyhow",

+ 1 - 1
lazer/sdk/rust/protocol/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "pyth-lazer-protocol"
-version = "0.6.0"
+version = "0.6.1"
 edition = "2021"
 description = "Pyth Lazer SDK - protocol types."
 license = "Apache-2.0"

+ 75 - 11
lazer/sdk/rust/protocol/src/payload.rs

@@ -2,7 +2,7 @@
 
 use {
     super::router::{PriceFeedId, PriceFeedProperty, TimestampUs},
-    crate::router::{ChannelId, Price},
+    crate::router::{ChannelId, Price, Rate},
     anyhow::bail,
     byteorder::{ByteOrder, ReadBytesExt, WriteBytesExt, BE, LE},
     serde::{Deserialize, Serialize},
@@ -33,9 +33,11 @@ pub enum PayloadPropertyValue {
     Price(Option<Price>),
     BestBidPrice(Option<Price>),
     BestAskPrice(Option<Price>),
-    PublisherCount(Option<u16>),
+    PublisherCount(u16),
     Exponent(i16),
     Confidence(Option<Price>),
+    FundingRate(Option<Rate>),
+    FundingTimestamp(Option<TimestampUs>),
 }
 
 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -43,8 +45,10 @@ pub struct AggregatedPriceFeedData {
     pub price: Option<Price>,
     pub best_bid_price: Option<Price>,
     pub best_ask_price: Option<Price>,
-    pub publisher_count: Option<u16>,
+    pub publisher_count: u16,
     pub confidence: Option<Price>,
+    pub funding_rate: Option<Rate>,
+    pub funding_timestamp: Option<TimestampUs>,
 }
 
 /// First bytes of a payload's encoding
@@ -84,6 +88,12 @@ impl PayloadData {
                             PriceFeedProperty::Confidence => {
                                 PayloadPropertyValue::Confidence(feed.confidence)
                             }
+                            PriceFeedProperty::FundingRate => {
+                                PayloadPropertyValue::FundingRate(feed.funding_rate)
+                            }
+                            PriceFeedProperty::FundingTimestamp => {
+                                PayloadPropertyValue::FundingTimestamp(feed.funding_timestamp)
+                            }
                         })
                         .collect(),
                 })
@@ -115,7 +125,7 @@ impl PayloadData {
                     }
                     PayloadPropertyValue::PublisherCount(count) => {
                         writer.write_u8(PriceFeedProperty::PublisherCount as u8)?;
-                        write_option_u16::<BO>(&mut writer, *count)?;
+                        writer.write_u16::<BO>(*count)?;
                     }
                     PayloadPropertyValue::Exponent(exponent) => {
                         writer.write_u8(PriceFeedProperty::Exponent as u8)?;
@@ -125,6 +135,14 @@ impl PayloadData {
                         writer.write_u8(PriceFeedProperty::Confidence as u8)?;
                         write_option_price::<BO>(&mut writer, *confidence)?;
                     }
+                    PayloadPropertyValue::FundingRate(rate) => {
+                        writer.write_u8(PriceFeedProperty::FundingRate as u8)?;
+                        write_option_rate::<BO>(&mut writer, *rate)?;
+                    }
+                    PayloadPropertyValue::FundingTimestamp(timestamp) => {
+                        writer.write_u8(PriceFeedProperty::FundingTimestamp as u8)?;
+                        write_option_timestamp::<BO>(&mut writer, *timestamp)?;
+                    }
                 }
             }
         }
@@ -164,11 +182,17 @@ impl PayloadData {
                 } else if property == PriceFeedProperty::BestAskPrice as u8 {
                     PayloadPropertyValue::BestAskPrice(read_option_price::<BO>(&mut reader)?)
                 } else if property == PriceFeedProperty::PublisherCount as u8 {
-                    PayloadPropertyValue::PublisherCount(read_option_u16::<BO>(&mut reader)?)
+                    PayloadPropertyValue::PublisherCount(reader.read_u16::<BO>()?)
                 } else if property == PriceFeedProperty::Exponent as u8 {
                     PayloadPropertyValue::Exponent(reader.read_i16::<BO>()?)
                 } else if property == PriceFeedProperty::Confidence as u8 {
                     PayloadPropertyValue::Confidence(read_option_price::<BO>(&mut reader)?)
+                } else if property == PriceFeedProperty::FundingRate as u8 {
+                    PayloadPropertyValue::FundingRate(read_option_rate::<BO>(&mut reader)?)
+                } else if property == PriceFeedProperty::FundingTimestamp as u8 {
+                    PayloadPropertyValue::FundingTimestamp(read_option_timestamp::<BO>(
+                        &mut reader,
+                    )?)
                 } else {
                     bail!("unknown property");
                 };
@@ -196,14 +220,54 @@ fn read_option_price<BO: ByteOrder>(mut reader: impl Read) -> std::io::Result<Op
     Ok(value.map(Price))
 }
 
-fn write_option_u16<BO: ByteOrder>(
+fn write_option_rate<BO: ByteOrder>(
+    mut writer: impl Write,
+    value: Option<Rate>,
+) -> std::io::Result<()> {
+    match value {
+        Some(value) => {
+            writer.write_u8(1)?;
+            writer.write_i64::<BO>(value.0)
+        }
+        None => {
+            writer.write_u8(0)?;
+            Ok(())
+        }
+    }
+}
+
+fn read_option_rate<BO: ByteOrder>(mut reader: impl Read) -> std::io::Result<Option<Rate>> {
+    let present = reader.read_u8()? != 0;
+    if present {
+        Ok(Some(Rate(reader.read_i64::<BO>()?)))
+    } else {
+        Ok(None)
+    }
+}
+
+fn write_option_timestamp<BO: ByteOrder>(
     mut writer: impl Write,
-    value: Option<u16>,
+    value: Option<TimestampUs>,
 ) -> std::io::Result<()> {
-    writer.write_u16::<BO>(value.unwrap_or(0))
+    match value {
+        Some(value) => {
+            writer.write_u8(1)?;
+            writer.write_u64::<BO>(value.0)
+        }
+        None => {
+            writer.write_u8(0)?;
+            Ok(())
+        }
+    }
 }
 
-fn read_option_u16<BO: ByteOrder>(mut reader: impl Read) -> std::io::Result<Option<u16>> {
-    let value = reader.read_u16::<BO>()?;
-    Ok(Some(value))
+fn read_option_timestamp<BO: ByteOrder>(
+    mut reader: impl Read,
+) -> std::io::Result<Option<TimestampUs>> {
+    let present = reader.read_u8()? != 0;
+    if present {
+        Ok(Some(TimestampUs(reader.read_u64::<BO>()?)))
+    } else {
+        Ok(None)
+    }
 }

+ 99 - 7
lazer/sdk/rust/protocol/src/publisher.rs

@@ -3,7 +3,7 @@
 //! eliminating WebSocket overhead.
 
 use {
-    super::router::{Price, PriceFeedId, TimestampUs},
+    super::router::{Price, PriceFeedId, Rate, TimestampUs},
     derive_more::derive::From,
     serde::{Deserialize, Serialize},
 };
@@ -12,7 +12,33 @@ use {
 /// from the publisher to the router.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
-pub struct PriceFeedData {
+pub struct PriceFeedDataV2 {
+    pub price_feed_id: PriceFeedId,
+    /// Timestamp of the last update provided by the source of the prices
+    /// (like an exchange). If unavailable, this value is set to `publisher_timestamp_us`.
+    pub source_timestamp_us: TimestampUs,
+    /// Timestamp of the last update provided by the publisher.
+    pub publisher_timestamp_us: TimestampUs,
+    /// Last known value of the best executable price of this price feed.
+    /// `None` if no value is currently available.
+    pub price: Option<Price>,
+    /// Last known value of the best bid price of this price feed.
+    /// `None` if no value is currently available.
+    pub best_bid_price: Option<Price>,
+    /// Last known value of the best ask price of this price feed.
+    /// `None` if no value is currently available.
+    pub best_ask_price: Option<Price>,
+    /// Last known value of the funding rate of this feed.
+    /// `None` if no value is currently available.
+    pub funding_rate: Option<Rate>,
+}
+
+/// Old Represents a binary (bincode-serialized) stream update sent
+/// from the publisher to the router.
+/// Superseded by `PriceFeedData`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PriceFeedDataV1 {
     pub price_feed_id: PriceFeedId,
     /// Timestamp of the last update provided by the source of the prices
     /// (like an exchange). If unavailable, this value is set to `publisher_timestamp_us`.
@@ -33,6 +59,20 @@ pub struct PriceFeedData {
     pub best_ask_price: Option<Price>,
 }
 
+impl From<PriceFeedDataV1> for PriceFeedDataV2 {
+    fn from(v0: PriceFeedDataV1) -> Self {
+        Self {
+            price_feed_id: v0.price_feed_id,
+            source_timestamp_us: v0.source_timestamp_us,
+            publisher_timestamp_us: v0.publisher_timestamp_us,
+            price: v0.price,
+            best_bid_price: v0.best_bid_price,
+            best_ask_price: v0.best_ask_price,
+            funding_rate: None,
+        }
+    }
+}
+
 /// A response sent from the server to the publisher client.
 /// Currently only serde errors are reported back to the client.
 #[derive(Debug, Clone, Serialize, Deserialize, From)]
@@ -49,7 +89,7 @@ pub struct UpdateDeserializationErrorResponse {
 }
 
 #[test]
-fn price_feed_data_serde() {
+fn price_feed_data_v1_serde() {
     let data = [
         1, 0, 0, 0, // price_feed_id
         2, 0, 0, 0, 0, 0, 0, 0, // source_timestamp_us
@@ -59,7 +99,7 @@ fn price_feed_data_serde() {
         6, 2, 0, 0, 0, 0, 0, 0, // best_ask_price
     ];
 
-    let expected = PriceFeedData {
+    let expected = PriceFeedDataV1 {
         price_feed_id: PriceFeedId(1),
         source_timestamp_us: TimestampUs(2),
         publisher_timestamp_us: TimestampUs(3),
@@ -68,7 +108,7 @@ fn price_feed_data_serde() {
         best_ask_price: Some(Price((2 * 256 + 6).try_into().unwrap())),
     };
     assert_eq!(
-        bincode::deserialize::<PriceFeedData>(&data).unwrap(),
+        bincode::deserialize::<PriceFeedDataV1>(&data).unwrap(),
         expected
     );
     assert_eq!(bincode::serialize(&expected).unwrap(), data);
@@ -81,16 +121,68 @@ fn price_feed_data_serde() {
         0, 0, 0, 0, 0, 0, 0, 0, // best_bid_price
         0, 0, 0, 0, 0, 0, 0, 0, // best_ask_price
     ];
-    let expected2 = PriceFeedData {
+    let expected2 = PriceFeedDataV1 {
+        price_feed_id: PriceFeedId(1),
+        source_timestamp_us: TimestampUs(2),
+        publisher_timestamp_us: TimestampUs(3),
+        price: Some(Price(4.try_into().unwrap())),
+        best_bid_price: None,
+        best_ask_price: None,
+    };
+    assert_eq!(
+        bincode::deserialize::<PriceFeedDataV1>(&data2).unwrap(),
+        expected2
+    );
+    assert_eq!(bincode::serialize(&expected2).unwrap(), data2);
+}
+
+#[test]
+fn price_feed_data_v2_serde() {
+    let data = [
+        1, 0, 0, 0, // price_feed_id
+        2, 0, 0, 0, 0, 0, 0, 0, // source_timestamp_us
+        3, 0, 0, 0, 0, 0, 0, 0, // publisher_timestamp_us
+        1, 4, 0, 0, 0, 0, 0, 0, 0, // price
+        1, 5, 0, 0, 0, 0, 0, 0, 0, // best_bid_price
+        1, 6, 2, 0, 0, 0, 0, 0, 0, // best_ask_price
+        0, // funding_rate
+    ];
+
+    let expected = PriceFeedDataV2 {
+        price_feed_id: PriceFeedId(1),
+        source_timestamp_us: TimestampUs(2),
+        publisher_timestamp_us: TimestampUs(3),
+        price: Some(Price(4.try_into().unwrap())),
+        best_bid_price: Some(Price(5.try_into().unwrap())),
+        best_ask_price: Some(Price((2 * 256 + 6).try_into().unwrap())),
+        funding_rate: None,
+    };
+    assert_eq!(
+        bincode::deserialize::<PriceFeedDataV2>(&data).unwrap(),
+        expected
+    );
+    assert_eq!(bincode::serialize(&expected).unwrap(), data);
+
+    let data2 = [
+        1, 0, 0, 0, // price_feed_id
+        2, 0, 0, 0, 0, 0, 0, 0, // source_timestamp_us
+        3, 0, 0, 0, 0, 0, 0, 0, // publisher_timestamp_us
+        1, 4, 0, 0, 0, 0, 0, 0, 0, // price
+        0, // best_bid_price
+        0, // best_ask_price
+        1, 7, 3, 0, 0, 0, 0, 0, 0, // funding_rate
+    ];
+    let expected2 = PriceFeedDataV2 {
         price_feed_id: PriceFeedId(1),
         source_timestamp_us: TimestampUs(2),
         publisher_timestamp_us: TimestampUs(3),
         price: Some(Price(4.try_into().unwrap())),
         best_bid_price: None,
         best_ask_price: None,
+        funding_rate: Some(Rate(3 * 256 + 7)),
     };
     assert_eq!(
-        bincode::deserialize::<PriceFeedData>(&data2).unwrap(),
+        bincode::deserialize::<PriceFeedDataV2>(&data2).unwrap(),
         expected2
     );
     assert_eq!(bincode::serialize(&expected2).unwrap(), data2);

+ 53 - 9
lazer/sdk/rust/protocol/src/router.rs

@@ -41,6 +41,33 @@ impl TimestampUs {
     }
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[repr(transparent)]
+pub struct Rate(pub i64);
+
+impl Rate {
+    pub fn parse_str(value: &str, exponent: u32) -> anyhow::Result<Self> {
+        let value: Decimal = value.parse()?;
+        let coef = 10i64.checked_pow(exponent).context("overflow")?;
+        let coef = Decimal::from_i64(coef).context("overflow")?;
+        let value = value.checked_mul(coef).context("overflow")?;
+        if !value.is_integer() {
+            bail!("price value is more precise than available exponent");
+        }
+        let value: i64 = value.try_into().context("overflow")?;
+        Ok(Self(value))
+    }
+
+    pub fn from_f64(value: f64, exponent: u32) -> anyhow::Result<Self> {
+        let value = Decimal::from_f64(value).context("overflow")?;
+        let coef = 10i64.checked_pow(exponent).context("overflow")?;
+        let coef = Decimal::from_i64(coef).context("overflow")?;
+        let value = value.checked_mul(coef).context("overflow")?;
+        let value: i64 = value.try_into().context("overflow")?;
+        Ok(Self(value))
+    }
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 #[repr(transparent)]
 pub struct Price(pub NonZeroI64);
@@ -79,6 +106,12 @@ impl Price {
         Ok(self.0.get() as f64 / 10i64.checked_pow(exponent).context("overflow")? as f64)
     }
 
+    pub fn from_f64(value: f64, exponent: u32) -> anyhow::Result<Self> {
+        let value = (value * 10f64.powi(exponent as i32)) as i64;
+        let value = NonZeroI64::new(value).context("zero price is unsupported")?;
+        Ok(Self(value))
+    }
+
     pub fn mul(self, rhs: Price, rhs_exponent: u32) -> anyhow::Result<Price> {
         let left_value = i128::from(self.0.get());
         let right_value = i128::from(rhs.0.get());
@@ -142,6 +175,8 @@ pub enum PriceFeedProperty {
     PublisherCount,
     Exponent,
     Confidence,
+    FundingRate,
+    FundingTimestamp,
     // More fields may be added later.
 }
 
@@ -394,13 +429,6 @@ pub struct ParsedPayload {
     pub price_feeds: Vec<ParsedFeedPayload>,
 }
 
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct NatsPayload {
-    pub payload: ParsedPayload,
-    pub channel: Channel,
-}
-
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ParsedFeedPayload {
@@ -426,6 +454,12 @@ pub struct ParsedFeedPayload {
     #[serde(skip_serializing_if = "Option::is_none")]
     #[serde(default)]
     pub confidence: Option<Price>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[serde(default)]
+    pub funding_rate: Option<Rate>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[serde(default)]
+    pub funding_timestamp: Option<TimestampUs>,
     // More fields may be added later.
 }
 
@@ -444,6 +478,8 @@ impl ParsedFeedPayload {
             publisher_count: None,
             exponent: None,
             confidence: None,
+            funding_rate: None,
+            funding_timestamp: None,
         };
         for &property in properties {
             match property {
@@ -457,7 +493,7 @@ impl ParsedFeedPayload {
                     output.best_ask_price = data.best_ask_price;
                 }
                 PriceFeedProperty::PublisherCount => {
-                    output.publisher_count = data.publisher_count;
+                    output.publisher_count = Some(data.publisher_count);
                 }
                 PriceFeedProperty::Exponent => {
                     output.exponent = exponent;
@@ -465,6 +501,12 @@ impl ParsedFeedPayload {
                 PriceFeedProperty::Confidence => {
                     output.confidence = data.confidence;
                 }
+                PriceFeedProperty::FundingRate => {
+                    output.funding_rate = data.funding_rate;
+                }
+                PriceFeedProperty::FundingTimestamp => {
+                    output.funding_timestamp = data.funding_timestamp;
+                }
             }
         }
         output
@@ -480,9 +522,11 @@ impl ParsedFeedPayload {
             price: data.price,
             best_bid_price: data.best_bid_price,
             best_ask_price: data.best_ask_price,
-            publisher_count: data.publisher_count,
+            publisher_count: Some(data.publisher_count),
             exponent,
             confidence: data.confidence,
+            funding_rate: data.funding_rate,
+            funding_timestamp: data.funding_timestamp,
         }
     }
 }