trading-ig 0.1.1

Async Rust client for the IG Markets REST and Lightstreamer streaming APIs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! Typed streaming update events for each subscription kind.
//!
//! These structs are what callers receive from the
//! `tokio::sync::mpsc::Receiver<T>` channels returned by the subscription
//! helpers on [`crate::streaming::StreamingClient`].
//!
//! Fields are `Option<f64>` / `Option<String>` etc. because:
//! - Lightstreamer may send `#` (null) for any field at any time.
//! - The "unchanged" sentinel is resolved before the event is emitted, so by
//!   the time a caller sees an event every field either has a value or is
//!   `None`.

use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// MARKET:<epic>  — MERGE mode
// ---------------------------------------------------------------------------

/// A single update from a `MARKET:<epic>` subscription.
///
/// Fields mirror the IG Lightstreamer `MARKET` adapter fields.
/// `None` means the server sent `null` (or the field has never been populated).
#[derive(Debug, Clone, Default)]
pub struct MarketUpdate {
    /// The IG epic this update belongs to.
    pub epic: String,
    /// Best bid price.
    pub bid: Option<f64>,
    /// Best offer (ask) price.
    pub offer: Option<f64>,
    /// Today's high price.
    pub high: Option<f64>,
    /// Today's low price.
    pub low: Option<f64>,
    /// Mid price at open.
    pub mid_open: Option<f64>,
    /// Net change vs. previous close.
    pub change: Option<f64>,
    /// Percentage change vs. previous close.
    pub change_pct: Option<f64>,
    /// Server-side update timestamp (HH:MM:SS string).
    pub update_time: Option<String>,
    /// Whether price quotes are delayed (`true`) or live (`false`).
    pub market_delay: Option<bool>,
    /// Market state string (e.g. `"TRADEABLE"`, `"CLOSED"`).
    pub market_state: Option<String>,
}

/// Field indices for `MARKET:<epic>`.
pub(crate) const MARKET_FIELDS: &[&str] = &[
    "BID",
    "OFFER",
    "HIGH",
    "LOW",
    "MID_OPEN",
    "CHANGE",
    "CHANGE_PCT",
    "UPDATE_TIME",
    "MARKET_DELAY",
    "MARKET_STATE",
];

impl MarketUpdate {
    /// Construct from a raw field-value slice (in `MARKET_FIELDS` order).
    pub fn from_raw(epic: &str, state: &[Option<String>]) -> Self {
        let get = |i: usize| state.get(i).and_then(|v| v.as_deref());
        Self {
            epic: epic.to_owned(),
            bid: get(0).and_then(|s| s.parse().ok()),
            offer: get(1).and_then(|s| s.parse().ok()),
            high: get(2).and_then(|s| s.parse().ok()),
            low: get(3).and_then(|s| s.parse().ok()),
            mid_open: get(4).and_then(|s| s.parse().ok()),
            change: get(5).and_then(|s| s.parse().ok()),
            change_pct: get(6).and_then(|s| s.parse().ok()),
            update_time: get(7).map(str::to_owned),
            market_delay: get(8).and_then(|s| match s {
                "0" | "false" => Some(false),
                "1" | "true" => Some(true),
                _ => None,
            }),
            market_state: get(9).map(str::to_owned),
        }
    }
}

// ---------------------------------------------------------------------------
// CHART:<epic>:TICK  — DISTINCT mode
// ---------------------------------------------------------------------------

/// A single tick from a `CHART:<epic>:TICK` subscription.
#[derive(Debug, Clone, Default)]
pub struct ChartTickUpdate {
    /// The IG epic this update belongs to.
    pub epic: String,
    /// Bid price for the tick.
    pub bid: Option<f64>,
    /// Offer price for the tick.
    pub ofr: Option<f64>,
    /// Last traded price.
    pub ltp: Option<f64>,
    /// Last traded volume.
    pub ltv: Option<f64>,
    /// Total traded volume today.
    pub ttv: Option<f64>,
    /// UTC millisecond timestamp of the tick.
    pub utm: Option<i64>,
    /// Mid price at open today.
    pub day_open_mid: Option<f64>,
    /// Net change mid today.
    pub day_net_chg_mid: Option<f64>,
    /// Percentage change mid today.
    pub day_perc_chg_mid: Option<f64>,
    /// Today's high.
    pub day_high: Option<f64>,
    /// Today's low.
    pub day_low: Option<f64>,
}

/// Field indices for `CHART:<epic>:TICK`.
pub(crate) const CHART_TICK_FIELDS: &[&str] = &[
    "BID",
    "OFR",
    "LTP",
    "LTV",
    "TTV",
    "UTM",
    "DAY_OPEN_MID",
    "DAY_NET_CHG_MID",
    "DAY_PERC_CHG_MID",
    "DAY_HIGH",
    "DAY_LOW",
];

