Skip to main content

oanda_client/
units.rs

1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Exact closeout quantities and the provider's symbolic closeout instructions.
5
6use crate::validation::Validate;
7use crate::{Error, Result};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11/// Trade closeout units, also used in executed position-closeout evidence.
12///
13/// Omitting a trade close request's units has the same effect as [`Self::All`].
14/// Decimal evidence is preserved when decoded; outgoing requests require a positive quantity.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TradeCloseoutUnits {
17    /// Close all remaining units.
18    All,
19    /// An exact number of units. Outgoing requests require this value to be positive.
20    Units(Decimal),
21}
22
23/// Units to close on one side of a position.
24///
25/// Omitting a position close request's side has the same effect as [`Self::All`].
26/// Use [`Self::None`] explicitly to leave that side open.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PositionCloseoutUnits {
29    /// Close all units on this side.
30    All,
31    /// Leave this side open.
32    None,
33    /// An exact number of units. Outgoing requests require this value to be positive.
34    Units(Decimal),
35}
36
37impl Serialize for TradeCloseoutUnits {
38    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
39        match self {
40            Self::All => serializer.serialize_str("ALL"),
41            Self::Units(value) => serializer.collect_str(value),
42        }
43    }
44}
45
46impl<'de> Deserialize<'de> for TradeCloseoutUnits {
47    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
48        let value = String::deserialize(deserializer)?;
49        match value.as_str() {
50            "ALL" => Ok(Self::All),
51            _ => Decimal::from_str_exact(&value)
52                .map(Self::Units)
53                .map_err(serde::de::Error::custom),
54        }
55    }
56}
57
58impl Serialize for PositionCloseoutUnits {
59    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
60        match self {
61            Self::All => serializer.serialize_str("ALL"),
62            Self::None => serializer.serialize_str("NONE"),
63            Self::Units(value) => serializer.collect_str(value),
64        }
65    }
66}
67
68impl<'de> Deserialize<'de> for PositionCloseoutUnits {
69    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
70        let value = String::deserialize(deserializer)?;
71        match value.as_str() {
72            "ALL" => Ok(Self::All),
73            "NONE" => Ok(Self::None),
74            _ => Decimal::from_str_exact(&value)
75                .map(Self::Units)
76                .map_err(serde::de::Error::custom),
77        }
78    }
79}
80
81impl Validate for TradeCloseoutUnits {
82    fn validate(&self) -> Result<()> {
83        match self {
84            Self::All => Ok(()),
85            Self::Units(value) => positive(*value),
86        }
87    }
88}
89
90impl Validate for PositionCloseoutUnits {
91    fn validate(&self) -> Result<()> {
92        match self {
93            Self::All | Self::None => Ok(()),
94            Self::Units(value) => positive(*value),
95        }
96    }
97}
98
99fn positive(value: Decimal) -> Result<()> {
100    if value <= Decimal::ZERO {
101        return Err(Error::InvalidInput("close units must be positive".into()));
102    }
103    Ok(())
104}
105
106#[cfg(test)]
107mod tests {
108    use super::{PositionCloseoutUnits, TradeCloseoutUnits};
109    use crate::validation::Validate;
110    use rust_decimal::Decimal;
111
112    #[test]
113    fn exact_closeout_quantities_reject_rounding() -> Result<(), Box<dyn std::error::Error>> {
114        let wire = "\"0.1234567890123456789012345678\"";
115        let quantity: TradeCloseoutUnits = serde_json::from_str(wire)?;
116        assert_eq!(serde_json::to_string(&quantity)?, wire);
117        assert!(
118            serde_json::from_str::<TradeCloseoutUnits>("\"0.12345678901234567890123456789\"")
119                .is_err()
120        );
121        assert!(
122            serde_json::from_str::<PositionCloseoutUnits>("\"0.12345678901234567890123456789\"")
123                .is_err()
124        );
125        assert!(serde_json::from_str::<TradeCloseoutUnits>("\"NONE\"").is_err());
126        Ok(())
127    }
128
129    #[test]
130    fn invalid_outgoing_quantity_keeps_decoded_evidence() -> Result<(), Box<dyn std::error::Error>>
131    {
132        let quantity: TradeCloseoutUnits = serde_json::from_str("\"-1.25\"")?;
133        assert!(quantity.validate().is_err());
134        assert_eq!(serde_json::to_string(&quantity)?, "\"-1.25\"");
135        assert!(
136            PositionCloseoutUnits::Units(Decimal::ZERO)
137                .validate()
138                .is_err()
139        );
140        assert!(PositionCloseoutUnits::All.validate().is_ok());
141        assert!(PositionCloseoutUnits::None.validate().is_ok());
142        Ok(())
143    }
144}