Skip to main content

trading_ig/streaming/
events.rs

1//! Typed streaming update events for each subscription kind.
2//!
3//! These structs are what callers receive from the
4//! `tokio::sync::mpsc::Receiver<T>` channels returned by the subscription
5//! helpers on [`crate::streaming::StreamingClient`].
6//!
7//! Fields are `Option<f64>` / `Option<String>` etc. because:
8//! - Lightstreamer may send `#` (null) for any field at any time.
9//! - The "unchanged" sentinel is resolved before the event is emitted, so by
10//!   the time a caller sees an event every field either has a value or is
11//!   `None`.
12
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// MARKET:<epic>  — MERGE mode
17// ---------------------------------------------------------------------------
18
19/// A single update from a `MARKET:<epic>` subscription.
20///
21/// Fields mirror the IG Lightstreamer `MARKET` adapter fields.
22/// `None` means the server sent `null` (or the field has never been populated).
23#[derive(Debug, Clone, Default)]
24pub struct MarketUpdate {
25    /// The IG epic this update belongs to.
26    pub epic: String,
27    /// Best bid price.
28    pub bid: Option<f64>,
29    /// Best offer (ask) price.
30    pub offer: Option<f64>,
31    /// Today's high price.
32    pub high: Option<f64>,
33    /// Today's low price.
34    pub low: Option<f64>,
35    /// Mid price at open.
36    pub mid_open: Option<f64>,
37    /// Net change vs. previous close.
38    pub change: Option<f64>,
39    /// Percentage change vs. previous close.
40    pub change_pct: Option<f64>,
41    /// Server-side update timestamp (HH:MM:SS string).
42    pub update_time: Option<String>,
43    /// Whether price quotes are delayed (`true`) or live (`false`).
44    pub market_delay: Option<bool>,
45    /// Market state string (e.g. `"TRADEABLE"`, `"CLOSED"`).
46    pub market_state: Option<String>,
47}
48
49/// Field indices for `MARKET:<epic>`.
50pub(crate) const MARKET_FIELDS: &[&str] = &[
51    "BID",
52    "OFFER",
53    "HIGH",
54    "LOW",
55    "MID_OPEN",
56    "CHANGE",
57    "CHANGE_PCT",
58    "UPDATE_TIME",
59    "MARKET_DELAY",
60    "MARKET_STATE",
61];
62
63impl MarketUpdate {
64    /// Construct from a raw field-value slice (in `MARKET_FIELDS` order).
65    pub fn from_raw(epic: &str, state: &[Option<String>]) -> Self {
66        let get = |i: usize| state.get(i).and_then(|v| v.as_deref());
67        Self {
68            epic: epic.to_owned(),
69            bid: get(0).and_then(|s| s.parse().ok()),
70            offer: get(1).and_then(|s| s.parse().ok()),
71            high: get(2).and_then(|s| s.parse().ok()),
72            low: get(3).and_then(|s| s.parse().ok()),
73            mid_open: get(4).and_then(|s| s.parse().ok()),
74            change: get(5).and_then(|s| s.parse().ok()),
75            change_pct: get(6).and_then(|s| s.parse().ok()),
76            update_time: get(7).map(str::to_owned),
77            market_delay: get(8).and_then(|s| match s {
78                "0" | "false" => Some(false),
79                "1" | "true" => Some(true),
80                _ => None,
81            }),
82            market_state: get(9).map(str::to_owned),
83        }
84    }
85}
86
87// ---------------------------------------------------------------------------
88// CHART:<epic>:TICK  — DISTINCT mode
89// ---------------------------------------------------------------------------
90
91/// A single tick from a `CHART:<epic>:TICK` subscription.
92#[derive(Debug, Clone, Default)]
93pub struct ChartTickUpdate {
94    /// The IG epic this update belongs to.
95    pub epic: String,
96    /// Bid price for the tick.
97    pub bid: Option<f64>,
98    /// Offer price for the tick.
99    pub ofr: Option<f64>,
100    /// Last traded price.
101    pub ltp: Option<f64>,
102    /// Last traded volume.
103    pub ltv: Option<f64>,
104    /// Total traded volume today.
105    pub ttv: Option<f64>,
106    /// UTC millisecond timestamp of the tick.
107    pub utm: Option<i64>,
108    /// Mid price at open today.
109    pub day_open_mid: Option<f64>,
110    /// Net change mid today.
111    pub day_net_chg_mid: Option<f64>,
112    /// Percentage change mid today.
113    pub day_perc_chg_mid: Option<f64>,
114    /// Today's high.
115    pub day_high: Option<f64>,
116    /// Today's low.
117    pub day_low: Option<f64>,
118}
119
120/// Field indices for `CHART:<epic>:TICK`.
121pub(crate) const CHART_TICK_FIELDS: &[&str] = &[
122    "BID",
123    "OFR",
124    "LTP",
125    "LTV",
126    "TTV",
127    "UTM",
128    "DAY_OPEN_MID",
129    "DAY_NET_CHG_MID",
130    "DAY_PERC_CHG_MID",
131    "DAY_HIGH",
132    "DAY_LOW",
133];
134
135impl ChartTickUpdate {
136    pub fn from_raw(epic: &str, state: &[Option<String>]) -> Self {
137        let get = |i: usize| state.get(i).and_then(|v| v.as_deref());
138        Self {
139            epic: epic.to_owned(),
140            bid: get(0).and_then(|s| s.parse().ok()),
141            ofr: get(1).and_then(|s| s.parse().ok()),
142            ltp: get(2).and_then(|s| s.parse().ok()),
143            ltv: get(3).and_then(|s| s.parse().ok()),
144            ttv: get(4).and_then(|s| s.parse().ok()),
145            utm: get(5).and_then(|s| s.parse().ok()),
146            day_open_mid: get(6).and_then(|s| s.parse().ok()),
147            day_net_chg_mid: get(7).and_then(|s| s.parse().ok()),
148            day_perc_chg_mid: get(8).and_then(|s| s.parse().ok()),
149            day_high: get(9).and_then(|s| s.parse().ok()),
150            day_low: get(10).and_then(|s| s.parse().ok()),
151        }
152    }
153}
154
155// ---------------------------------------------------------------------------
156// CHART:<epic>:<scale>  — MERGE mode
157// ---------------------------------------------------------------------------
158
159/// Candle scale for `CHART:<epic>:<scale>` subscriptions.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum CandleScale {
162    /// One-minute candle.
163    OneMinute,
164    /// Five-minute candle.
165    FiveMinute,
166    /// One-hour candle.
167    Hour,
168}
169
170impl CandleScale {
171    /// Return the wire-level scale string used in the Lightstreamer item name.
172    pub fn as_str(self) -> &'static str {
173        match self {
174            Self::OneMinute => "1MINUTE",
175            Self::FiveMinute => "5MINUTE",
176            Self::Hour => "HOUR",
177        }
178    }
179}
180
181impl std::fmt::Display for CandleScale {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.write_str(self.as_str())
184    }
185}
186
187/// A candle update from a `CHART:<epic>:<scale>` subscription.
188#[derive(Debug, Clone, Default)]
189pub struct ChartCandleUpdate {
190    /// The IG epic this update belongs to.
191    pub epic: String,
192    /// Candle scale.
193    pub scale: Option<CandleScale>,
194    /// Offer open price.
195    pub ofr_open: Option<f64>,
196    /// Offer high price.
197    pub ofr_high: Option<f64>,
198    /// Offer low price.
199    pub ofr_low: Option<f64>,
200    /// Offer close price.
201    pub ofr_close: Option<f64>,
202    /// Bid open price.
203    pub bid_open: Option<f64>,
204    /// Bid high price.
205    pub bid_high: Option<f64>,
206    /// Bid low price.
207    pub bid_low: Option<f64>,
208    /// Bid close price.
209    pub bid_close: Option<f64>,
210    /// Last-traded-price open.
211    pub ltp_open: Option<f64>,
212    /// Last-traded-price high.
213    pub ltp_high: Option<f64>,
214    /// Last-traded-price low.
215    pub ltp_low: Option<f64>,
216    /// Last-traded-price close.
217    pub ltp_close: Option<f64>,
218    /// Whether the candle is complete (`1`) or still forming (`0`).
219    pub cons_end: Option<bool>,
220    /// Number of ticks in this candle.
221    pub cons_tick_count: Option<i64>,
222    /// UTC millisecond timestamp.
223    pub utm: Option<i64>,
224}
225
226/// Field indices for `CHART:<epic>:<scale>`.
227pub(crate) const CHART_CANDLE_FIELDS: &[&str] = &[
228    "OFR_OPEN",
229    "OFR_HIGH",
230    "OFR_LOW",
231    "OFR_CLOSE",
232    "BID_OPEN",
233    "BID_HIGH",
234    "BID_LOW",
235    "BID_CLOSE",
236    "LTP_OPEN",
237    "LTP_HIGH",
238    "LTP_LOW",
239    "LTP_CLOSE",
240    "CONS_END",
241    "CONS_TICK_COUNT",
242    "UTM",
243];
244
245impl ChartCandleUpdate {
246    pub fn from_raw(epic: &str, scale: CandleScale, state: &[Option<String>]) -> Self {
247        let get = |i: usize| state.get(i).and_then(|v| v.as_deref());
248        let pf = |i: usize| get(i).and_then(|s| s.parse::<f64>().ok());
249        let pi = |i: usize| get(i).and_then(|s| s.parse::<i64>().ok());
250        Self {
251            epic: epic.to_owned(),
252            scale: Some(scale),
253            ofr_open: pf(0),
254            ofr_high: pf(1),
255            ofr_low: pf(2),
256            ofr_close: pf(3),
257            bid_open: pf(4),
258            bid_high: pf(5),
259            bid_low: pf(6),
260            bid_close: pf(7),
261            ltp_open: pf(8),
262            ltp_high: pf(9),
263            ltp_low: pf(10),
264            ltp_close: pf(11),
265            cons_end: get(12).and_then(|s| match s {
266                "1" => Some(true),
267                "0" => Some(false),
268                _ => None,
269            }),
270            cons_tick_count: pi(13),
271            utm: pi(14),
272        }
273    }
274}
275
276// ---------------------------------------------------------------------------
277// ACCOUNT:<accountId>  — MERGE mode
278// ---------------------------------------------------------------------------
279
280/// An update from an `ACCOUNT:<accountId>` subscription.
281#[derive(Debug, Clone, Default)]
282pub struct AccountUpdate {
283    /// The account ID this update belongs to.
284    pub account_id: String,
285    /// Profit and loss (unrealised).
286    pub pnl: Option<f64>,
287    /// Total deposit.
288    pub deposit: Option<f64>,
289    /// Available cash.
290    pub available_cash: Option<f64>,
291    /// Funds (equity - margin).
292    pub funds: Option<f64>,
293    /// Total margin in use.
294    pub margin: Option<f64>,
295    /// Limited-risk margin.
296    pub margin_lr: Option<f64>,
297    /// Non-limited-risk margin.
298    pub margin_nlr: Option<f64>,
299    /// Amount available to deal.
300    pub available_to_deal: Option<f64>,
301    /// Equity value.
302    pub equity: Option<f64>,
303    /// Equity used (percentage).
304    pub equity_used: Option<f64>,
305}
306
307/// Field indices for `ACCOUNT:<accountId>`.
308pub(crate) const ACCOUNT_FIELDS: &[&str] = &[
309    "PNL",
310    "DEPOSIT",
311    "AVAILABLE_CASH",
312    "FUNDS",
313    "MARGIN",
314    "MARGIN_LR",
315    "MARGIN_NLR",
316    "AVAILABLE_TO_DEAL",
317    "EQUITY",
318    "EQUITY_USED",
319];
320
321impl AccountUpdate {
322    pub fn from_raw(account_id: &str, state: &[Option<String>]) -> Self {
323        let get = |i: usize| state.get(i).and_then(|v| v.as_deref());
324        let pf = |i: usize| get(i).and_then(|s| s.parse::<f64>().ok());
325        Self {
326            account_id: account_id.to_owned(),
327            pnl: pf(0),
328            deposit: pf(1),
329            available_cash: pf(2),
330            funds: pf(3),
331            margin: pf(4),
332            margin_lr: pf(5),
333            margin_nlr: pf(6),
334            available_to_deal: pf(7),
335            equity: pf(8),
336            equity_used: pf(9),
337        }
338    }
339}
340
341// ---------------------------------------------------------------------------
342// TRADE:<accountId>  — DISTINCT mode
343// ---------------------------------------------------------------------------
344
345/// Nested type for a trade `CONFIRMS` JSON payload.
346#[derive(Debug, Clone, Deserialize, Serialize)]
347#[serde(rename_all = "camelCase")]
348pub struct TradeConfirm {
349    /// IG deal reference.
350    pub deal_reference: Option<String>,
351    /// IG deal ID.
352    pub deal_id: Option<String>,
353    /// Affected epic.
354    pub epic: Option<String>,
355    /// Status code (e.g. `"AMENDED"`, `"CLOSED"`, `"DELETED"`, `"OPEN"`, `"PARTIALLY_CLOSED"`).
356    pub status: Option<String>,
357    /// Deal status (e.g. `"ACCEPTED"`, `"REJECTED"`).
358    pub deal_status: Option<String>,
359    /// Any extra fields from the payload.
360    #[serde(flatten)]
361    pub extra: serde_json::Map<String, serde_json::Value>,
362}
363
364/// Nested type for an open-position update (`OPU`) JSON payload.
365#[derive(Debug, Clone, Deserialize, Serialize)]
366#[serde(rename_all = "camelCase")]
367pub struct OpenPositionUpdate {
368    /// IG deal ID.
369    pub deal_id: Option<String>,
370    /// Deal status.
371    pub deal_status: Option<String>,
372    /// Direction (`BUY` / `SELL`).
373    pub direction: Option<String>,
374    /// Epic.
375    pub epic: Option<String>,
376    /// Level at which the position was opened.
377    pub level: Option<f64>,
378    /// Size of the position.
379    pub size: Option<f64>,
380    /// Current price.
381    pub price: Option<f64>,
382    /// Status string.
383    pub status: Option<String>,
384    /// Any extra fields from the payload.
385    #[serde(flatten)]
386    pub extra: serde_json::Map<String, serde_json::Value>,
387}
388
389/// Nested type for a working-order update (`WOU`) JSON payload.
390#[derive(Debug, Clone, Deserialize, Serialize)]
391#[serde(rename_all = "camelCase")]
392pub struct WorkingOrderUpdate {
393    /// IG deal ID.
394    pub deal_id: Option<String>,
395    /// Deal status.
396    pub deal_status: Option<String>,
397    /// Epic.
398    pub epic: Option<String>,
399    /// Target level.
400    pub level: Option<f64>,
401    /// Status string.
402    pub status: Option<String>,
403    /// Any extra fields from the payload.
404    #[serde(flatten)]
405    pub extra: serde_json::Map<String, serde_json::Value>,
406}
407
408/// An update from a `TRADE:<accountId>` subscription.
409///
410/// `CONFIRMS`, `OPU`, and `WOU` fields are JSON-encoded strings on the wire;
411/// they are decoded here into structured types.
412#[derive(Debug, Clone)]
413pub struct TradeUpdate {
414    /// The account ID this update belongs to.
415    pub account_id: String,
416    /// Trade confirmation (deal accepted/rejected).
417    pub confirms: Option<TradeConfirm>,
418    /// Open-position update.
419    pub opu: Option<OpenPositionUpdate>,
420    /// Working-order update.
421    pub wou: Option<WorkingOrderUpdate>,
422}
423
424/// Field indices for `TRADE:<accountId>`.
425pub(crate) const TRADE_FIELDS: &[&str] = &["CONFIRMS", "OPU", "WOU"];
426
427impl TradeUpdate {
428    pub fn from_raw(account_id: &str, state: &[Option<String>]) -> Self {
429        let parse_str = |i: usize| state.get(i).and_then(|v| v.as_deref());
430        Self {
431            account_id: account_id.to_owned(),
432            confirms: parse_str(0).and_then(|s| serde_json::from_str(s).ok()),
433            opu: parse_str(1).and_then(|s| serde_json::from_str(s).ok()),
434            wou: parse_str(2).and_then(|s| serde_json::from_str(s).ok()),
435        }
436    }
437}