impl ChartTickUpdate {
    pub fn from_raw(epic: &str, state: &[Option<String>]) -> Self {
        let get = |i: usize| state.get(i).and_then(|v| v.as_deref());
        Self {
            epic: epic.to_owned(),
            bid: get(0).and_then(|s| s.parse().ok()),
            ofr: get(1).and_then(|s| s.parse().ok()),
            ltp: get(2).and_then(|s| s.parse().ok()),
            ltv: get(3).and_then(|s| s.parse().ok()),
            ttv: get(4).and_then(|s| s.parse().ok()),
            utm: get(5).and_then(|s| s.parse().ok()),
            day_open_mid: get(6).and_then(|s| s.parse().ok()),
            day_net_chg_mid: get(7).and_then(|s| s.parse().ok()),
            day_perc_chg_mid: get(8).and_then(|s| s.parse().ok()),
            day_high: get(9).and_then(|s| s.parse().ok()),
            day_low: get(10).and_then(|s| s.parse().ok()),
        }
    }
}

// ---------------------------------------------------------------------------
// CHART:<epic>:<scale>  — MERGE mode
// ---------------------------------------------------------------------------

/// Candle scale for `CHART:<epic>:<scale>` subscriptions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandleScale {
    /// One-minute candle.
    OneMinute,
    /// Five-minute candle.
    FiveMinute,
    /// One-hour candle.
    Hour,
}

impl CandleScale {
    /// Return the wire-level scale string used in the Lightstreamer item name.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::OneMinute => "1MINUTE",
            Self::FiveMinute => "5MINUTE",
            Self::Hour => "HOUR",
        }
    }
}

