Skip to main content

oanda_rs/endpoints/
accounts.rs

1//! Account endpoints: listing, summaries, tradeable instruments,
2//! configuration, and change polling.
3
4use serde::{Deserialize, Serialize};
5
6use crate::client::Client;
7use crate::error::Error;
8use crate::models::transaction::{ClientConfigureRejectTransaction, ClientConfigureTransaction};
9use crate::models::{
10    Account, AccountChanges, AccountChangesState, AccountId, AccountProperties, AccountSummary,
11    DecimalNumber, Instrument, InstrumentName, TransactionId,
12};
13
14impl Client {
15    /// Get the full details for a single account, including a full list of
16    /// its open trades, open positions, and pending orders.
17    ///
18    /// `GET /v3/accounts/{accountID}`
19    pub async fn account(
20        &self,
21        account_id: impl Into<AccountId>,
22    ) -> Result<AccountResponse, Error> {
23        let account_id = account_id.into();
24        self.execute(self.get(&["accounts", account_id.as_str()]))
25            .await
26    }
27
28    /// Poll an account for its current state and changes since a specified
29    /// transaction ID.
30    ///
31    /// This is OANDA's recommended pattern for tracking account state:
32    /// fetch [`Client::account`] once, then poll this endpoint with the
33    /// last seen transaction ID and apply the returned
34    /// [`AccountChanges`] to your snapshot.
35    ///
36    /// `GET /v3/accounts/{accountID}/changes`
37    pub fn account_changes(&self, account_id: impl Into<AccountId>) -> AccountChangesRequest {
38        AccountChangesRequest {
39            client: self.clone(),
40            account_id: account_id.into(),
41            since_transaction_id: None,
42        }
43    }
44    /// Get a list of all accounts authorized for the provided token.
45    ///
46    /// `GET /v3/accounts`
47    ///
48    /// # Examples
49    ///
50    /// ```no_run
51    /// # async fn run() -> Result<(), oanda_rs::Error> {
52    /// # let client = oanda_rs::Client::new(oanda_rs::Environment::Practice, "token");
53    /// for account in client.list_accounts().await?.accounts {
54    ///     println!("{:?}", account.id);
55    /// }
56    /// # Ok(())
57    /// # }
58    /// ```
59    pub async fn list_accounts(&self) -> Result<ListAccountsResponse, Error> {
60        self.execute(self.get(&["accounts"])).await
61    }
62
63    /// Get a summary for a single account.
64    ///
65    /// `GET /v3/accounts/{accountID}/summary`
66    ///
67    /// # Examples
68    ///
69    /// ```no_run
70    /// # async fn run() -> Result<(), oanda_rs::Error> {
71    /// # let client = oanda_rs::Client::new(oanda_rs::Environment::Practice, "token");
72    /// let summary = client.account_summary("101-004-1234567-001").await?.account;
73    /// println!("balance: {:?}", summary.balance);
74    /// # Ok(())
75    /// # }
76    /// ```
77    pub async fn account_summary(
78        &self,
79        account_id: impl Into<AccountId>,
80    ) -> Result<AccountSummaryResponse, Error> {
81        let account_id = account_id.into();
82        self.execute(self.get(&["accounts", account_id.as_str(), "summary"]))
83            .await
84    }
85
86    /// Get the list of tradeable instruments for the given account. The
87    /// list of tradeable instruments is dependent on the regulatory division
88    /// that the account is located in.
89    ///
90    /// `GET /v3/accounts/{accountID}/instruments`
91    ///
92    /// # Examples
93    ///
94    /// ```no_run
95    /// # async fn run() -> Result<(), oanda_rs::Error> {
96    /// # let client = oanda_rs::Client::new(oanda_rs::Environment::Practice, "token");
97    /// use oanda_rs::models::InstrumentName;
98    ///
99    /// // All instruments:
100    /// let all = client.account_instruments("101-004-1234567-001").send().await?;
101    /// // Or a specific subset:
102    /// let some = client
103    ///     .account_instruments("101-004-1234567-001")
104    ///     .instruments([InstrumentName::EurUsd, InstrumentName::XauUsd])
105    ///     .send()
106    ///     .await?;
107    /// # Ok(())
108    /// # }
109    /// ```
110    pub fn account_instruments(
111        &self,
112        account_id: impl Into<AccountId>,
113    ) -> AccountInstrumentsRequest {
114        AccountInstrumentsRequest {
115            client: self.clone(),
116            account_id: account_id.into(),
117            instruments: None,
118        }
119    }
120
121    /// Set client-configurable properties of the account: its alias and
122    /// margin rate.
123    ///
124    /// `PATCH /v3/accounts/{accountID}/configuration`
125    ///
126    /// # Errors
127    ///
128    /// Rejections (HTTP 400/403) carry a
129    /// [`ClientConfigureRejectTransaction`]; recover it with
130    /// [`ApiErrorBody::details::<ConfigureAccountRejectBody>`](crate::ApiErrorBody::details).
131    ///
132    /// # Examples
133    ///
134    /// ```no_run
135    /// # async fn run() -> Result<(), oanda_rs::Error> {
136    /// # let client = oanda_rs::Client::new(oanda_rs::Environment::Practice, "token");
137    /// use oanda_rs::models::DecimalNumber;
138    ///
139    /// client
140    ///     .configure_account("101-004-1234567-001")
141    ///     .alias("my-strategy-account")
142    ///     .margin_rate("0.02".parse::<DecimalNumber>().unwrap())
143    ///     .send()
144    ///     .await?;
145    /// # Ok(())
146    /// # }
147    /// ```
148    pub fn configure_account(&self, account_id: impl Into<AccountId>) -> ConfigureAccountRequest {
149        ConfigureAccountRequest {
150            client: self.clone(),
151            account_id: account_id.into(),
152            body: ConfigureAccountBody {
153                alias: None,
154                margin_rate: None,
155            },
156        }
157    }
158}
159
160/// Response of [`Client::account`].
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[non_exhaustive]
163pub struct AccountResponse {
164    /// The full details of the requested account.
165    #[serde(rename = "account")]
166    pub account: Account,
167    /// The ID of the most recent transaction created for the account.
168    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
169    pub last_transaction_id: Option<TransactionId>,
170}
171
172/// Builder for [`Client::account_changes`].
173#[derive(Debug)]
174pub struct AccountChangesRequest {
175    client: Client,
176    account_id: AccountId,
177    since_transaction_id: Option<TransactionId>,
178}
179
180impl AccountChangesRequest {
181    /// The ID of the transaction to get account changes since.
182    pub fn since_transaction_id(mut self, id: impl Into<TransactionId>) -> Self {
183        self.since_transaction_id = Some(id.into());
184        self
185    }
186
187    /// Performs the request.
188    pub async fn send(self) -> Result<AccountChangesResponse, Error> {
189        let mut request = self
190            .client
191            .get(&["accounts", self.account_id.as_str(), "changes"]);
192        if let Some(id) = &self.since_transaction_id {
193            request = request.query(&[("sinceTransactionID", id.as_str())]);
194        }
195        self.client.execute(request).await
196    }
197}
198
199/// Response of [`Client::account_changes`].
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[non_exhaustive]
202pub struct AccountChangesResponse {
203    /// The changes to the account's orders, trades and positions since the
204    /// specified transaction ID. Only present when a `sinceTransactionID`
205    /// was provided.
206    #[serde(rename = "changes", skip_serializing_if = "Option::is_none")]
207    pub changes: Option<AccountChanges>,
208    /// The account's current price-dependent state.
209    #[serde(rename = "state", skip_serializing_if = "Option::is_none")]
210    pub state: Option<AccountChangesState>,
211    /// The ID of the last transaction created for the account (to be used
212    /// as the `sinceTransactionID` of the next poll).
213    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
214    pub last_transaction_id: Option<TransactionId>,
215}
216
217/// Response of [`Client::list_accounts`].
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[non_exhaustive]
220pub struct ListAccountsResponse {
221    /// The list of accounts the client is authorized to access and their
222    /// associated properties.
223    #[serde(rename = "accounts", default, skip_serializing_if = "Vec::is_empty")]
224    pub accounts: Vec<AccountProperties>,
225}
226
227/// Response of [`Client::account_summary`].
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[non_exhaustive]
230pub struct AccountSummaryResponse {
231    /// The summary of the requested account.
232    #[serde(rename = "account")]
233    pub account: AccountSummary,
234    /// The ID of the most recent transaction created for the account.
235    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
236    pub last_transaction_id: Option<TransactionId>,
237}
238
239/// Builder for [`Client::account_instruments`].
240#[derive(Debug)]
241pub struct AccountInstrumentsRequest {
242    client: Client,
243    account_id: AccountId,
244    instruments: Option<Vec<InstrumentName>>,
245}
246
247impl AccountInstrumentsRequest {
248    /// Restricts the response to the given instruments (defaults to all
249    /// instruments tradeable by the account).
250    pub fn instruments<I>(mut self, instruments: I) -> Self
251    where
252        I: IntoIterator,
253        I::Item: Into<InstrumentName>,
254    {
255        self.instruments = Some(instruments.into_iter().map(Into::into).collect());
256        self
257    }
258
259    /// Performs the request.
260    pub async fn send(self) -> Result<AccountInstrumentsResponse, Error> {
261        let mut request = self
262            .client
263            .get(&["accounts", self.account_id.as_str(), "instruments"]);
264        if let Some(instruments) = &self.instruments {
265            request = request.query(&[("instruments", join_names(instruments))]);
266        }
267        self.client.execute(request).await
268    }
269}
270
271/// Response of [`Client::account_instruments`].
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[non_exhaustive]
274pub struct AccountInstrumentsResponse {
275    /// The requested list of instruments.
276    #[serde(rename = "instruments", default, skip_serializing_if = "Vec::is_empty")]
277    pub instruments: Vec<Instrument>,
278    /// The ID of the most recent transaction created for the account.
279    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
280    pub last_transaction_id: Option<TransactionId>,
281}
282
283/// Builder for [`Client::configure_account`].
284#[derive(Debug)]
285pub struct ConfigureAccountRequest {
286    client: Client,
287    account_id: AccountId,
288    body: ConfigureAccountBody,
289}
290
291#[derive(Debug, Serialize)]
292struct ConfigureAccountBody {
293    #[serde(rename = "alias", skip_serializing_if = "Option::is_none")]
294    alias: Option<String>,
295    #[serde(rename = "marginRate", skip_serializing_if = "Option::is_none")]
296    margin_rate: Option<DecimalNumber>,
297}
298
299impl ConfigureAccountRequest {
300    /// Sets the client-defined alias for the account.
301    pub fn alias(mut self, alias: impl Into<String>) -> Self {
302        self.body.alias = Some(alias.into());
303        self
304    }
305
306    /// Sets the margin rate override for the account.
307    pub fn margin_rate(mut self, margin_rate: impl Into<DecimalNumber>) -> Self {
308        self.body.margin_rate = Some(margin_rate.into());
309        self
310    }
311
312    /// Performs the request.
313    pub async fn send(self) -> Result<ConfigureAccountResponse, Error> {
314        let request = self
315            .client
316            .patch(&["accounts", self.account_id.as_str(), "configuration"])
317            .json(&self.body);
318        self.client.execute(request).await
319    }
320}
321
322/// Response of [`Client::configure_account`].
323#[derive(Debug, Clone, Serialize, Deserialize)]
324#[non_exhaustive]
325pub struct ConfigureAccountResponse {
326    /// The transaction that configured the account.
327    #[serde(
328        rename = "clientConfigureTransaction",
329        skip_serializing_if = "Option::is_none"
330    )]
331    pub client_configure_transaction: Option<ClientConfigureTransaction>,
332    /// The ID of the most recent transaction created for the account.
333    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
334    pub last_transaction_id: Option<TransactionId>,
335}
336
337/// Typed view of the error body returned when
338/// [`Client::configure_account`] is rejected (HTTP 400/403). Recover it via
339/// [`ApiErrorBody::details`](crate::ApiErrorBody::details).
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[non_exhaustive]
342pub struct ConfigureAccountRejectBody {
343    /// The transaction that rejected the account configuration.
344    #[serde(
345        rename = "clientConfigureRejectTransaction",
346        skip_serializing_if = "Option::is_none"
347    )]
348    pub client_configure_reject_transaction: Option<ClientConfigureRejectTransaction>,
349    /// The ID of the most recent transaction created for the account.
350    #[serde(rename = "lastTransactionID", skip_serializing_if = "Option::is_none")]
351    pub last_transaction_id: Option<TransactionId>,
352}
353
354/// Joins instrument names into OANDA's comma-separated list format.
355pub(crate) fn join_names(instruments: &[InstrumentName]) -> String {
356    instruments
357        .iter()
358        .map(InstrumentName::as_str)
359        .collect::<Vec<_>>()
360        .join(",")
361}