Skip to main content

px_core/models/
trade.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct PublicTrade {
6    pub proxy_wallet: String,
7    pub side: String,
8    pub asset: String,
9    pub condition_id: String,
10    pub size: f64,
11    pub price: f64,
12    pub timestamp: DateTime<Utc>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub title: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub slug: Option<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub icon: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub event_slug: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub outcome: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub outcome_index: Option<u32>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub name: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub pseudonym: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub bio: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub profile_image: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub profile_image_optimized: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub transaction_hash: Option<String>,
37}
38
39/// Normalized public market trade, suitable for "tape" UIs.
40///
41/// - `price` is normalized to [0.0, 1.0] across all exchanges.
42/// - `timestamp` is the exchange-provided trade timestamp (UTC).
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45pub struct MarketTrade {
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub id: Option<String>,
48    pub price: f64,
49    pub size: f64,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub side: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub aggressor_side: Option<String>,
54    pub timestamp: DateTime<Utc>,
55    pub source_channel: std::borrow::Cow<'static, str>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub tx_hash: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub outcome: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub yes_price: Option<f64>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub no_price: Option<f64>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub taker_address: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct PricePoint {
70    pub timestamp: DateTime<Utc>,
71    pub price: f64,
72    #[serde(default)]
73    pub raw: serde_json::Value,
74}
75
76/// OHLCV candlestick, normalized across all exchanges.
77/// Prices are decimals (0.0 to 1.0). Timestamp is the period START (not end).
78/// Serialized over the wire as RFC3339 (DateTime<Utc>) for API consistency.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct Candlestick {
82    /// Period start timestamp (UTC). lightweight-charts expects start-of-period.
83    pub timestamp: DateTime<Utc>,
84    pub open: f64,
85    pub high: f64,
86    pub low: f64,
87    pub close: f64,
88    /// Trade volume in contracts. 0.0 if exchange doesn't provide volume.
89    pub volume: f64,
90    /// Open interest at this candle's close. Only available from exchanges that report it (e.g., Kalshi).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub open_interest: Option<f64>,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub enum PriceHistoryInterval {
98    #[serde(rename = "1m")]
99    OneMinute,
100    #[serde(rename = "1h")]
101    OneHour,
102    #[serde(rename = "6h")]
103    SixHours,
104    #[serde(rename = "1d")]
105    OneDay,
106    #[serde(rename = "1w")]
107    OneWeek,
108    #[serde(rename = "max")]
109    Max,
110}
111
112impl PriceHistoryInterval {
113    pub fn as_str(&self) -> &'static str {
114        match self {
115            Self::OneMinute => "1m",
116            Self::OneHour => "1h",
117            Self::SixHours => "6h",
118            Self::OneDay => "1d",
119            Self::OneWeek => "1w",
120            Self::Max => "max",
121        }
122    }
123
124    /// Approximate duration of one interval in seconds.
125    pub fn seconds(&self) -> i64 {
126        match self {
127            Self::OneMinute => 60,
128            Self::OneHour => 3600,
129            Self::SixHours => 21600,
130            Self::OneDay => 86400,
131            Self::OneWeek => 604_800,
132            Self::Max => 86400,
133        }
134    }
135}
136
137impl std::str::FromStr for PriceHistoryInterval {
138    type Err = String;
139
140    fn from_str(s: &str) -> Result<Self, Self::Err> {
141        match s {
142            "1m" => Ok(Self::OneMinute),
143            "1h" => Ok(Self::OneHour),
144            "6h" => Ok(Self::SixHours),
145            "1d" => Ok(Self::OneDay),
146            "1w" => Ok(Self::OneWeek),
147            "max" => Ok(Self::Max),
148            _ => Err(format!("unknown interval: {s}")),
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn candlestick_omits_open_interest_when_none() {
159        let c = Candlestick {
160            timestamp: chrono::Utc::now(),
161            open: 0.5,
162            high: 0.6,
163            low: 0.4,
164            close: 0.55,
165            volume: 100.0,
166            open_interest: None,
167        };
168        let json = serde_json::to_value(&c).unwrap();
169        assert!(json.get("open_interest").is_none());
170    }
171
172    #[test]
173    fn candlestick_includes_open_interest_when_some() {
174        let c = Candlestick {
175            timestamp: chrono::Utc::now(),
176            open: 0.5,
177            high: 0.6,
178            low: 0.4,
179            close: 0.55,
180            volume: 100.0,
181            open_interest: Some(42000.0),
182        };
183        let json = serde_json::to_value(&c).unwrap();
184        assert_eq!(json["open_interest"], 42000.0);
185    }
186
187    #[test]
188    fn candlestick_roundtrip_with_open_interest() {
189        let c = Candlestick {
190            timestamp: chrono::Utc::now(),
191            open: 0.5,
192            high: 0.6,
193            low: 0.4,
194            close: 0.55,
195            volume: 100.0,
196            open_interest: Some(1234.0),
197        };
198        let serialized = serde_json::to_string(&c).unwrap();
199        let deserialized: Candlestick = serde_json::from_str(&serialized).unwrap();
200        assert_eq!(deserialized.open_interest, Some(1234.0));
201    }
202
203    #[test]
204    fn candlestick_deserialize_without_open_interest_defaults_none() {
205        // Simulates old relay/exchange response without the field
206        let json = r#"{"timestamp":"2026-01-01T00:00:00Z","open":0.5,"high":0.6,"low":0.4,"close":0.55,"volume":100.0}"#;
207        let c: Candlestick = serde_json::from_str(json).unwrap();
208        assert_eq!(c.open_interest, None);
209    }
210}