impl std::fmt::Display for CandleScale {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A candle update from a `CHART:<epic>:<scale>` subscription.
#[derive(Debug, Clone, Default)]
pub struct ChartCandleUpdate {
    /// The IG epic this update belongs to.
    pub epic: String,
    /// Candle scale.
    pub scale: Option<CandleScale>,
    /// Offer open price.
    pub ofr_open: Option<f64>,
    /// Offer high price.
    pub ofr_high: Option<f64>,
    /// Offer low price.
    pub ofr_low: Option<f64>,
    /// Offer close price.
    pub ofr_close: Option<f64>,
    /// Bid open price.
    pub bid_open: Option<f64>,
    /// Bid high price.
    pub bid_high: Option<f64>,
    /// Bid low price.
    pub bid_low: Option<f64>,
    /// Bid close price.
    pub bid_close: Option<f64>,
    /// Last-traded-price open.
    pub ltp_open: Option<f64>,
    /// Last-traded-price high.
    pub ltp_high: Option<f64>,
    /// Last-traded-price low.
    pub ltp_low: Option<f64>,
    /// Last-traded-price close.
    pub ltp_close: Option<f64>,
    /// Whether the candle is complete (`1`) or still forming (`0`).
    pub cons_end: Option<bool>,
    /// Number of ticks in this candle.
    pub cons_tick_count: Option<i64>,
    /// UTC millisecond timestamp.
    pub utm: Option<i64>,
}

/// Field indices for `CHART:<epic>:<scale>`.
pub(crate) const CHART_CANDLE_FIELDS: &[&str] = &[
    "OFR_OPEN",
    "OFR_HIGH",
    "OFR_LOW",
    "OFR_CLOSE",
    "BID_OPEN",
    "BID_HIGH",
    "BID_LOW",
    "BID_CLOSE",
    "LTP_OPEN",
    "LTP_HIGH",
    "LTP_LOW",
    "LTP_CLOSE",
    "CONS_END",
    "CONS_TICK_COUNT",
    "UTM",
];

impl ChartCandleUpdate {
    pub fn from_raw(epic: &str, scale: CandleScale, state: &[Option<String>]) -> Self {
        let get = |i: usize| state.get(i).and_then(|v| v.as_deref());
        let pf = |i: usize| get(i).and_then(|s| s.parse::<f64>().ok());
        let pi = |i: usize| get(i).and_then(|s| s.parse::<i64>().ok());
        Self {
            epic: epic.to_owned(),
            scale: Some(scale),
            ofr_open: pf(0),
            ofr_high: pf(1),
            ofr_low: pf(2),
            ofr_close: pf(3),
            bid_open: pf(4),
            bid_high: pf(5),
            bid_low: pf(6),
            bid_close: pf(7),
            ltp_open: pf(8),
            ltp_high: pf(9),
            ltp_low: pf(10),
            ltp_close: pf(11),
            cons_end: get(12).and_then(|s| match s {
                "1" => Some(true),
                "0" => Some(false),
                _ => None,
            }),
            cons_tick_count: pi(13),
            utm: pi(14),
        }
    }
}

// ---------------------------------------------------------------------------
// ACCOUNT:<accountId>  — MERGE mode
// ---------------------------------------------------------------------------

/// An update from an `ACCOUNT:<accountId>` subscription.
#[derive(Debug, Clone, Default)]
pub struct AccountUpdate {
    /// The account ID this update belongs to.
    pub account_id: String,
    /// Profit and loss (unrealised).
    pub pnl: Option<f64>,
    /// Total deposit.
    pub deposit: Option<f64>,
    /// Available cash.
    pub available_cash: Option<f64>,
    /// Funds (equity - margin).
    pub funds: Option<f64>,
    /// Total margin in use.
    pub margin: Option<f64>,
    /// Limited-risk margin.
    pub margin_lr: Option<f64>,
    /// Non-limited-risk margin.
    pub margin_nlr: Option<f64>,
    /// Amount available to deal.
    pub available_to_deal: Option<f64>,
    /// Equity value.
    pub equity: Option<f64>,
    /// Equity used (percentage).
    pub equity_used: Option<f64>,
}

/// Field indices for `ACCOUNT:<accountId>`.
pub(crate) const ACCOUNT_FIELDS: &[&str] = &[
    "PNL",
    "DEPOSIT",
    "AVAILABLE_CASH",
    "FUNDS",
    "MARGIN",
    "MARGIN_LR",
    "MARGIN_NLR",
    "AVAILABLE_TO_DEAL",
    "EQUITY",
    "EQUITY_USED",
];

impl AccountUpdate {
    pub fn from_raw(account_id: &str, state: &[Option<String>]) -> Self {
        let get = |i: usize| state.get(i).and_then(|v| v.as_deref());
        let pf = |i: usize| get(i).and_then(|s| s.parse::<f64>().ok());
        Self {
            account_id: account_id.to_owned(),
            pnl: pf(0),
            deposit: pf(1),
            available_cash: pf(2),
            funds: pf(3),
            margin: pf(4),
            margin_lr: pf(5),
            margin_nlr: pf(6),
            available_to_deal: pf(7),
            equity: pf(8),
            equity_used: pf(9),
        }
    }
}

// ---------------------------------------------------------------------------
// TRADE:<accountId>  — DISTINCT mode
// ---------------------------------------------------------------------------

/// Nested type for a trade `CONFIRMS` JSON payload.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TradeConfirm {
    /// IG deal reference.
    pub deal_reference: Option<String>,
    /// IG deal ID.
    pub deal_id: Option<String>,
    /// Affected epic.
    pub epic: Option<String>,
    /// Status code (e.g. `"AMENDED"`, `"CLOSED"`, `"DELETED"`, `"OPEN"`, `"PARTIALLY_CLOSED"`).
    pub status: Option<String>,
    /// Deal status (e.g. `"ACCEPTED"`, `"REJECTED"`).
    pub deal_status: Option<String>,
    /// Any extra fields from the payload.
    #[serde(flatten)]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// Nested type for an open-position update (`OPU`) JSON payload.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenPositionUpdate {
    /// IG deal ID.
    pub deal_id: Option<String>,
    /// Deal status.
    pub deal_status: Option<String>,
    /// Direction (`BUY` / `SELL`).
    pub direction: Option<String>,
    /// Epic.
    pub epic: Option<String>,
    /// Level at which the position was opened.
    pub level: Option<f64>,
    /// Size of the position.
    pub size: Option<f64>,
    /// Current price.
    pub price: Option<f64>,
    /// Status string.
    pub status: Option<String>,
    /// Any extra fields from the payload.
    #[serde(flatten)]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// Nested type for a working-order update (`WOU`) JSON payload.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkingOrderUpdate {
    /// IG deal ID.
    pub deal_id: Option<String>,
    /// Deal status.
    pub deal_status: Option<String>,
    /// Epic.
    pub epic: Option<String>,
    /// Target level.
    pub level: Option<f64>,
    /// Status string.
    pub status: Option<String>,
    /// Any extra fields from the payload.
    #[serde(flatten)]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// An update from a `TRADE:<accountId>` subscription.
///
/// `CONFIRMS`, `OPU`, and `WOU` fields are JSON-encoded strings on the wire;
/// they are decoded here into structured types.
#[derive(Debug, Clone)]
pub struct TradeUpdate {
    /// The account ID this update belongs to.
    pub account_id: String,
    /// Trade confirmation (deal accepted/rejected).
    pub confirms: Option<TradeConfirm>,
    /// Open-position update.
    pub opu: Option<OpenPositionUpdate>,
    /// Working-order update.
    pub wou: Option<WorkingOrderUpdate>,
}

/// Field indices for `TRADE:<accountId>`.
pub(crate) const TRADE_FIELDS: &[&str] = &["CONFIRMS", "OPU", "WOU"];

impl TradeUpdate {
    pub fn from_raw(account_id: &str, state: &[Option<String>]) -> Self {
        let parse_str = |i: usize| state.get(i).and_then(|v| v.as_deref());
        Self {
            account_id: account_id.to_owned(),
            confirms: parse_str(0).and_then(|s| serde_json::from_str(s).ok()),
            opu: parse_str(1).and_then(|s| serde_json::from_str(s).ok()),
            wou: parse_str(2).and_then(|s| serde_json::from_str(s).ok()),
        }
    }
}