longbridge/trade/context.rs
1use std::sync::Arc;
2
3use longbridge_httpcli::{DcRegion, HttpClient, Json, Method};
4use longbridge_wscli::WsClientError;
5use rust_decimal::Decimal;
6use serde::{Deserialize, Serialize};
7use tokio::sync::{mpsc, oneshot};
8use tracing::{Subscriber, dispatcher, instrument::WithSubscriber};
9
10use crate::{
11 Config, Result, serde_utils,
12 trade::{
13 AccountBalance, AllExecutionsResponse, CancelOrderOptions, CashFlow,
14 EstimateMaxPurchaseQuantityOptions, Execution, FundPositionsResponse,
15 GetAllExecutionsOptions, GetCashFlowOptions, GetFundPositionsOptions,
16 GetHistoryExecutionsOptions, GetHistoryOrdersOptions, GetOrderDetailOptions,
17 GetStockPositionsOptions, GetTodayExecutionsOptions, GetTodayOrdersOptions,
18 GetUSAvailableHoldingOptions, GetUSHistoryOrders, GetUSRealizedPLOptions,
19 GetUSTransactionHistoryOptions, MarginRatio, Order, OrderDetail, OrderSide, PushEvent,
20 QueryUSOrdersResponse, ReplaceOrderOptions, StockPositionsResponse,
21 SubmitMultiLegOrderOptions, SubmitOrderOptions, TopicType, USAssetOverview,
22 USAvailableHolding, USCashAndBuyPower, USExchangeRates, USOrderDetailResponse,
23 USRealizedPL, USTransactionHistory,
24 core::{Command, Core},
25 },
26};
27
28#[derive(Debug, Deserialize)]
29struct EmptyResponse {}
30
31/// Response for submit order request
32#[derive(Debug, Serialize, Deserialize)]
33pub struct SubmitOrderResponse {
34 /// Order id
35 pub order_id: String,
36}
37
38/// Response for estimate maximum purchase quantity
39#[derive(Debug, Serialize, Deserialize)]
40pub struct EstimateMaxPurchaseQuantityResponse {
41 /// Cash available quantity
42 #[serde(with = "serde_utils::decimal_empty_is_0")]
43 pub cash_max_qty: Decimal,
44 /// Margin available quantity
45 #[serde(with = "serde_utils::decimal_empty_is_0")]
46 pub margin_max_qty: Decimal,
47}
48
49struct InnerTradeContext {
50 command_tx: mpsc::UnboundedSender<Command>,
51 http_cli: HttpClient,
52 log_subscriber: Arc<dyn Subscriber + Send + Sync>,
53 /// Kept alive only so the background `Core::run` task can observe the
54 /// context being dropped (via this channel closing) and stop reconnecting.
55 _shutdown_tx: mpsc::UnboundedSender<()>,
56}
57
58impl Drop for InnerTradeContext {
59 fn drop(&mut self) {
60 dispatcher::with_default(&self.log_subscriber.clone().into(), || {
61 tracing::info!("trade context dropped");
62 });
63 }
64}
65
66/// Trade context
67#[derive(Clone)]
68pub struct TradeContext(Arc<InnerTradeContext>);
69
70impl TradeContext {
71 /// Create a `TradeContext`
72 pub fn new(config: Arc<Config>) -> (Self, mpsc::UnboundedReceiver<PushEvent>) {
73 let log_subscriber = config.create_log_subscriber("trade");
74
75 dispatcher::with_default(&log_subscriber.clone().into(), || {
76 tracing::info!(language = ?config.language, "creating trade context");
77 });
78
79 let http_cli = config.create_http_client();
80 let (command_tx, command_rx) = mpsc::unbounded_channel();
81 let (push_tx, push_rx) = mpsc::unbounded_channel();
82 let (shutdown_tx, shutdown_rx) = mpsc::unbounded_channel();
83 let core = Core::new(config, command_rx, push_tx);
84 crate::runtime::RUNTIME.handle().spawn(
85 core.run(shutdown_rx)
86 .with_subscriber(log_subscriber.clone()),
87 );
88
89 dispatcher::with_default(&log_subscriber.clone().into(), || {
90 tracing::info!("trade context created");
91 });
92
93 (
94 TradeContext(Arc::new(InnerTradeContext {
95 http_cli,
96 command_tx,
97 log_subscriber,
98 _shutdown_tx: shutdown_tx,
99 })),
100 push_rx,
101 )
102 }
103
104 /// Returns the log subscriber
105 #[inline]
106 pub fn log_subscriber(&self) -> Arc<dyn Subscriber + Send + Sync> {
107 self.0.log_subscriber.clone()
108 }
109
110 /// Subscribe
111 ///
112 /// Reference: <https://open.longbridge.com/en/docs/trade/trade-push#subscribe>
113 ///
114 /// # Examples
115 ///
116 /// ```no_run
117 /// use std::sync::Arc;
118 ///
119 /// use longbridge::{
120 /// Config, decimal,
121 /// oauth::OAuthBuilder,
122 /// trade::{OrderSide, OrderType, SubmitOrderOptions, TimeInForceType, TradeContext},
123 /// };
124 ///
125 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
126 /// let oauth = OAuthBuilder::new("your-client-id")
127 /// .build(|url| println!("Visit: {url}"))
128 /// .await?;
129 /// let config = Arc::new(Config::from_oauth(oauth));
130 /// let (ctx, mut receiver) = TradeContext::new(config);
131 ///
132 /// let opts = SubmitOrderOptions::new(
133 /// "700.HK",
134 /// OrderType::LO,
135 /// OrderSide::Buy,
136 /// decimal!(200),
137 /// TimeInForceType::Day,
138 /// )
139 /// .submitted_price(decimal!(50i32));
140 /// let resp = ctx.submit_order(opts).await?;
141 /// println!("{:?}", resp);
142 ///
143 /// while let Some(event) = receiver.recv().await {
144 /// println!("{:?}", event);
145 /// }
146 ///
147 /// # Ok::<_, Box<dyn std::error::Error>>(())
148 /// # });
149 /// ```
150 pub async fn subscribe<I>(&self, topics: I) -> Result<()>
151 where
152 I: IntoIterator<Item = TopicType>,
153 {
154 let (reply_tx, reply_rx) = oneshot::channel();
155 self.0
156 .command_tx
157 .send(Command::Subscribe {
158 topics: topics.into_iter().collect(),
159 reply_tx,
160 })
161 .map_err(|_| WsClientError::ClientClosed)?;
162 reply_rx.await.map_err(|_| WsClientError::ClientClosed)?
163 }
164
165 /// Unsubscribe
166 ///
167 /// Reference: <https://open.longbridge.com/en/docs/trade/trade-push#cancel-subscribe>
168 pub async fn unsubscribe<I>(&self, topics: I) -> Result<()>
169 where
170 I: IntoIterator<Item = TopicType>,
171 {
172 let (reply_tx, reply_rx) = oneshot::channel();
173 self.0
174 .command_tx
175 .send(Command::Unsubscribe {
176 topics: topics.into_iter().collect(),
177 reply_tx,
178 })
179 .map_err(|_| WsClientError::ClientClosed)?;
180 reply_rx.await.map_err(|_| WsClientError::ClientClosed)?
181 }
182
183 /// Get history executions
184 ///
185 /// `side` may be `Unknown` on Longbridge US accounts.
186 ///
187 /// Reference: <https://open.longbridge.com/en/docs/trade/execution/history_executions>
188 ///
189 /// # Examples
190 ///
191 /// ```no_run
192 /// use std::sync::Arc;
193 ///
194 /// use longbridge::{
195 /// oauth::OAuthBuilder,
196 /// trade::{GetHistoryExecutionsOptions, TradeContext},
197 /// Config,
198 /// };
199 /// use time::macros::datetime;
200 ///
201 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
202 /// let oauth = OAuthBuilder::new("your-client-id")
203 /// .build(|url| println!("Visit: {url}"))
204 /// .await?;
205 /// let config = Arc::new(Config::from_oauth(oauth));
206 /// let (ctx, _) = TradeContext::new(config);
207 ///
208 /// let opts = GetHistoryExecutionsOptions::new()
209 /// .symbol("700.HK")
210 /// .start_at(datetime!(2022-05-09 0:00 UTC))
211 /// .end_at(datetime!(2022-05-12 0:00 UTC));
212 /// let resp = ctx.history_executions(opts).await?;
213 /// println!("{:?}", resp);
214 /// # Ok::<_, Box<dyn std::error::Error>>(())
215 /// # });
216 /// ```
217 pub async fn history_executions(
218 &self,
219 options: impl Into<Option<GetHistoryExecutionsOptions>>,
220 ) -> Result<Vec<Execution>> {
221 use std::collections::HashSet;
222
223 #[derive(Deserialize)]
224 struct Response {
225 #[serde(default)]
226 has_more: bool,
227 trades: Vec<Execution>,
228 }
229
230 // The endpoint caps each response at 1000 records; walk the `page`
231 // param (1-based) until `has_more` is false. Dedupe by
232 // `trade_id` and stop if a page adds nothing new, guarding
233 // against the gateway ignoring `page`. Bounded to 1000 pages as
234 // a runaway guard.
235 let mut options = options.into().unwrap_or_default();
236 let mut all: Vec<Execution> = Vec::new();
237 let mut seen: HashSet<String> = HashSet::new();
238 for page in 1..=1000u32 {
239 options = options.with_page(page);
240 let resp = self
241 .0
242 .http_cli
243 .request(Method::GET, "/v1/trade/execution/history")
244 .query_params(&options)
245 .response::<Json<Response>>()
246 .send()
247 .with_subscriber(self.0.log_subscriber.clone())
248 .await?
249 .0;
250 if resp.trades.is_empty() {
251 break;
252 }
253 let mut added = 0usize;
254 for t in resp.trades {
255 if seen.insert(t.trade_id.clone()) {
256 all.push(t);
257 added += 1;
258 }
259 }
260 if !resp.has_more || added == 0 {
261 break;
262 }
263 }
264 Ok(all)
265 }
266
267 /// Get today executions
268 ///
269 /// Reference: <https://open.longbridge.com/en/docs/trade/execution/today_executions>
270 ///
271 /// # Examples
272 ///
273 /// ```no_run
274 /// use std::sync::Arc;
275 ///
276 /// use longbridge::{
277 /// Config,
278 /// oauth::OAuthBuilder,
279 /// trade::{GetTodayExecutionsOptions, TradeContext},
280 /// };
281 ///
282 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
283 /// let oauth = OAuthBuilder::new("your-client-id")
284 /// .build(|url| println!("Visit: {url}"))
285 /// .await?;
286 /// let config = Arc::new(Config::from_oauth(oauth));
287 /// let (ctx, _) = TradeContext::new(config);
288 ///
289 /// let opts = GetTodayExecutionsOptions::new().symbol("700.HK");
290 /// let resp = ctx.today_executions(opts).await?;
291 /// println!("{:?}", resp);
292 /// # Ok::<_, Box<dyn std::error::Error>>(())
293 /// # });
294 /// ```
295 pub async fn today_executions(
296 &self,
297 options: impl Into<Option<GetTodayExecutionsOptions>>,
298 ) -> Result<Vec<Execution>> {
299 #[derive(Deserialize)]
300 struct Response {
301 trades: Vec<Execution>,
302 }
303
304 Ok(self
305 .0
306 .http_cli
307 .request(Method::GET, "/v1/trade/execution/today")
308 .query_params(options.into().unwrap_or_default())
309 .response::<Json<Response>>()
310 .send()
311 .with_subscriber(self.0.log_subscriber.clone())
312 .await?
313 .0
314 .trades)
315 }
316
317 /// Get all executions
318 ///
319 /// AP accounts only: US data-center accounts get a region error; use
320 /// `history_executions` instead.
321 ///
322 /// Reference: <https://open.longbridge.com/en/docs/trade/execution/all_executions>
323 pub async fn all_executions(
324 &self,
325 options: impl Into<Option<GetAllExecutionsOptions>>,
326 ) -> Result<AllExecutionsResponse> {
327 Ok(self
328 .0
329 .http_cli
330 .request(Method::GET, "/v3/trade/execution/all")
331 .dc_restrict(DcRegion::Ap)
332 .query_params(options.into().unwrap_or_default())
333 .response::<Json<AllExecutionsResponse>>()
334 .send()
335 .with_subscriber(self.0.log_subscriber.clone())
336 .await?
337 .0)
338 }
339
340 /// Get history orders
341 ///
342 /// Reference: <https://open.longbridge.com/en/docs/trade/order/history_orders>
343 ///
344 /// # Examples
345 ///
346 /// ```no_run
347 /// use std::sync::Arc;
348 ///
349 /// use longbridge::{
350 /// oauth::OAuthBuilder,
351 /// trade::{GetHistoryOrdersOptions, OrderSide, OrderStatus, TradeContext},
352 /// Config, Market,
353 /// };
354 /// use time::macros::datetime;
355 ///
356 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
357 /// let oauth = OAuthBuilder::new("your-client-id")
358 /// .build(|url| println!("Visit: {url}"))
359 /// .await?;
360 /// let config = Arc::new(Config::from_oauth(oauth));
361 /// let (ctx, _) = TradeContext::new(config);
362 ///
363 /// let opts = GetHistoryOrdersOptions::new()
364 /// .symbol("700.HK")
365 /// .status([OrderStatus::Filled, OrderStatus::New])
366 /// .side(OrderSide::Buy)
367 /// .market(Market::HK)
368 /// .start_at(datetime!(2022-05-09 0:00 UTC))
369 /// .end_at(datetime!(2022-05-12 0:00 UTC));
370 /// let resp = ctx.history_orders(opts).await?;
371 /// println!("{:?}", resp);
372 /// # Ok::<_, Box<dyn std::error::Error>>(())
373 /// # });
374 /// ```
375 pub async fn history_orders(
376 &self,
377 options: impl Into<Option<GetHistoryOrdersOptions>>,
378 ) -> Result<Vec<Order>> {
379 #[derive(Deserialize)]
380 struct Response {
381 orders: Vec<Order>,
382 }
383
384 Ok(self
385 .0
386 .http_cli
387 .request(Method::GET, "/v1/trade/order/history")
388 .query_params(options.into().unwrap_or_default())
389 .response::<Json<Response>>()
390 .send()
391 .with_subscriber(self.0.log_subscriber.clone())
392 .await?
393 .0
394 .orders)
395 }
396
397 /// Get today orders
398 ///
399 /// Reference: <https://open.longbridge.com/en/docs/trade/order/today_orders>
400 ///
401 /// # Examples
402 ///
403 /// ```no_run
404 /// use std::sync::Arc;
405 ///
406 /// use longbridge::{
407 /// Config, Market,
408 /// oauth::OAuthBuilder,
409 /// trade::{GetTodayOrdersOptions, OrderSide, OrderStatus, TradeContext},
410 /// };
411 ///
412 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
413 /// let oauth = OAuthBuilder::new("your-client-id")
414 /// .build(|url| println!("Visit: {url}"))
415 /// .await?;
416 /// let config = Arc::new(Config::from_oauth(oauth));
417 /// let (ctx, _) = TradeContext::new(config);
418 ///
419 /// let opts = GetTodayOrdersOptions::new()
420 /// .symbol("700.HK")
421 /// .status([OrderStatus::Filled, OrderStatus::New])
422 /// .side(OrderSide::Buy)
423 /// .market(Market::HK);
424 /// let resp = ctx.today_orders(opts).await?;
425 /// println!("{:?}", resp);
426 /// # Ok::<_, Box<dyn std::error::Error>>(())
427 /// # });
428 /// ```
429 pub async fn today_orders(
430 &self,
431 options: impl Into<Option<GetTodayOrdersOptions>>,
432 ) -> Result<Vec<Order>> {
433 #[derive(Deserialize)]
434 struct Response {
435 orders: Vec<Order>,
436 }
437
438 Ok(self
439 .0
440 .http_cli
441 .request(Method::GET, "/v1/trade/order/today")
442 .query_params(options.into().unwrap_or_default())
443 .response::<Json<Response>>()
444 .send()
445 .with_subscriber(self.0.log_subscriber.clone())
446 .await?
447 .0
448 .orders)
449 }
450
451 /// Replace order
452 ///
453 /// Reference: <https://open.longbridge.com/en/docs/trade/order/replace>
454 ///
455 /// # Examples
456 ///
457 /// ```no_run
458 /// use std::sync::Arc;
459 ///
460 /// use longbridge::{
461 /// Config, decimal,
462 /// oauth::OAuthBuilder,
463 /// trade::{ReplaceOrderOptions, TradeContext},
464 /// };
465 ///
466 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
467 /// let oauth = OAuthBuilder::new("your-client-id")
468 /// .build(|url| println!("Visit: {url}"))
469 /// .await?;
470 /// let config = Arc::new(Config::from_oauth(oauth));
471 /// let (ctx, _) = TradeContext::new(config);
472 ///
473 /// let opts =
474 /// ReplaceOrderOptions::new("709043056541253632", decimal!(100)).price(decimal!(300i32));
475 /// let resp = ctx.replace_order(opts).await?;
476 /// println!("{:?}", resp);
477 /// # Ok::<_, Box<dyn std::error::Error>>(())
478 /// # });
479 /// ```
480 pub async fn replace_order(&self, options: ReplaceOrderOptions) -> Result<()> {
481 Ok(self
482 .0
483 .http_cli
484 .request(Method::PUT, "/v1/trade/order")
485 .body(Json(options))
486 .response::<Json<EmptyResponse>>()
487 .send()
488 .with_subscriber(self.0.log_subscriber.clone())
489 .await
490 .map(|_| ())?)
491 }
492
493 /// Submit order
494 ///
495 /// Reference: <https://open.longbridge.com/en/docs/trade/order/submit>
496 ///
497 /// # Examples
498 ///
499 /// ```no_run
500 /// use std::sync::Arc;
501 ///
502 /// use longbridge::{
503 /// Config, decimal,
504 /// oauth::OAuthBuilder,
505 /// trade::{OrderSide, OrderType, SubmitOrderOptions, TimeInForceType, TradeContext},
506 /// };
507 ///
508 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
509 /// let oauth = OAuthBuilder::new("your-client-id")
510 /// .build(|url| println!("Visit: {url}"))
511 /// .await?;
512 /// let config = Arc::new(Config::from_oauth(oauth));
513 /// let (ctx, _) = TradeContext::new(config);
514 ///
515 /// let opts = SubmitOrderOptions::new(
516 /// "700.HK",
517 /// OrderType::LO,
518 /// OrderSide::Buy,
519 /// decimal!(200),
520 /// TimeInForceType::Day,
521 /// )
522 /// .submitted_price(decimal!(50i32));
523 /// let resp = ctx.submit_order(opts).await?;
524 /// println!("{:?}", resp);
525 /// # Ok::<_, Box<dyn std::error::Error>>(())
526 /// # });
527 /// ```
528 pub async fn submit_order(&self, options: SubmitOrderOptions) -> Result<SubmitOrderResponse> {
529 let resp: SubmitOrderResponse = self
530 .0
531 .http_cli
532 .request(Method::POST, "/v1/trade/order")
533 .body(Json(options))
534 .response::<Json<_>>()
535 .send()
536 .with_subscriber(self.0.log_subscriber.clone())
537 .await?
538 .0;
539 _ = self.0.command_tx.send(Command::SubmittedOrder {
540 order_id: resp.order_id.clone(),
541 });
542 Ok(resp)
543 }
544
545 /// Submit a multi-leg option combination order (such as vertical spreads,
546 /// straddles, strangles, collars, etc.). All legs are submitted together
547 /// as a single strategy order.
548 ///
549 /// # Examples
550 ///
551 /// ```no_run
552 /// use std::sync::Arc;
553 ///
554 /// use longbridge::{
555 /// Config, decimal,
556 /// oauth::OAuthBuilder,
557 /// trade::{
558 /// MultiLegStrategy, OrderSide, OrderType, SubmitMultiLegOrderLeg,
559 /// SubmitMultiLegOrderOptions, TradeContext,
560 /// },
561 /// };
562 ///
563 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
564 /// let oauth = OAuthBuilder::new("your-client-id")
565 /// .build(|url| println!("Visit: {url}"))
566 /// .await?;
567 /// let config = Arc::new(Config::from_oauth(oauth));
568 /// let (ctx, _) = TradeContext::new(config);
569 ///
570 /// let opts = SubmitMultiLegOrderOptions::new(
571 /// OrderSide::Buy,
572 /// OrderType::LO,
573 /// decimal!(1i32),
574 /// MultiLegStrategy::VerticalCallSpread,
575 /// [
576 /// SubmitMultiLegOrderLeg::new("QQQ260731C764000.US", decimal!(1i32)),
577 /// SubmitMultiLegOrderLeg::new("QQQ260731C767000.US", decimal!(1i32)),
578 /// ],
579 /// )
580 /// .submitted_price(decimal!(1.5));
581 /// let resp = ctx.submit_multileg(opts).await?;
582 /// println!("{:?}", resp);
583 /// # Ok::<_, Box<dyn std::error::Error>>(())
584 /// # });
585 /// ```
586 pub async fn submit_multileg(
587 &self,
588 options: SubmitMultiLegOrderOptions,
589 ) -> Result<SubmitOrderResponse> {
590 let resp: SubmitOrderResponse = self
591 .0
592 .http_cli
593 .request(Method::POST, "/v1/trade/order/multileg")
594 .body(Json(options))
595 .response::<Json<_>>()
596 .send()
597 .with_subscriber(self.0.log_subscriber.clone())
598 .await?
599 .0;
600 _ = self.0.command_tx.send(Command::SubmittedOrder {
601 order_id: resp.order_id.clone(),
602 });
603 Ok(resp)
604 }
605
606 /// Cancel order
607 ///
608 /// Reference: <https://open.longbridge.com/en/docs/trade/order/withdraw>
609 ///
610 /// # Examples
611 ///
612 /// ```no_run
613 /// use std::sync::Arc;
614 ///
615 /// use longbridge::{Config, oauth::OAuthBuilder, trade::TradeContext};
616 ///
617 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
618 /// let oauth = OAuthBuilder::new("your-client-id")
619 /// .build(|url| println!("Visit: {url}"))
620 /// .await?;
621 /// let config = Arc::new(Config::from_oauth(oauth));
622 /// let (ctx, _) = TradeContext::new(config);
623 ///
624 /// ctx.cancel_order("709043056541253632").await?;
625 /// # Ok::<_, Box<dyn std::error::Error>>(())
626 /// # });
627 /// ```
628 pub async fn cancel_order(&self, options: impl Into<CancelOrderOptions>) -> Result<()> {
629 Ok(self
630 .0
631 .http_cli
632 .request(Method::DELETE, "/v1/trade/order")
633 .response::<Json<EmptyResponse>>()
634 .query_params(options.into())
635 .send()
636 .with_subscriber(self.0.log_subscriber.clone())
637 .await
638 .map(|_| ())?)
639 }
640
641 /// Get account balance
642 ///
643 /// AP accounts only: US data-center accounts get a region error; use
644 /// `us_cash_and_buy_power` (or `us_asset_overview`) instead.
645 ///
646 /// Reference: <https://open.longbridge.com/en/docs/trade/asset/account>
647 ///
648 /// # Examples
649 ///
650 /// ```no_run
651 /// use std::sync::Arc;
652 ///
653 /// use longbridge::{Config, oauth::OAuthBuilder, trade::TradeContext};
654 ///
655 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
656 /// let oauth = OAuthBuilder::new("your-client-id")
657 /// .build(|url| println!("Visit: {url}"))
658 /// .await?;
659 /// let config = Arc::new(Config::from_oauth(oauth));
660 /// let (ctx, _) = TradeContext::new(config);
661 ///
662 /// let resp = ctx.account_balance(None).await?;
663 /// println!("{:?}", resp);
664 /// # Ok::<_, Box<dyn std::error::Error>>(())
665 /// # });
666 /// ```
667 pub async fn account_balance(&self, currency: Option<&str>) -> Result<Vec<AccountBalance>> {
668 #[derive(Debug, Serialize)]
669 struct Request<'a> {
670 currency: Option<&'a str>,
671 }
672
673 #[derive(Debug, Deserialize)]
674 struct Response {
675 list: Vec<AccountBalance>,
676 }
677
678 Ok(self
679 .0
680 .http_cli
681 .request(Method::GET, "/v1/asset/account")
682 .dc_restrict(DcRegion::Ap)
683 .query_params(Request { currency })
684 .response::<Json<Response>>()
685 .send()
686 .with_subscriber(self.0.log_subscriber.clone())
687 .await?
688 .0
689 .list)
690 }
691
692 /// Get cash flow
693 ///
694 /// AP accounts only: US data-center accounts get a region error; use
695 /// `us_transaction_history` instead.
696 ///
697 /// Reference: <https://open.longbridge.com/en/docs/trade/asset/cashflow>
698 ///
699 /// # Examples
700 ///
701 /// ```no_run
702 /// use std::sync::Arc;
703 ///
704 /// use longbridge::{
705 /// oauth::OAuthBuilder,
706 /// trade::{GetCashFlowOptions, TradeContext},
707 /// Config,
708 /// };
709 /// use time::macros::datetime;
710 ///
711 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
712 /// let oauth = OAuthBuilder::new("your-client-id")
713 /// .build(|url| println!("Visit: {url}"))
714 /// .await?;
715 /// let config = Arc::new(Config::from_oauth(oauth));
716 /// let (ctx, _) = TradeContext::new(config);
717 ///
718 /// let opts = GetCashFlowOptions::new(datetime!(2022-05-09 0:00 UTC), datetime!(2022-05-12 0:00 UTC));
719 /// let resp = ctx.cash_flow(opts).await?;
720 /// println!("{:?}", resp);
721 /// # Ok::<_, Box<dyn std::error::Error>>(())
722 /// # });
723 /// ```
724 pub async fn cash_flow(&self, options: GetCashFlowOptions) -> Result<Vec<CashFlow>> {
725 #[derive(Debug, Deserialize)]
726 struct Response {
727 list: Vec<CashFlow>,
728 }
729
730 Ok(self
731 .0
732 .http_cli
733 .request(Method::GET, "/v1/asset/cashflow")
734 .dc_restrict(DcRegion::Ap)
735 .query_params(options)
736 .response::<Json<Response>>()
737 .send()
738 .with_subscriber(self.0.log_subscriber.clone())
739 .await?
740 .0
741 .list)
742 }
743
744 /// Get fund positions
745 ///
746 /// Reference: <https://open.longbridge.com/en/docs/trade/asset/fund>
747 ///
748 /// AP accounts only: US data-center accounts have no fund holdings and
749 /// get a region error. No US equivalent.
750 ///
751 /// # Examples
752 ///
753 /// ```no_run
754 /// use std::sync::Arc;
755 ///
756 /// use longbridge::{Config, oauth::OAuthBuilder, trade::TradeContext};
757 ///
758 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
759 /// let oauth = OAuthBuilder::new("your-client-id")
760 /// .build(|url| println!("Visit: {url}"))
761 /// .await?;
762 /// let config = Arc::new(Config::from_oauth(oauth));
763 /// let (ctx, _) = TradeContext::new(config);
764 ///
765 /// let resp = ctx.fund_positions(None).await?;
766 /// println!("{:?}", resp);
767 /// # Ok::<_, Box<dyn std::error::Error>>(())
768 /// # });
769 /// ```
770 pub async fn fund_positions(
771 &self,
772 opts: impl Into<Option<GetFundPositionsOptions>>,
773 ) -> Result<FundPositionsResponse> {
774 Ok(self
775 .0
776 .http_cli
777 .request(Method::GET, "/v1/asset/fund")
778 .dc_restrict(DcRegion::Ap)
779 .query_params(opts.into().unwrap_or_default())
780 .response::<Json<FundPositionsResponse>>()
781 .send()
782 .with_subscriber(self.0.log_subscriber.clone())
783 .await?
784 .0)
785 }
786
787 /// Get stock positions
788 ///
789 /// AP accounts only: US data-center accounts get a region error; use
790 /// `us_asset_overview` (`stock_list`) instead.
791 ///
792 /// Reference: <https://open.longbridge.com/en/docs/trade/asset/stock>
793 ///
794 /// # Examples
795 ///
796 /// ```no_run
797 /// use std::sync::Arc;
798 ///
799 /// use longbridge::{Config, oauth::OAuthBuilder, trade::TradeContext};
800 ///
801 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
802 /// let oauth = OAuthBuilder::new("your-client-id")
803 /// .build(|url| println!("Visit: {url}"))
804 /// .await?;
805 /// let config = Arc::new(Config::from_oauth(oauth));
806 /// let (ctx, _) = TradeContext::new(config);
807 ///
808 /// let resp = ctx.stock_positions(None).await?;
809 /// println!("{:?}", resp);
810 /// # Ok::<_, Box<dyn std::error::Error>>(())
811 /// # });
812 /// ```
813 pub async fn stock_positions(
814 &self,
815 opts: impl Into<Option<GetStockPositionsOptions>>,
816 ) -> Result<StockPositionsResponse> {
817 Ok(self
818 .0
819 .http_cli
820 .request(Method::GET, "/v1/asset/stock")
821 .dc_restrict(DcRegion::Ap)
822 .query_params(opts.into().unwrap_or_default())
823 .response::<Json<StockPositionsResponse>>()
824 .send()
825 .with_subscriber(self.0.log_subscriber.clone())
826 .await?
827 .0)
828 }
829
830 /// Get margin ratio
831 ///
832 /// Reference: <https://open.longbridge.com/en/docs/trade/asset/margin_ratio>
833 ///
834 /// AP accounts only: US data-center accounts get a region error. No US
835 /// equivalent; for US margin info see `us_cash_and_buy_power`
836 /// (`margin_status`).
837 ///
838 /// # Examples
839 ///
840 /// ```no_run
841 /// use std::sync::Arc;
842 ///
843 /// use longbridge::{Config, oauth::OAuthBuilder, trade::TradeContext};
844 ///
845 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
846 /// let oauth = OAuthBuilder::new("your-client-id")
847 /// .build(|url| println!("Visit: {url}"))
848 /// .await?;
849 /// let config = Arc::new(Config::from_oauth(oauth));
850 /// let (ctx, _) = TradeContext::new(config);
851 ///
852 /// let resp = ctx.margin_ratio("700.HK").await?;
853 /// println!("{:?}", resp);
854 /// # Ok::<_, Box<dyn std::error::Error>>(())
855 /// # });
856 /// ```
857 pub async fn margin_ratio(&self, symbol: impl Into<String>) -> Result<MarginRatio> {
858 #[derive(Debug, Serialize)]
859 struct Request {
860 symbol: String,
861 }
862
863 Ok(self
864 .0
865 .http_cli
866 .request(Method::GET, "/v1/risk/margin-ratio")
867 .dc_restrict(DcRegion::Ap)
868 .query_params(Request {
869 symbol: symbol.into(),
870 })
871 .response::<Json<MarginRatio>>()
872 .send()
873 .with_subscriber(self.0.log_subscriber.clone())
874 .await?
875 .0)
876 }
877
878 /// Get order detail
879 ///
880 /// AP accounts only: US data-center accounts get a region error; use
881 /// `us_order_detail` instead.
882 ///
883 /// Reference: <https://open.longbridge.com/en/docs/trade/order/order_detail>
884 ///
885 /// # Examples
886 ///
887 /// ```no_run
888 /// use std::sync::Arc;
889 ///
890 /// use longbridge::{
891 /// Config, Market,
892 /// oauth::OAuthBuilder,
893 /// trade::{GetHistoryOrdersOptions, OrderSide, OrderStatus, TradeContext},
894 /// };
895 /// use time::macros::datetime;
896 ///
897 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
898 /// let oauth = OAuthBuilder::new("your-client-id")
899 /// .build(|url| println!("Visit: {url}"))
900 /// .await?;
901 /// let config = Arc::new(Config::from_oauth(oauth));
902 /// let (ctx, _) = TradeContext::new(config);
903 ///
904 /// let resp = ctx.order_detail("701276261045858304").await?;
905 /// println!("{:?}", resp);
906 /// # Ok::<_, Box<dyn std::error::Error>>(())
907 /// # });
908 /// ```
909 pub async fn order_detail(
910 &self,
911 options: impl Into<GetOrderDetailOptions>,
912 ) -> Result<OrderDetail> {
913 Ok(self
914 .0
915 .http_cli
916 .request(Method::GET, "/v1/trade/order")
917 .dc_restrict(DcRegion::Ap)
918 .response::<Json<OrderDetail>>()
919 .query_params(options.into())
920 .send()
921 .with_subscriber(self.0.log_subscriber.clone())
922 .await?
923 .0)
924 }
925
926 /// Estimating the maximum purchase quantity for Hong Kong and US stocks,
927 /// warrants, and options
928 ///
929 ///
930 /// Reference: <https://open.longbridge.com/en/docs/trade/order/estimate_available_buy_limit>
931 ///
932 /// # Examples
933 ///
934 /// ```no_run
935 /// use std::sync::Arc;
936 ///
937 /// use longbridge::{
938 /// Config,
939 /// oauth::OAuthBuilder,
940 /// trade::{EstimateMaxPurchaseQuantityOptions, OrderSide, OrderType, TradeContext},
941 /// };
942 /// use time::macros::datetime;
943 ///
944 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
945 /// let oauth = OAuthBuilder::new("your-client-id")
946 /// .build(|url| println!("Visit: {url}"))
947 /// .await?;
948 /// let config = Arc::new(Config::from_oauth(oauth));
949 /// let (ctx, _) = TradeContext::new(config);
950 ///
951 /// let resp = ctx
952 /// .estimate_max_purchase_quantity(EstimateMaxPurchaseQuantityOptions::new(
953 /// "700.HK",
954 /// OrderType::LO,
955 /// OrderSide::Buy,
956 /// ))
957 /// .await?;
958 /// println!("{:?}", resp);
959 /// # Ok::<_, Box<dyn std::error::Error>>(())
960 /// # });
961 /// ```
962 pub async fn estimate_max_purchase_quantity(
963 &self,
964 opts: EstimateMaxPurchaseQuantityOptions,
965 ) -> Result<EstimateMaxPurchaseQuantityResponse> {
966 Ok(self
967 .0
968 .http_cli
969 .request(Method::GET, "/v1/trade/estimate/buy_limit")
970 .query_params(opts)
971 .response::<Json<EstimateMaxPurchaseQuantityResponse>>()
972 .send()
973 .with_subscriber(self.0.log_subscriber.clone())
974 .await?
975 .0)
976 }
977
978 // ── US-market APIs
979 // ────────────────────────────────────────────────────────
980
981 /// Query the paginated US order list.
982 ///
983 /// Path: `POST /v1/us/orders/query`
984 ///
985 /// US token required.
986 pub async fn us_query_orders(&self, opts: GetUSHistoryOrders) -> Result<QueryUSOrdersResponse> {
987 use std::time::{SystemTime, UNIX_EPOCH};
988
989 let now = SystemTime::now()
990 .duration_since(UNIX_EPOCH)
991 .unwrap_or_default()
992 .as_secs() as i64;
993
994 let action = match opts.side {
995 OrderSide::Buy => 1,
996 OrderSide::Sell => 2,
997 _ => 0,
998 };
999
1000 let symbols = opts
1001 .symbol
1002 .as_deref()
1003 .filter(|s| !s.is_empty())
1004 .map(|s| vec![s.to_string()])
1005 .unwrap_or_default();
1006
1007 let start_at = if opts.start_at == 0 {
1008 (now - 90 * 24 * 3600) as f64
1009 } else {
1010 opts.start_at as f64
1011 };
1012 let end_at = if opts.end_at == 0 {
1013 now as f64
1014 } else {
1015 opts.end_at as f64
1016 };
1017 let page = if opts.page <= 0 { 1 } else { opts.page };
1018 let limit = if opts.limit <= 0 { 20 } else { opts.limit };
1019
1020 let body = super::types::USQueryOrdersBody {
1021 account_channel: String::new(),
1022 action,
1023 start_at,
1024 end_at,
1025 symbols,
1026 security_types: vec![],
1027 query_type: opts.query_type,
1028 page,
1029 limit,
1030 query_version: now as f64,
1031 };
1032
1033 Ok(self
1034 .0
1035 .http_cli
1036 .request(Method::POST, "/v1/us/orders/query")
1037 .dc_restrict(DcRegion::Us)
1038 .body(Json(body))
1039 .response::<Json<QueryUSOrdersResponse>>()
1040 .send()
1041 .with_subscriber(self.0.log_subscriber.clone())
1042 .await?
1043 .0)
1044 }
1045
1046 /// Get US order detail.
1047 ///
1048 /// Path: `GET /v1/us/orders/{order_id}`
1049 ///
1050 /// US token required.
1051 pub async fn us_order_detail(
1052 &self,
1053 order_id: impl Into<String>,
1054 ) -> Result<USOrderDetailResponse> {
1055 let order_id = order_id.into();
1056 let path = format!("/v1/us/orders/{order_id}");
1057
1058 Ok(self
1059 .0
1060 .http_cli
1061 .request(Method::GET, path.as_str())
1062 .dc_restrict(DcRegion::Us)
1063 .response::<Json<USOrderDetailResponse>>()
1064 .send()
1065 .with_subscriber(self.0.log_subscriber.clone())
1066 .await?
1067 .0)
1068 }
1069
1070 /// Get the full US account asset snapshot (stocks, options, crypto, buying
1071 /// power).
1072 ///
1073 /// Path: `GET /v1/us/assets/overview`
1074 ///
1075 /// US token required.
1076 pub async fn us_asset_overview(&self) -> Result<USAssetOverview> {
1077 Ok(self
1078 .0
1079 .http_cli
1080 .request(Method::GET, "/v1/us/assets/overview")
1081 .dc_restrict(DcRegion::Us)
1082 .response::<Json<USAssetOverview>>()
1083 .send()
1084 .with_subscriber(self.0.log_subscriber.clone())
1085 .await?
1086 .0)
1087 }
1088
1089 /// Get realized profit-and-loss for the US account.
1090 ///
1091 /// `currency`: required, e.g. `"USD"`.
1092 /// `category`: optional filter — `"ALL"`, `"STOCK"`, `"OPTION"`, or
1093 /// `"CRYPTO"`.
1094 ///
1095 /// Path: `GET /v1/us/assets/pl/realized`
1096 ///
1097 /// US token required.
1098 pub async fn us_realized_pl(&self, opts: GetUSRealizedPLOptions) -> Result<USRealizedPL> {
1099 #[derive(Serialize)]
1100 struct Query {
1101 currency: String,
1102 #[serde(skip_serializing_if = "Option::is_none")]
1103 category: Option<String>,
1104 }
1105
1106 let currency = if opts.currency.is_empty() {
1107 "USD".to_string()
1108 } else {
1109 opts.currency
1110 };
1111 let category = if opts.category.is_empty() {
1112 None
1113 } else {
1114 Some(opts.category)
1115 };
1116
1117 Ok(self
1118 .0
1119 .http_cli
1120 .request(Method::GET, "/v1/us/assets/pl/realized")
1121 .dc_restrict(DcRegion::Us)
1122 .query_params(Query { currency, category })
1123 .response::<Json<USRealizedPL>>()
1124 .send()
1125 .with_subscriber(self.0.log_subscriber.clone())
1126 .await?
1127 .0)
1128 }
1129
1130 /// Get cash, buying power and margin status for the US account.
1131 ///
1132 /// `currency`: optional, e.g. `"USD"`.
1133 ///
1134 /// Path: `GET /v1/us/assets/cash-and-buy-power`
1135 ///
1136 /// US token required.
1137 pub async fn us_cash_and_buy_power(&self, currency: Option<&str>) -> Result<USCashAndBuyPower> {
1138 #[derive(Serialize)]
1139 struct Query<'a> {
1140 #[serde(skip_serializing_if = "Option::is_none")]
1141 currency: Option<&'a str>,
1142 }
1143
1144 Ok(self
1145 .0
1146 .http_cli
1147 .request(Method::GET, "/v1/us/assets/cash-and-buy-power")
1148 .dc_restrict(DcRegion::Us)
1149 .query_params(Query { currency })
1150 .response::<Json<USCashAndBuyPower>>()
1151 .send()
1152 .with_subscriber(self.0.log_subscriber.clone())
1153 .await?
1154 .0)
1155 }
1156
1157 /// Get the quantity of a US holding that is available to trade.
1158 ///
1159 /// Path: `GET /v1/us/assets/available-holding`
1160 ///
1161 /// US token required.
1162 pub async fn us_available_holding(
1163 &self,
1164 opts: GetUSAvailableHoldingOptions,
1165 ) -> Result<USAvailableHolding> {
1166 #[derive(Serialize)]
1167 struct Query {
1168 symbol: String,
1169 equity_type: String,
1170 position_side: String,
1171 }
1172
1173 let equity_type = if opts.equity_type.is_empty() {
1174 "STOCK".to_string()
1175 } else {
1176 opts.equity_type
1177 };
1178 let position_side = if opts.position_side.is_empty() {
1179 "LONG".to_string()
1180 } else {
1181 opts.position_side
1182 };
1183
1184 Ok(self
1185 .0
1186 .http_cli
1187 .request(Method::GET, "/v1/us/assets/available-holding")
1188 .dc_restrict(DcRegion::Us)
1189 .query_params(Query {
1190 symbol: opts.symbol,
1191 equity_type,
1192 position_side,
1193 })
1194 .response::<Json<USAvailableHolding>>()
1195 .send()
1196 .with_subscriber(self.0.log_subscriber.clone())
1197 .await?
1198 .0)
1199 }
1200
1201 /// Get the US account transaction history (deposits, withdrawals, trades,
1202 /// corporate actions, transfers and so on), one page at a time.
1203 ///
1204 /// Path: `POST /v1/us/assets/transaction-history`
1205 ///
1206 /// US token required.
1207 pub async fn us_transaction_history(
1208 &self,
1209 opts: GetUSTransactionHistoryOptions,
1210 ) -> Result<USTransactionHistory> {
1211 #[derive(Debug, Serialize)]
1212 struct Body {
1213 start: i64,
1214 end: i64,
1215 page: i32,
1216 size: i32,
1217 #[serde(skip_serializing_if = "Vec::is_empty")]
1218 types: Vec<String>,
1219 #[serde(skip_serializing_if = "String::is_empty")]
1220 currency: String,
1221 }
1222
1223 Ok(self
1224 .0
1225 .http_cli
1226 .request(Method::POST, "/v1/us/assets/transaction-history")
1227 .dc_restrict(DcRegion::Us)
1228 .body(Json(Body {
1229 start: opts.start_at.unix_timestamp(),
1230 end: opts.end_at.unix_timestamp(),
1231 page: if opts.page > 0 { opts.page } else { 1 },
1232 size: if opts.size > 0 { opts.size } else { 20 },
1233 types: opts.types,
1234 currency: opts.currency,
1235 }))
1236 .response::<Json<USTransactionHistory>>()
1237 .send()
1238 .with_subscriber(self.0.log_subscriber.clone())
1239 .await?
1240 .0)
1241 }
1242
1243 /// Get the exchange rates used by the US account.
1244 ///
1245 /// The upstream two-level map is flattened into
1246 /// [`USExchangeRates::exchanges`]; each entry keeps the two upstream map
1247 /// keys as `universal_key` and `spec_key`.
1248 ///
1249 /// Path: `GET /v1/us/assets/exchange-rates`
1250 ///
1251 /// US token required.
1252 pub async fn us_exchange_rates(&self) -> Result<USExchangeRates> {
1253 Ok(self
1254 .0
1255 .http_cli
1256 .request(Method::GET, "/v1/us/assets/exchange-rates")
1257 .dc_restrict(DcRegion::Us)
1258 .response::<Json<USExchangeRates>>()
1259 .send()
1260 .with_subscriber(self.0.log_subscriber.clone())
1261 .await?
1262 .0)
1263 }
1264}
1265
1266#[cfg(test)]
1267mod dc_restrict_tests {
1268 use longbridge_httpcli::HttpClientError;
1269 use time::macros::datetime;
1270
1271 use super::*;
1272 use crate::Error;
1273
1274 fn us_trade_context() -> TradeContext {
1275 let config = Arc::new(Config::from_apikey("us_app_key", "app_secret", "us_token"));
1276 TradeContext::new(config).0
1277 }
1278
1279 #[track_caller]
1280 fn assert_ap_restricted<T>(res: Result<T>, path: &str)
1281 where
1282 T: std::fmt::Debug,
1283 {
1284 match res {
1285 Err(Error::HttpClient(HttpClientError::DcRegionRestricted {
1286 path: p,
1287 required,
1288 current,
1289 })) => {
1290 assert_eq!(p, path, "restricted path mismatch");
1291 assert_eq!(required, DcRegion::Ap, "expected AP-only restriction");
1292 assert_eq!(current, DcRegion::Us, "expected US session region");
1293 }
1294 other => panic!("expected DcRegionRestricted for {path}, got {other:?}"),
1295 }
1296 }
1297
1298 /// AP-only asset/risk/order endpoints fail fast for a US (`us_`) token
1299 /// instead of sending a request the US data center rejects.
1300 #[tokio::test(flavor = "multi_thread")]
1301 async fn ap_only_endpoints_reject_us_token() {
1302 let ctx = us_trade_context();
1303
1304 assert_ap_restricted(ctx.account_balance(None).await, "/v1/asset/account");
1305 assert_ap_restricted(
1306 ctx.cash_flow(GetCashFlowOptions::new(
1307 datetime!(2026-01-01 0:00 UTC),
1308 datetime!(2026-01-02 0:00 UTC),
1309 ))
1310 .await,
1311 "/v1/asset/cashflow",
1312 );
1313 assert_ap_restricted(ctx.fund_positions(None).await, "/v1/asset/fund");
1314 assert_ap_restricted(ctx.stock_positions(None).await, "/v1/asset/stock");
1315 assert_ap_restricted(ctx.margin_ratio("700.HK").await, "/v1/risk/margin-ratio");
1316 assert_ap_restricted(ctx.order_detail("1").await, "/v1/trade/order");
1317 assert_ap_restricted(ctx.all_executions(None).await, "/v3/trade/execution/all");
1318 }
1319}