use std::collections::HashMap;
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::core::{
HttpClient, Credentials,
ExchangeId, ExchangeType, AccountType, Symbol,
ExchangeError, ExchangeResult,
Price, Kline, Ticker, OrderBook,
Order, OrderSide, OrderType, Balance, AccountInfo,
Position, FundingRate, SymbolInfo,
OrderRequest, CancelRequest, CancelScope,
AmendRequest,
BalanceQuery, PositionQuery, PositionModification,
OrderHistoryFilter, PlaceOrderResponse, FeeInfo,
AmendOrder,
};
use crate::core::traits::{
ExchangeIdentity, MarketData, Trading, Account, Positions,
};
use crate::core::utils::precision::PrecisionCache;
use super::endpoints::{OandaUrls, OandaEndpoint, format_symbol, map_granularity};
use super::auth::OandaAuth;
use super::parser::OandaParser;
pub struct OandaConnector {
http: HttpClient,
auth: OandaAuth,
urls: OandaUrls,
practice: bool,
account_id: Option<String>,
precision: PrecisionCache,
}
impl OandaConnector {
pub async fn new(credentials: Credentials, practice: bool) -> ExchangeResult<Self> {
let urls = if practice {
OandaUrls::PRACTICE
} else {
OandaUrls::LIVE
};
let http = HttpClient::new(30_000)?; let auth = OandaAuth::new(&credentials)?;
Ok(Self {
http,
auth,
urls,
practice,
account_id: None,
precision: PrecisionCache::new(),
})
}
async fn get_account_id(&mut self) -> ExchangeResult<String> {
if let Some(ref id) = self.account_id {
return Ok(id.clone());
}
let endpoint = OandaEndpoint::ListAccounts;
let response = self.get(endpoint, HashMap::new()).await?;
let account_id = OandaParser::parse_account_id(&response)?;
self.account_id = Some(account_id.clone());
Ok(account_id)
}
fn require_account_id(&self) -> ExchangeResult<&str> {
self.account_id.as_deref()
.ok_or_else(|| ExchangeError::Auth("Account ID not initialized".to_string()))
}
async fn get(
&self,
endpoint: OandaEndpoint,
params: HashMap<String, String>,
) -> ExchangeResult<Value> {
let base_url = self.urls.rest_url;
let path = endpoint.path();
let query = if params.is_empty() {
String::new()
} else {
let qs: Vec<String> = params.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
format!("?{}", qs.join("&"))
};
let url = format!("{}{}{}", base_url, path, query);
let headers = self.auth.sign_request();
let response = self.http.get_with_headers(&url, &HashMap::new(), &headers).await?;
self.check_response(&response)?;
Ok(response)
}
async fn post(
&self,
endpoint: OandaEndpoint,
body: Value,
) -> ExchangeResult<Value> {
let base_url = self.urls.rest_url;
let path = endpoint.path();
let url = format!("{}{}", base_url, path);
let headers = self.auth.sign_request();
let response = self.http.post(&url, &body, &headers).await?;
self.check_response(&response)?;
Ok(response)
}
async fn put(
&self,
endpoint: OandaEndpoint,
body: Value,
) -> ExchangeResult<Value> {
let base_url = self.urls.rest_url;
let path = endpoint.path();
let url = format!("{}{}", base_url, path);
let headers = self.auth.sign_request();
let response = self.http.put(&url, &body, &headers).await?;
self.check_response(&response)?;
Ok(response)
}
fn check_response(&self, response: &Value) -> ExchangeResult<()> {
if let Some(error_msg) = response.get("errorMessage") {
let msg = error_msg.as_str().unwrap_or("Unknown error");
if let Some(code) = response.get("errorCode").and_then(|c| c.as_str()) {
return Err(ExchangeError::Api {
code: -1,
message: format!("{}: {}", code, msg),
});
}
return Err(ExchangeError::Api {
code: -1,
message: msg.to_string(),
});
}
Ok(())
}
pub async fn get_instruments(&mut self) -> ExchangeResult<Vec<String>> {
let account_id = self.get_account_id().await?;
let endpoint = OandaEndpoint::GetInstruments(account_id);
let response = self.get(endpoint, HashMap::new()).await?;
let instruments = response.get("instruments")
.and_then(|i| i.as_array())
.map(|arr| {
arr.iter()
.filter_map(|inst| inst.get("name").and_then(|n| n.as_str()))
.map(String::from)
.collect()
})
.unwrap_or_default();
Ok(instruments)
}
pub async fn close_all_positions(&mut self, symbol: Symbol) -> ExchangeResult<()> {
let account_id = self.get_account_id().await?;
let instrument = format_symbol(&symbol.base, &symbol.quote);
let endpoint = OandaEndpoint::ClosePosition {
account_id,
instrument,
};
let body = json!({
"longUnits": "ALL",
"shortUnits": "ALL"
});
self.put(endpoint, body).await?;
Ok(())
}
}
impl ExchangeIdentity for OandaConnector {
fn exchange_id(&self) -> ExchangeId {
ExchangeId::Oanda
}
fn is_testnet(&self) -> bool {
self.practice
}
fn supported_account_types(&self) -> Vec<AccountType> {
vec![AccountType::Spot]
}
fn exchange_type(&self) -> ExchangeType {
ExchangeType::Cex
}
}
#[async_trait]
impl MarketData for OandaConnector {
async fn get_price(
&self,
symbol: Symbol,
_account_type: AccountType,
) -> ExchangeResult<Price> {
let account_id = self.require_account_id()?;
let instrument = format_symbol(&symbol.base, &symbol.quote);
let endpoint = OandaEndpoint::GetPricing(account_id.to_string());
let mut params = HashMap::new();
params.insert("instruments".to_string(), instrument);
let response = self.get(endpoint, params).await?;
OandaParser::parse_price(&response)
}
async fn get_orderbook(
&self,
symbol: Symbol,
_depth: Option<u16>,
_account_type: AccountType,
) -> ExchangeResult<OrderBook> {
let account_id = self.require_account_id()?;
let instrument = format_symbol(&symbol.base, &symbol.quote);
let endpoint = OandaEndpoint::GetPricing(account_id.to_string());
let mut params = HashMap::new();
params.insert("instruments".to_string(), instrument);
let response = self.get(endpoint, params).await?;
OandaParser::parse_orderbook(&response)
}
async fn get_klines(
&self,
symbol: Symbol,
interval: &str,
limit: Option<u16>,
_account_type: AccountType,
_end_time: Option<i64>,
) -> ExchangeResult<Vec<Kline>> {
let instrument = format_symbol(&symbol.base, &symbol.quote);
let endpoint = OandaEndpoint::GetCandles(instrument.clone());
let mut params = HashMap::new();
params.insert("granularity".to_string(), map_granularity(interval).to_string());
params.insert("count".to_string(), limit.unwrap_or(500).to_string());
params.insert("price".to_string(), "M".to_string());
let response = self.get(endpoint, params).await?;
OandaParser::parse_klines(&response)
}
async fn get_ticker(
&self,
symbol: Symbol,
_account_type: AccountType,
) -> ExchangeResult<Ticker> {
let account_id = self.require_account_id()?;
let instrument = format_symbol(&symbol.base, &symbol.quote);
let endpoint = OandaEndpoint::GetPricing(account_id.to_string());
let mut params = HashMap::new();
params.insert("instruments".to_string(), instrument);
let response = self.get(endpoint, params).await?;
OandaParser::parse_ticker(&response)
}
async fn ping(&self) -> ExchangeResult<()> {
let endpoint = OandaEndpoint::ListAccounts;
let response = self.get(endpoint, HashMap::new()).await?;
self.check_response(&response)
}
async fn get_exchange_info(&self, account_type: AccountType) -> ExchangeResult<Vec<SymbolInfo>> {
let accounts_response = self.get(OandaEndpoint::ListAccounts, HashMap::new()).await?;
let account_id = OandaParser::parse_account_id(&accounts_response)?;
let endpoint = OandaEndpoint::GetInstruments(account_id);
let response = self.get(endpoint, HashMap::new()).await?;
let instruments = response
.get("instruments")
.and_then(|i| i.as_array())
.ok_or_else(|| ExchangeError::Parse("Missing 'instruments' array".to_string()))?;
let infos = instruments
.iter()
.filter_map(|inst| {
let name = inst.get("name").and_then(|n| n.as_str())?; let _inst_type = inst.get("type").and_then(|t| t.as_str()).unwrap_or("CURRENCY");
let _display = inst.get("displayName").and_then(|d| d.as_str()).unwrap_or(name);
let parts: Vec<&str> = name.splitn(2, '_').collect();
let (base, quote) = if parts.len() == 2 {
(parts[0].to_string(), parts[1].to_string())
} else {
(name.to_string(), "USD".to_string())
};
let pip_location = inst.get("pipLocation")
.and_then(|v| v.as_i64());
let tick_size = pip_location.map(|pl| 10f64.powi(pl as i32));
let price_precision = inst.get("displayPrecision")
.and_then(|v| v.as_u64())
.unwrap_or_else(|| pip_location.map(|pl| pl.unsigned_abs()).unwrap_or(5))
as u8;
Some(SymbolInfo {
symbol: name.to_string(),
base_asset: base,
quote_asset: quote,
status: "TRADING".to_string(),
price_precision,
quantity_precision: 0,
min_quantity: Some(1.0),
max_quantity: None,
tick_size,
step_size: Some(1.0),
min_notional: None,
account_type,
})
})
.collect::<Vec<SymbolInfo>>();
self.precision.load_from_symbols(&infos);
Ok(infos)
}
}
#[async_trait]
impl Trading for OandaConnector {
async fn place_order(&self, req: OrderRequest) -> ExchangeResult<PlaceOrderResponse> {
let symbol = req.symbol.clone();
let side = req.side;
let quantity = req.quantity;
let account_id = self.require_account_id()?;
let instrument = format_symbol(&symbol.base, &symbol.quote);
let units = match side {
OrderSide::Buy => quantity,
OrderSide::Sell => -quantity,
};
let units_str = self.precision.qty(&instrument, units.abs());
let units_str = if units < 0.0 {
format!("-{}", units_str)
} else {
units_str
};
let endpoint = OandaEndpoint::CreateOrder(account_id.to_string());
let (body, result_order_type, result_price, result_stop_price, result_tif) = match req.order_type {
OrderType::Market => {
let b = json!({
"order": {
"type": "MARKET",
"instrument": instrument,
"units": units_str,
"timeInForce": "FOK",
"positionFill": "DEFAULT"
}
});
(b, OrderType::Market, None::<f64>, None::<f64>, crate::core::TimeInForce::Fok)
}
OrderType::Limit { price } => {
let b = json!({
"order": {
"type": "LIMIT",
"instrument": instrument,
"units": units_str,
"price": self.precision.price(&instrument, price),
"timeInForce": "GTC",
"positionFill": "DEFAULT"
}
});
(b, OrderType::Limit { price }, Some(price), None, crate::core::TimeInForce::Gtc)
}
OrderType::StopMarket { stop_price } => {
let b = json!({
"order": {
"type": "STOP",
"instrument": instrument,
"units": units_str,
"price": self.precision.price(&instrument, stop_price),
"timeInForce": "GTC",
"positionFill": "DEFAULT"
}
});
(b, OrderType::StopMarket { stop_price }, None, Some(stop_price), crate::core::TimeInForce::Gtc)
}
OrderType::StopLimit { stop_price, limit_price } => {
let b = json!({
"order": {
"type": "STOP",
"instrument": instrument,
"units": units_str,
"price": self.precision.price(&instrument, stop_price),
"priceBound": self.precision.price(&instrument, limit_price),
"timeInForce": "GTC",
"positionFill": "DEFAULT"
}
});
(b, OrderType::StopLimit { stop_price, limit_price }, Some(limit_price), Some(stop_price), crate::core::TimeInForce::Gtc)
}
OrderType::TrailingStop { callback_rate, activation_price: _ } => {
let b = json!({
"order": {
"type": "TRAILING_STOP_LOSS",
"instrument": instrument,
"units": units_str,
"distance": self.precision.price(&instrument, callback_rate),
"timeInForce": "GTC",
"positionFill": "DEFAULT"
}
});
(b, OrderType::TrailingStop { callback_rate, activation_price: None }, None, None, crate::core::TimeInForce::Gtc)
}
OrderType::Bracket { price, take_profit, stop_loss } => {
let order_type_str = if price.is_some() { "LIMIT" } else { "MARKET" };
let mut order_body = serde_json::Map::new();
order_body.insert("type".to_string(), json!(order_type_str));
order_body.insert("instrument".to_string(), json!(instrument));
order_body.insert("units".to_string(), json!(units_str));
order_body.insert("timeInForce".to_string(), json!("GTC"));
order_body.insert("positionFill".to_string(), json!("DEFAULT"));
if let Some(p) = price {
order_body.insert("price".to_string(), json!(self.precision.price(&instrument, p)));
}
order_body.insert("takeProfitOnFill".to_string(), json!({ "price": self.precision.price(&instrument, take_profit) }));
order_body.insert("stopLossOnFill".to_string(), json!({ "price": self.precision.price(&instrument, stop_loss) }));
let b = json!({ "order": serde_json::Value::Object(order_body) });
let entry_price = price;
(b, OrderType::Bracket { price, take_profit, stop_loss }, entry_price, None, crate::core::TimeInForce::Gtc)
}
OrderType::Ioc { price } => {
let mut order_body = serde_json::Map::new();
if let Some(p) = price {
order_body.insert("type".to_string(), json!("LIMIT"));
order_body.insert("price".to_string(), json!(self.precision.price(&instrument, p)));
} else {
order_body.insert("type".to_string(), json!("MARKET"));
}
order_body.insert("instrument".to_string(), json!(instrument));
order_body.insert("units".to_string(), json!(units_str));
order_body.insert("timeInForce".to_string(), json!("IOC"));
order_body.insert("positionFill".to_string(), json!("DEFAULT"));
let b = json!({ "order": serde_json::Value::Object(order_body) });
(b, OrderType::Ioc { price }, price, None, crate::core::TimeInForce::Ioc)
}
OrderType::Fok { price } => {
let b = json!({
"order": {
"type": "LIMIT",
"instrument": instrument,
"units": units_str,
"price": self.precision.price(&instrument, price),
"timeInForce": "FOK",
"positionFill": "DEFAULT"
}
});
(b, OrderType::Fok { price }, Some(price), None, crate::core::TimeInForce::Fok)
}
OrderType::Gtd { price, expire_time } => {
let expire_rfc3339 = {
use std::time::{UNIX_EPOCH, Duration};
let secs = (expire_time / 1000) as u64;
let millis = (expire_time % 1000) as u32;
let dt = UNIX_EPOCH + Duration::from_secs(secs) + Duration::from_millis(millis as u64);
chrono::DateTime::<chrono::Utc>::from(dt).to_rfc3339()
};
let b = json!({
"order": {
"type": "LIMIT",
"instrument": instrument,
"units": units_str,
"price": self.precision.price(&instrument, price),
"timeInForce": "GTD",
"gtdTime": expire_rfc3339,
"positionFill": "DEFAULT"
}
});
(b, OrderType::Gtd { price, expire_time }, Some(price), None, crate::core::TimeInForce::Gtd)
}
OrderType::ReduceOnly { price } => {
let (order_type_str, tif_str) = if price.is_some() {
("LIMIT", "GTC")
} else {
("MARKET", "FOK")
};
let mut order_body = serde_json::Map::new();
order_body.insert("type".to_string(), json!(order_type_str));
order_body.insert("instrument".to_string(), json!(instrument));
order_body.insert("units".to_string(), json!(units_str));
order_body.insert("timeInForce".to_string(), json!(tif_str));
order_body.insert("positionFill".to_string(), json!("REDUCE_ONLY"));
if let Some(p) = price {
order_body.insert("price".to_string(), json!(self.precision.price(&instrument, p)));
}
let b = json!({ "order": serde_json::Value::Object(order_body) });
let tif = if price.is_some() {
crate::core::TimeInForce::Gtc
} else {
crate::core::TimeInForce::Fok
};
(b, OrderType::ReduceOnly { price }, price, None, tif)
}
OrderType::Oco { .. } => {
return Err(ExchangeError::UnsupportedOperation(
"OANDA does not support native OCO orders".to_string()
));
}
OrderType::PostOnly { .. } => {
return Err(ExchangeError::UnsupportedOperation(
"OANDA does not support Post-Only orders".to_string()
));
}
OrderType::Iceberg { .. } => {
return Err(ExchangeError::UnsupportedOperation(
"OANDA does not support Iceberg orders".to_string()
));
}
OrderType::Twap { .. } => {
return Err(ExchangeError::UnsupportedOperation(
"OANDA does not support TWAP orders".to_string()
));
}
OrderType::Oto { .. } | OrderType::ConditionalPlan { .. } | OrderType::DcaRecurring { .. } => {
return Err(ExchangeError::UnsupportedOperation(
"Oto/ConditionalPlan/DcaRecurring orders are not supported on OANDA".to_string()
));
}
};
let response = self.post(endpoint, body).await?;
let order_id = OandaParser::parse_order_id(&response)?;
Ok(PlaceOrderResponse::Simple(Order {
id: order_id,
client_order_id: req.client_order_id,
symbol: symbol.to_string(),
side,
order_type: result_order_type,
status: crate::core::OrderStatus::New,
price: result_price,
stop_price: result_stop_price,
quantity,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: crate::core::timestamp_millis() as i64,
updated_at: None,
time_in_force: result_tif,
}))
}
async fn cancel_order(&self, req: CancelRequest) -> ExchangeResult<Order> {
match req.scope {
CancelScope::Single { ref order_id } => {
let account_id = self.require_account_id()?;
let endpoint = OandaEndpoint::CancelOrder {
account_id: account_id.to_string(),
order_id: order_id.to_string(),
};
let response = self.put(endpoint, json!({})).await?;
if let Ok(order) = OandaParser::parse_order(&response, "") {
return Ok(order);
}
Ok(Order {
id: order_id.to_string(),
client_order_id: None,
symbol: req.symbol
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
side: OrderSide::Buy,
order_type: OrderType::Limit { price: 0.0 },
status: crate::core::OrderStatus::Canceled,
price: None,
stop_price: None,
quantity: 0.0,
filled_quantity: 0.0,
average_price: None,
commission: None,
commission_asset: None,
created_at: 0,
updated_at: Some(crate::core::timestamp_millis() as i64),
time_in_force: crate::core::TimeInForce::Gtc,
})
}
_ => Err(ExchangeError::UnsupportedOperation(
format!("{:?} cancel scope not supported on OANDA — only Single is supported", req.scope)
)),
}
}
async fn get_order(
&self,
_symbol: &str,
order_id: &str,
_account_type: AccountType,
) -> ExchangeResult<Order> {
let account_id = self.require_account_id()?;
let endpoint = OandaEndpoint::GetOrder {
account_id: account_id.to_string(),
order_id: order_id.to_string(),
};
let response = self.get(endpoint, HashMap::new()).await?;
OandaParser::parse_order(&response, _symbol)
}
async fn get_open_orders(
&self,
symbol: Option<&str>,
_account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let account_id = self.require_account_id()?;
let endpoint = OandaEndpoint::ListPendingOrders(account_id.to_string());
let mut params = HashMap::new();
if let Some(s) = symbol {
let instrument = if s.contains('/') {
let parts: Vec<&str> = s.splitn(2, '/').collect();
if parts.len() == 2 {
format_symbol(parts[0], parts[1])
} else {
s.to_string()
}
} else {
s.to_string()
};
params.insert("instrument".to_string(), instrument);
}
let response = self.get(endpoint, params).await?;
OandaParser::parse_orders(&response)
}
async fn get_order_history(
&self,
filter: OrderHistoryFilter,
_account_type: AccountType,
) -> ExchangeResult<Vec<Order>> {
let account_id = self.require_account_id()?;
let endpoint = OandaEndpoint::ListOrders(account_id.to_string());
let mut params = HashMap::new();
params.insert("state".to_string(), "FILLED".to_string());
if let Some(limit) = filter.limit {
params.insert("count".to_string(), limit.to_string());
} else {
params.insert("count".to_string(), "50".to_string());
}
if let Some(sym) = filter.symbol {
let instrument = format_symbol(&sym.base, &sym.quote);
params.insert("instrument".to_string(), instrument);
}
let response = self.get(endpoint, params).await?;
OandaParser::parse_orders(&response)
}
}
#[async_trait]
impl Account for OandaConnector {
async fn get_balance(&self, query: BalanceQuery) -> ExchangeResult<Vec<Balance>> {
let _asset = query.asset.clone();
let _account_type = query.account_type;
let account_id = self.require_account_id()?;
let endpoint = OandaEndpoint::GetAccountSummary(account_id.to_string());
let response = self.get(endpoint, HashMap::new()).await?;
OandaParser::parse_balances(&response)
}
async fn get_account_info(&self, _account_type: AccountType) -> ExchangeResult<AccountInfo> {
let account_id = self.require_account_id()?;
let endpoint = OandaEndpoint::GetAccountSummary(account_id.to_string());
let response = self.get(endpoint, HashMap::new()).await?;
OandaParser::parse_account_info(&response)
}
async fn get_fees(&self, _symbol: Option<&str>) -> ExchangeResult<FeeInfo> {
Err(ExchangeError::UnsupportedOperation(
"get_fees not yet implemented".to_string()
))
}
}
#[async_trait]
impl Positions for OandaConnector {
async fn get_positions(&self, query: PositionQuery) -> ExchangeResult<Vec<Position>> {
let symbol = query.symbol.clone();
let _account_type = query.account_type;
let account_id = self.require_account_id()?;
if let Some(s) = symbol {
let instrument = format_symbol(&s.base, &s.quote);
let endpoint = OandaEndpoint::GetPosition {
account_id: account_id.to_string(),
instrument,
};
let response = self.get(endpoint, HashMap::new()).await?;
OandaParser::parse_position(&response).map(|p| vec![p])
} else {
let endpoint = OandaEndpoint::ListOpenPositions(account_id.to_string());
let response = self.get(endpoint, HashMap::new()).await?;
OandaParser::parse_positions(&response)
}
}
async fn get_funding_rate(
&self,
_symbol: &str,
_account_type: AccountType,
) -> ExchangeResult<FundingRate> {
let _symbol_str = _symbol;
let _symbol = {
let parts: Vec<&str> = _symbol_str.split('/').collect();
if parts.len() == 2 {
crate::core::Symbol::new(parts[0], parts[1])
} else {
crate::core::Symbol { base: _symbol_str.to_string(), quote: String::new(), raw: Some(_symbol_str.to_string()) }
}
};
Err(ExchangeError::UnsupportedOperation(
"OANDA does not have funding rates (forex broker)".to_string()
))
}
async fn modify_position(&self, req: PositionModification) -> ExchangeResult<()> {
match req {
PositionModification::ClosePosition { symbol, account_type: _ } => {
let account_id = self.require_account_id()?;
let instrument = format_symbol(&symbol.base, &symbol.quote);
let endpoint = OandaEndpoint::ClosePosition {
account_id: account_id.to_string(),
instrument,
};
let body = json!({
"longUnits": "ALL",
"shortUnits": "ALL"
});
self.put(endpoint, body).await?;
Ok(())
}
PositionModification::SetLeverage { .. } => {
Err(ExchangeError::UnsupportedOperation(
"OANDA leverage is set at account level, not per symbol".to_string()
))
}
PositionModification::SetMarginMode { .. } => {
Err(ExchangeError::UnsupportedOperation(
"OANDA does not support per-symbol margin mode (uses account-level cross margin)".to_string()
))
}
PositionModification::AddMargin { .. } | PositionModification::RemoveMargin { .. } => {
Err(ExchangeError::UnsupportedOperation(
"OANDA does not support manual margin adjustment (auto cross-margin)".to_string()
))
}
PositionModification::SetTpSl { .. } => {
Err(ExchangeError::UnsupportedOperation(
"OANDA TP/SL is set on individual trades, not positions — use place_order with Bracket".to_string()
))
}
PositionModification::SwitchPositionMode { .. } | PositionModification::MovePositions { .. } => {
Err(ExchangeError::UnsupportedOperation(
"SwitchPositionMode/MovePositions not supported on OANDA".to_string()
))
}
}
}
}
#[async_trait]
impl AmendOrder for OandaConnector {
async fn amend_order(&self, req: AmendRequest) -> ExchangeResult<Order> {
if req.fields.price.is_none()
&& req.fields.quantity.is_none()
&& req.fields.trigger_price.is_none()
{
return Err(ExchangeError::InvalidRequest(
"AmendOrder: at least one field must be specified".to_string()
));
}
let account_id = self.require_account_id()?;
let instrument = format_symbol(&req.symbol.base, &req.symbol.quote);
let current_endpoint = OandaEndpoint::GetOrder {
account_id: account_id.to_string(),
order_id: req.order_id.clone(),
};
let current_response = self.get(current_endpoint, HashMap::new()).await?;
let current_order = OandaParser::parse_order(¤t_response, &instrument)?;
let new_price = req.fields.price.unwrap_or(current_order.price.unwrap_or(0.0));
let new_quantity = req.fields.quantity.unwrap_or(current_order.quantity);
let new_stop = req.fields.trigger_price.or(current_order.stop_price);
let units = match current_order.side {
OrderSide::Buy => new_quantity,
OrderSide::Sell => -new_quantity,
};
let order_type_str = match ¤t_order.order_type {
OrderType::Market => "MARKET",
OrderType::Limit { .. } => "LIMIT",
OrderType::StopMarket { .. } | OrderType::StopLimit { .. } => "STOP",
_ => "LIMIT",
};
let mut order_body = serde_json::Map::new();
let units_qty_str = self.precision.qty(&instrument, units.abs());
let units_str = if units < 0.0 {
format!("-{}", units_qty_str)
} else {
units_qty_str
};
order_body.insert("type".to_string(), json!(order_type_str));
order_body.insert("instrument".to_string(), json!(instrument));
order_body.insert("units".to_string(), json!(units_str));
order_body.insert("timeInForce".to_string(), json!("GTC"));
order_body.insert("positionFill".to_string(), json!("DEFAULT"));
if new_price != 0.0 {
order_body.insert("price".to_string(), json!(self.precision.price(&instrument, new_price)));
}
if let Some(stop) = new_stop {
order_body.insert("priceBound".to_string(), json!(self.precision.price(&instrument, stop)));
}
let body = json!({ "order": serde_json::Value::Object(order_body) });
let endpoint = OandaEndpoint::AmendOrder {
account_id: account_id.to_string(),
order_id: req.order_id.clone(),
};
let response = self.put(endpoint, body).await?;
OandaParser::parse_order(&response, &instrument)
}
}