Skip to main content

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}