1use 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#[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#[derive(Debug, Deserialize)]
244pub struct InstrumentCategoriesResponse {
245 #[serde(default)]
246 pub categories: Vec<InstrumentCategory>,
247}
248
249#[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#[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#[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}