Skip to main content

truefix_ig_client/
types.rs

1//! Request and response types for the supported IG REST operations.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
6pub enum Direction {
7    #[serde(rename = "BUY")]
8    Buy,
9    #[serde(rename = "SELL")]
10    Sell,
11}
12
13#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
14pub enum OrderType {
15    #[serde(rename = "MARKET")]
16    Market,
17    #[serde(rename = "LIMIT")]
18    Limit,
19    #[serde(rename = "STOP")]
20    Stop,
21    #[serde(rename = "QUOTE")]
22    Quote,
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
26pub enum TimeInForce {
27    #[serde(rename = "GOOD_TILL_CANCELLED")]
28    GoodTillCancelled,
29    #[serde(rename = "GOOD_TILL_DATE")]
30    GoodTillDate,
31}
32
33#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
34pub enum PositionTimeInForce {
35    #[serde(rename = "FILL_OR_KILL")]
36    FillOrKill,
37    #[serde(rename = "EXECUTE_AND_ELIMINATE")]
38    ExecuteAndEliminate,
39}
40
41#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
42pub enum MarketStatus {
43    #[serde(rename = "TRADEABLE")]
44    Tradeable,
45    #[serde(rename = "CLOSED")]
46    Closed,
47    #[serde(rename = "EDITS_ONLY")]
48    EditsOnly,
49    #[serde(rename = "OFFLINE")]
50    Offline,
51    #[serde(other)]
52    Unknown,
53}
54
55#[derive(Debug, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct LoginResponse {
58    pub current_account_id: Option<String>,
59    pub lightstreamer_endpoint: String,
60    pub client_id: String,
61    pub currency_iso_code: Option<String>,
62    pub dealing_enabled: Option<bool>,
63}
64
65#[derive(Debug, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub(crate) struct V3LoginResponse {
68    pub account_id: Option<String>,
69    pub current_account_id: Option<String>,
70    pub lightstreamer_endpoint: String,
71    pub client_id: String,
72    pub currency_iso_code: Option<String>,
73    pub dealing_enabled: Option<bool>,
74    pub oauth_token: Option<OAuthToken>,
75}
76
77#[derive(Debug, Deserialize)]
78pub(crate) struct OAuthToken {
79    #[serde(alias = "accessToken")]
80    pub access_token: String,
81    #[serde(alias = "refreshToken")]
82    pub refresh_token: String,
83    #[serde(
84        alias = "expiresIn",
85        deserialize_with = "deserialize_u64_string_or_number"
86    )]
87    pub expires_in: u64,
88}
89
90fn deserialize_u64_string_or_number<'de, D>(deserializer: D) -> Result<u64, D::Error>
91where
92    D: serde::Deserializer<'de>,
93{
94    #[derive(Deserialize)]
95    #[serde(untagged)]
96    enum Value {
97        Number(u64),
98        String(String),
99    }
100
101    match Value::deserialize(deserializer)? {
102        Value::Number(value) => Ok(value),
103        Value::String(value) => value.parse().map_err(serde::de::Error::custom),
104    }
105}
106
107#[derive(Debug, Deserialize)]
108pub struct AccountsResponse {
109    pub accounts: Vec<Account>,
110}
111
112#[derive(Debug, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct Account {
115    pub account_id: String,
116    pub account_name: String,
117    pub preferred: bool,
118    pub balance: Option<AccountBalance>,
119    pub currency: Option<String>,
120}
121
122#[derive(Debug, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct AccountBalance {
125    pub balance: f64,
126    pub available: f64,
127    pub deposit: f64,
128    pub profit_loss: f64,
129}
130
131#[derive(Debug, Deserialize)]
132pub struct PositionsResponse {
133    pub positions: Vec<Position>,
134}
135
136#[derive(Debug, Deserialize)]
137pub struct Position {
138    pub position: PositionDetail,
139    pub market: PositionMarket,
140}
141
142#[derive(Debug, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct PositionDetail {
145    pub deal_id: String,
146    pub direction: Direction,
147    pub currency: String,
148    pub size: Option<f64>,
149    pub level: Option<f64>,
150}
151
152#[derive(Debug, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct PositionMarket {
155    pub epic: String,
156    pub instrument_name: String,
157    pub bid: Option<f64>,
158    pub offer: Option<f64>,
159    pub market_status: Option<MarketStatus>,
160}
161
162#[derive(Debug, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct MarketDetails {
165    pub instrument: Instrument,
166    pub snapshot: MarketSnapshot,
167    #[serde(default)]
168    pub dealing_rules: Option<DealingRules>,
169}
170
171#[derive(Debug, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct Instrument {
174    pub name: String,
175    pub epic: String,
176    pub instrument_type: Option<String>,
177    pub expiry: Option<String>,
178    #[serde(default)]
179    pub currencies: Vec<InstrumentCurrency>,
180    #[serde(default)]
181    pub lot_size: Option<f64>,
182    #[serde(default)]
183    pub stops_limits_allowed: Option<bool>,
184    #[serde(default)]
185    pub streaming_prices_available: Option<bool>,
186}
187
188#[derive(Debug, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct InstrumentCurrency {
191    pub code: String,
192    #[serde(default)]
193    pub is_default: bool,
194}
195
196#[derive(Debug, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct DealingRules {
199    #[serde(default)]
200    pub min_deal_size: Option<RuleValue>,
201    #[serde(default)]
202    pub min_normal_stop_or_limit_distance: Option<RuleValue>,
203    #[serde(default)]
204    pub max_stop_or_limit_distance: Option<RuleValue>,
205    #[serde(default)]
206    pub market_order_preference: Option<String>,
207}
208
209#[derive(Debug, Deserialize)]
210pub struct RuleValue {
211    pub value: f64,
212    #[serde(default)]
213    pub unit: Option<String>,
214}
215
216#[derive(Debug, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct MarketSnapshot {
219    pub market_status: MarketStatus,
220    pub bid: Option<f64>,
221    pub offer: Option<f64>,
222    pub high: Option<f64>,
223    pub low: Option<f64>,
224    #[serde(default)]
225    pub decimal_places_factor: Option<u32>,
226}
227
228#[derive(Debug, Deserialize)]
229pub struct MarketsResponse {
230    pub markets: Vec<MarketData>,
231}
232
233/// One account-visible IG instrument category returned by `GET /categories`.
234#[derive(Debug, Clone, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct InstrumentCategory {
237    pub code: String,
238    #[serde(default)]
239    pub name: Option<String>,
240}
241
242/// Account-visible instrument categories for the authenticated account.
243#[derive(Debug, Deserialize)]
244pub struct InstrumentCategoriesResponse {
245    #[serde(default)]
246    pub categories: Vec<InstrumentCategory>,
247}
248
249/// One page returned by `GET /categories/{categoryId}/instruments`.
250#[derive(Debug)]
251pub struct CategoryInstrumentsResponse {
252    pub instruments: Vec<MarketData>,
253}
254
255impl<'de> Deserialize<'de> for CategoryInstrumentsResponse {
256    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
257    where
258        D: serde::Deserializer<'de>,
259    {
260        #[derive(Deserialize)]
261        #[serde(untagged)]
262        enum Wire {
263            Instruments {
264                #[serde(default)]
265                instruments: Vec<MarketData>,
266            },
267            Markets {
268                #[serde(default)]
269                markets: Vec<MarketData>,
270            },
271            Direct(Vec<MarketData>),
272        }
273
274        Ok(Self {
275            instruments: match Wire::deserialize(deserializer)? {
276                Wire::Instruments { instruments } => instruments,
277                Wire::Markets { markets } => markets,
278                Wire::Direct(instruments) => instruments,
279            },
280        })
281    }
282}
283
284/// Detailed account activity used to recover authoritative fills and deal state.
285#[derive(Debug, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct ActivityHistoryResponse {
288    #[serde(default)]
289    pub activities: Vec<ActivityHistoryItem>,
290    #[serde(default)]
291    pub metadata: Option<ActivityHistoryMetadata>,
292}
293
294#[derive(Debug, Deserialize)]
295pub struct ActivityHistoryMetadata {
296    #[serde(default)]
297    pub paging: Option<ActivityHistoryPaging>,
298}
299
300#[derive(Debug, Deserialize)]
301pub struct ActivityHistoryPaging {
302    #[serde(default)]
303    pub next: Option<String>,
304    #[serde(default)]
305    pub size: Option<u64>,
306}
307
308#[derive(Debug, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ActivityHistoryItem {
311    pub date: String,
312    pub deal_id: String,
313    #[serde(default)]
314    pub description: Option<String>,
315    #[serde(default)]
316    pub channel: Option<String>,
317    #[serde(default)]
318    pub status: Option<String>,
319    #[serde(default)]
320    pub details: Option<ActivityHistoryDetails>,
321}
322
323#[derive(Debug, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct ActivityHistoryDetails {
326    #[serde(default)]
327    pub epic: Option<String>,
328    #[serde(default)]
329    pub actions: Vec<ActivityHistoryAction>,
330}
331
332#[derive(Debug, Deserialize)]
333#[serde(rename_all = "camelCase")]
334pub struct ActivityHistoryAction {
335    #[serde(default)]
336    pub action_type: Option<String>,
337    #[serde(default)]
338    pub affected_deal_id: Option<String>,
339    #[serde(default)]
340    pub deal_reference: Option<String>,
341    #[serde(default)]
342    pub direction: Option<Direction>,
343    #[serde(default)]
344    pub level: Option<f64>,
345    #[serde(default)]
346    pub size: Option<f64>,
347    #[serde(default)]
348    pub currency: Option<String>,
349    #[serde(default)]
350    pub market_name: Option<String>,
351}
352
353#[derive(Debug, Deserialize)]
354#[serde(rename_all = "camelCase")]
355pub struct MarketData {
356    pub epic: String,
357    pub instrument_name: String,
358    #[serde(default)]
359    pub instrument_type: Option<String>,
360    #[serde(default)]
361    pub expiry: Option<String>,
362    pub bid: Option<f64>,
363    pub offer: Option<f64>,
364    #[serde(default)]
365    pub market_status: Option<MarketStatus>,
366}
367
368#[derive(Debug, Clone, Default)]
369pub struct HistoricalPricesQuery<'a> {
370    pub resolution: &'a str,
371    pub from: Option<&'a str>,
372    pub to: Option<&'a str>,
373    pub max: Option<u32>,
374}
375impl<'a> HistoricalPricesQuery<'a> {
376    pub fn new(resolution: &'a str) -> Self {
377        Self {
378            resolution,
379            ..Self::default()
380        }
381    }
382    pub fn from(mut self, value: &'a str) -> Self {
383        self.from = Some(value);
384        self
385    }
386    pub fn to(mut self, value: &'a str) -> Self {
387        self.to = Some(value);
388        self
389    }
390    pub fn max(mut self, value: u32) -> Self {
391        self.max = Some(value);
392        self
393    }
394}
395
396#[derive(Debug, Deserialize)]
397pub struct HistoricalPricesResponse {
398    pub prices: Vec<HistoricalPrice>,
399}
400
401#[derive(Debug, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct HistoricalPrice {
404    pub snapshot_time: Option<String>,
405    pub snapshot_time_utc: Option<String>,
406    pub open_price: PricePoint,
407    pub high_price: PricePoint,
408    pub low_price: PricePoint,
409    pub close_price: PricePoint,
410    #[serde(default)]
411    pub last_traded_volume: Option<f64>,
412}
413
414#[derive(Debug, Default, Deserialize)]
415pub struct PricePoint {
416    pub bid: Option<f64>,
417    pub ask: Option<f64>,
418    pub last_traded: Option<f64>,
419}
420
421#[derive(Debug, Serialize)]
422#[serde(rename_all = "camelCase")]
423pub struct CreatePositionRequest {
424    pub currency_code: String,
425    pub direction: Direction,
426    pub epic: String,
427    pub expiry: String,
428    pub force_open: bool,
429    pub guaranteed_stop: bool,
430    pub order_type: OrderType,
431    pub time_in_force: PositionTimeInForce,
432    pub size: f64,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub level: Option<f64>,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub limit_level: Option<f64>,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub stop_level: Option<f64>,
439}
440
441#[derive(Debug, Deserialize)]
442#[serde(rename_all = "camelCase")]
443pub struct DealReferenceResponse {
444    pub deal_reference: String,
445}
446
447#[derive(Debug, Deserialize)]
448#[serde(rename_all = "camelCase")]
449pub struct SwitchAccountResponse {
450    pub dealing_enabled: bool,
451    #[serde(default)]
452    pub has_active_demo_accounts: bool,
453    #[serde(default)]
454    pub has_active_live_accounts: bool,
455    #[serde(default)]
456    pub trailing_stops_enabled: bool,
457}
458
459#[derive(Debug, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct WorkingOrdersResponse {
462    #[serde(default)]
463    pub working_orders: Vec<WorkingOrder>,
464}
465
466#[derive(Debug, Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct WorkingOrder {
469    pub working_order_data: WorkingOrderData,
470    pub market_data: MarketData,
471}
472
473#[derive(Debug, Deserialize)]
474#[serde(rename_all = "camelCase")]
475pub struct WorkingOrderData {
476    pub deal_id: String,
477    #[serde(default)]
478    pub deal_reference: Option<String>,
479    pub direction: Direction,
480    pub epic: String,
481    pub order_size: f64,
482    pub order_level: f64,
483    #[serde(rename = "orderType")]
484    pub order_type: OrderType,
485    pub time_in_force: TimeInForce,
486    #[serde(default)]
487    pub good_till_date: Option<String>,
488    #[serde(default)]
489    pub guaranteed_stop: bool,
490    #[serde(default)]
491    pub stop_level: Option<f64>,
492    #[serde(default)]
493    pub stop_distance: Option<f64>,
494    #[serde(default)]
495    pub limit_level: Option<f64>,
496    #[serde(default)]
497    pub limit_distance: Option<f64>,
498    #[serde(default)]
499    pub currency_code: Option<String>,
500    #[serde(default)]
501    pub created_date: Option<String>,
502}
503
504#[derive(Debug, Serialize)]
505#[serde(rename_all = "camelCase")]
506pub struct CreateWorkingOrderRequest {
507    pub epic: String,
508    pub direction: Direction,
509    pub size: f64,
510    pub level: f64,
511    #[serde(rename = "type")]
512    pub order_type: OrderType,
513    pub time_in_force: TimeInForce,
514    pub guaranteed_stop: bool,
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub stop_level: Option<f64>,
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub stop_distance: Option<f64>,
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub limit_level: Option<f64>,
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub limit_distance: Option<f64>,
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub good_till_date: Option<String>,
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub deal_reference: Option<String>,
527    pub currency_code: String,
528    pub expiry: String,
529}
530
531#[derive(Debug, Serialize)]
532#[serde(rename_all = "camelCase")]
533pub struct UpdateWorkingOrderRequest {
534    pub level: f64,
535    #[serde(rename = "type")]
536    pub order_type: OrderType,
537    pub time_in_force: TimeInForce,
538    pub guaranteed_stop: bool,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub good_till_date: Option<String>,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub stop_level: Option<f64>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub stop_distance: Option<f64>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub limit_level: Option<f64>,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub limit_distance: Option<f64>,
549}
550
551/// Authoritative acknowledgement returned by `GET /confirms/{dealReference}`.
552/// Optional fields keep the client forward-compatible with IG's product- and
553/// rejection-specific response shapes.
554#[derive(Debug, Clone, Deserialize)]
555#[serde(rename_all = "camelCase")]
556pub struct DealConfirmation {
557    pub deal_reference: String,
558    #[serde(default)]
559    pub deal_id: Option<String>,
560    pub deal_status: DealStatus,
561    #[serde(default)]
562    pub reason: Option<String>,
563    #[serde(default)]
564    pub status: Option<String>,
565    #[serde(default)]
566    pub level: Option<f64>,
567    #[serde(default)]
568    pub size: Option<f64>,
569}
570
571#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
572#[serde(rename_all = "UPPERCASE")]
573pub enum DealStatus {
574    Accepted,
575    Rejected,
576    #[serde(other)]
577    Unknown,
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    #[test]
585    fn deal_confirmation_accepts_product_specific_optional_fields() {
586        let confirmation: DealConfirmation = serde_json::from_value(serde_json::json!({
587            "dealReference": "reference-1",
588            "dealId": "deal-1",
589            "dealStatus": "ACCEPTED",
590            "status": "OPEN",
591            "level": 123.45,
592            "size": 2,
593            "epic": "CS.D.AAPL.CFD.IP"
594        }))
595        .unwrap();
596        assert_eq!(confirmation.deal_status, DealStatus::Accepted);
597        assert_eq!(confirmation.deal_id.as_deref(), Some("deal-1"));
598    }
599
600    #[test]
601    fn category_catalogue_accepts_account_visible_instrument_pages() {
602        let categories: InstrumentCategoriesResponse = serde_json::from_value(serde_json::json!({
603            "categories": [{"code": "INDICES", "name": "Indices"}]
604        }))
605        .unwrap();
606        assert_eq!(categories.categories[0].code, "INDICES");
607
608        let page: CategoryInstrumentsResponse = serde_json::from_value(serde_json::json!({
609            "instruments": [{
610                "epic": "IX.D.FTSE.CFD.IP",
611                "instrumentName": "FTSE 100",
612                "instrumentType": "INDICES",
613                "expiry": "-",
614                "marketStatus": "TRADEABLE"
615            }]
616        }))
617        .unwrap();
618        assert_eq!(page.instruments[0].epic, "IX.D.FTSE.CFD.IP");
619        assert_eq!(
620            page.instruments[0].market_status,
621            Some(MarketStatus::Tradeable)
622        );
623        let direct: CategoryInstrumentsResponse = serde_json::from_value(serde_json::json!([{
624            "epic": "CS.D.EURUSD.CFD.IP",
625            "instrumentName": "EUR/USD",
626            "instrumentType": "CURRENCIES",
627            "expiry": "DFB"
628        }]))
629        .unwrap();
630        assert_eq!(direct.instruments[0].epic, "CS.D.EURUSD.CFD.IP");
631    }
632
633    #[test]
634    fn detailed_activity_accepts_fill_evidence() {
635        let response: ActivityHistoryResponse = serde_json::from_value(serde_json::json!({
636            "activities": [{
637                "date": "2026-08-12T06:00:00",
638                "dealId": "deal-1",
639                "status": "ACCEPTED",
640                "details": {
641                    "epic": "CS.D.EURUSD.CFD.IP",
642                    "actions": [{
643                        "actionType": "POSITION_OPENED",
644                        "affectedDealId": "deal-1",
645                        "direction": "BUY",
646                        "level": 1.1,
647                        "size": 2.0
648                    }]
649                }
650            }],
651            "metadata": {"paging": {"next": null, "size": 1}}
652        }))
653        .unwrap();
654        assert_eq!(response.activities[0].deal_id, "deal-1");
655        assert_eq!(
656            response.activities[0]
657                .details
658                .as_ref()
659                .unwrap()
660                .actions
661                .len(),
662            1
663        );
664    }
665
666    #[test]
667    fn otc_market_order_serializes_required_time_in_force() {
668        let request = CreatePositionRequest {
669            currency_code: "USD".into(),
670            direction: Direction::Buy,
671            epic: "IX.D.DAX.IFD.IP".into(),
672            expiry: "DFB".into(),
673            force_open: true,
674            guaranteed_stop: false,
675            order_type: OrderType::Market,
676            time_in_force: PositionTimeInForce::FillOrKill,
677            size: 1.0,
678            level: None,
679            limit_level: None,
680            stop_level: None,
681        };
682        let encoded = serde_json::to_value(request).unwrap();
683        assert_eq!(encoded["timeInForce"], "FILL_OR_KILL");
684    }
685}