diff --git a/.gitignore b/.gitignore index 6d0f7e95d5..ca1b0defd8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .vscode *.log cmake.build +.claude/ build # Eclipse IDE diff --git a/java/src/fundamental_context.rs b/java/src/fundamental_context.rs index bfd2d8b373..7215d411f3 100644 --- a/java/src/fundamental_context.rs +++ b/java/src/fundamental_context.rs @@ -330,3 +330,134 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_fundamentalContextMa Ok(()) }) } + +// ── US-market JNI stubs +// ─────────────────────────────────────────────────────── All US APIs return +// JSON strings; Java callers parse with Gson/Jackson. + +macro_rules! us_counter_id_method { + ($jni_name:ident, $method:ident) => { + #[unsafe(no_mangle)] + pub unsafe extern "system" fn $jni_name( + mut env: JNIEnv, + _class: JClass, + context: i64, + counter_id: JObject, + callback: JObject, + ) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let counter_id: String = FromJValue::from_jvalue(env, counter_id.into())?; + async_util::execute(env, callback, async move { + let resp = context.ctx.$method(counter_id).await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) + } + }; +} + +macro_rules! us_counter_id_report_method { + ($jni_name:ident, $method:ident) => { + #[unsafe(no_mangle)] + pub unsafe extern "system" fn $jni_name( + mut env: JNIEnv, + _class: JClass, + context: i64, + counter_id: JObject, + report: JObject, + callback: JObject, + ) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let counter_id: String = FromJValue::from_jvalue(env, counter_id.into())?; + let report: String = FromJValue::from_jvalue(env, report.into())?; + async_util::execute(env, callback, async move { + let resp = context.ctx.$method(counter_id, report).await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) + } + }; +} + +us_counter_id_method!( + Java_com_longbridge_SdkNative_fundamentalContextUsCompanyOverview, + us_company_overview +); +us_counter_id_method!( + Java_com_longbridge_SdkNative_fundamentalContextUsValuationOverview, + us_valuation_overview +); +us_counter_id_report_method!( + Java_com_longbridge_SdkNative_fundamentalContextUsFinancialOverview, + us_financial_overview +); +us_counter_id_report_method!( + Java_com_longbridge_SdkNative_fundamentalContextUsKeyFinancialMetrics, + us_key_financial_metrics +); +us_counter_id_report_method!( + Java_com_longbridge_SdkNative_fundamentalContextUsAnalystConsensus, + us_analyst_consensus +); +us_counter_id_method!( + Java_com_longbridge_SdkNative_fundamentalContextUsEtfDividendInfo, + us_etf_dividend_info +); +us_counter_id_method!( + Java_com_longbridge_SdkNative_fundamentalContextUsCompanyDividends, + us_company_dividends +); + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_fundamentalContextUsFinancialStatementV3( + mut env: JNIEnv, + _class: JClass, + context: i64, + counter_id: JObject, + kind: JObject, + report: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let counter_id: String = FromJValue::from_jvalue(env, counter_id.into())?; + let kind: String = FromJValue::from_jvalue(env, kind.into())?; + let report: String = FromJValue::from_jvalue(env, report.into())?; + async_util::execute(env, callback, async move { + let resp = context + .ctx + .us_financial_statement_v3(counter_id, kind, report) + .await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_fundamentalContextUsEtfFiles( + mut env: JNIEnv, + _class: JClass, + context: i64, + counter_id: JObject, + size: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let counter_id: String = FromJValue::from_jvalue(env, counter_id.into())?; + let size: Option = FromJValue::from_jvalue(env, size.into())?; + async_util::execute(env, callback, async move { + let resp = context + .ctx + .us_etf_files(counter_id, size.map(|s| s as u32)) + .await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) +} diff --git a/java/src/quote_context.rs b/java/src/quote_context.rs index 167942ab73..1fa8377fa4 100644 --- a/java/src/quote_context.rs +++ b/java/src/quote_context.rs @@ -1282,3 +1282,22 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_quoteContextOptionVo Ok(()) }) } + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_quoteContextUsCryptoOverview( + mut env: JNIEnv, + _class: JClass, + context: i64, + symbol: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let symbol: String = FromJValue::from_jvalue(env, symbol.into())?; + async_util::execute(env, callback, async move { + let resp = context.ctx.us_crypto_overview(symbol).await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) +} diff --git a/java/src/trade_context.rs b/java/src/trade_context.rs index 214812ffdc..66a7b49fdb 100644 --- a/java/src/trade_context.rs +++ b/java/src/trade_context.rs @@ -4,7 +4,7 @@ use jni::{ JNIEnv, JavaVM, errors::Result, objects::{GlobalRef, JClass, JObject, JString}, - sys::jobjectArray, + sys::{jboolean, jobjectArray}, }; use longbridge::{ Config, Decimal, Market, TradeContext, @@ -12,8 +12,8 @@ use longbridge::{ BalanceType, EstimateMaxPurchaseQuantityOptions, GetCashFlowOptions, GetFundPositionsOptions, GetHistoryExecutionsOptions, GetHistoryOrdersOptions, GetStockPositionsOptions, GetTodayExecutionsOptions, GetTodayOrdersOptions, OrderSide, - OrderStatus, OrderType, OutsideRTH, PushEvent, ReplaceOrderOptions, SubmitOrderOptions, - TimeInForceType, TopicType, + OrderStatus, OrderType, OutsideRTH, PushEvent, QueryUSOrdersOptions, ReplaceOrderOptions, + SubmitOrderOptions, TimeInForceType, TopicType, }, }; use parking_lot::Mutex; @@ -625,3 +625,104 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_tradeContextEstimate Ok(()) }) } + +// ── US-market JNI stubs ────────────────────────────────────────────────────── + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_tradeContextUsQueryOrders( + mut env: JNIEnv, + _class: JClass, + context: i64, + symbol: JObject, + action: i64, + start_at: i64, + end_at: i64, + query_type: i64, + page: i64, + limit: i64, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let symbol_str: String = FromJValue::from_jvalue(env, symbol.into())?; + let us_opts = longbridge::trade::GetUSHistoryOrders { + symbol: if symbol_str.is_empty() { + None + } else { + Some(symbol_str) + }, + side: match action { + 1 => longbridge::trade::OrderSide::Buy, + 2 => longbridge::trade::OrderSide::Sell, + _ => longbridge::trade::OrderSide::Unknown, + }, + start_at, + end_at, + query_type: query_type as i32, + page: page as i32, + limit: limit as i32, + }; + async_util::execute(env, callback, async move { + let resp = context.ctx.us_query_orders(us_opts).await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_tradeContextUsOrderDetail( + mut env: JNIEnv, + _class: JClass, + context: i64, + order_id: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let order_id: String = FromJValue::from_jvalue(env, order_id.into())?; + async_util::execute(env, callback, async move { + let resp = context.ctx.us_order_detail(order_id).await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_tradeContextUsAssetOverview( + mut env: JNIEnv, + _class: JClass, + context: i64, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + async_util::execute(env, callback, async move { + let resp = context.ctx.us_asset_overview().await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_tradeContextUsRealizedPl( + mut env: JNIEnv, + _class: JClass, + context: i64, + currency: JObject, + category: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let currency: String = FromJValue::from_jvalue(env, currency.into())?; + let category: Option = FromJValue::from_jvalue(env, category.into()).ok(); + async_util::execute(env, callback, async move { + let resp = context.ctx.us_realized_pl(longbridge::trade::GetUSRealizedPLOptions { currency, category: category.unwrap_or_default() }).await?; + Ok(serde_json::to_string(&resp).unwrap_or_default()) + })?; + Ok(()) + }) +} diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 4fcaab997c..1347f52999 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -622,6 +622,24 @@ export declare class FundamentalContext { macroeconomicIndicators(country?: MacroeconomicCountry | undefined | null, keyword?: string | undefined | null, offset?: number | undefined | null, limit?: number | undefined | null): Promise /** Get historical data for a macroeconomic indicator */ macroeconomic(indicatorCode: string, startDate?: string | undefined | null, endDate?: string | undefined | null, offset?: number | undefined | null, limit?: number | undefined | null): Promise + /** Get US company overview. US token required. counterID format: "ST/US/AAPL" */ + usCompanyOverview(symbol: string): Promise + /** Get US valuation snapshot (PE/PB/PS). US token required. */ + usValuationOverview(symbol: string): Promise + /** Get US financial overview (revenue/net income/EPS). Returns JSON string. US token required. */ + usFinancialOverview(symbol: string, report: string): Promise + /** Get US financial statement (IS/BS/CF). kind: "IS" | "BS" | "CF". US token required. */ + usFinancialStatementV3(symbol: string, kind: string, report: string): Promise + /** Get US key financial metrics (ROE/margins). Returns JSON string. US token required. */ + usKeyFinancialMetrics(symbol: string, report: string): Promise + /** Get US analyst consensus estimates. Returns JSON string. US token required. */ + usAnalystConsensus(symbol: string, report: string): Promise + /** Get US ETF dividend history. US token required. */ + usEtfDividendInfo(symbol: string): Promise + /** Get US company historical dividends. US token required. */ + usCompanyDividends(symbol: string): Promise + /** Get US ETF document list. size=null returns all. US token required. */ + usEtfFiles(symbol: string, size?: number | undefined | null): Promise } /** Fund position */ @@ -2030,6 +2048,8 @@ export declare class QuoteContext { optionVolume(symbol: string): Promise /** Get daily historical option volume */ optionVolumeDaily(symbol: string, timestamp: number, count: number): Promise + /** Get US cryptocurrency market overview. counterID format: "CY/US/BTC". US token required. */ + usCryptoOverview(symbol: string): Promise } export declare class QuotePackageDetail { @@ -2800,6 +2820,21 @@ export declare class TradeContext { * ``` */ estimateMaxPurchaseQuantity(opts: EstimateMaxPurchaseQuantityOptions): Promise + /** Query US order list (paginated). Returns JSON string. US token required. */ + /** + * Query US order list. Returns JSON string with shape `{orders: USOrder[], total_count: number}`. + * symbol: user-facing symbol e.g. "AAPL.US" (optional). + * action: 0=all, 1=buy, 2=sell. + * queryType: 0=all (incl. Rejected), 1=pending, 2=history (filled only). + * US token required. + */ + usQueryOrders(symbol?: string | null, action?: number, startAt?: number, endAt?: number, queryType?: number, page?: number, limit?: number): Promise + /** Get US order detail. isAttached=true includes take-profit/stop-loss sub-orders. Returns JSON string. US token required. */ + usOrderDetail(orderId: string): Promise + /** Get US account asset overview (stocks/options/crypto/buy power). US token required. */ + usAssetOverview(): Promise + /** Get US realized P&L. category: "ALL"|"STOCK"|"OPTION"|"CRYPTO". US token required. */ + usRealizedPl(currency: string, category?: string | undefined | null): Promise } /** The information of trading session */ @@ -6059,3 +6094,156 @@ export declare const enum WarrantType { /** Inline */ Inline = 5 } + +export interface USOrderHistory { + execType: number + status: string + price: string + qty: string + time: string + msg: string +} + +export interface USOrderDetailResponse { + order: Record + orderHistories: Array + currentAttachedOrder: Record | null +} + +// ── US-market types ──────────────────────────────────────────────────────── + +export interface USRankTag { + name: string + chg: string + rankType: number +} + +export interface USCompanyOverview { + intro: string + marketCap: string + ccySymbol: string + topRankTags: Array + detailUrl: string +} + +export interface USValuationIndicator { + circle: string + part: string + metric: string + metricType: string + desc: string + ccySymbol: string +} + +export interface USValuationOverview { + indicator: string + currentIndicator: USValuationIndicator + range: number + date: string + aiSummary: string +} + +export interface USFinancialStatement { + revenue: string + netIncome: string + netMargin: string + periods: Array + currency: string +} + +export interface USETFDividendInfo { + dividendTtm: string + dividendYieldTtm: string + dividendFrequency: string + currency: string + fiscalYearInfo: Array +} + +export interface USDividendItem { + dividend: string + dividendType: string + exDate: string + paymentDate: string + recordDate: string +} + +export interface USCompanyDividends { + dividendTtm: string + dividendYieldTtm: string + payouts: string + currency: string + items: Array +} + +export interface USETFFile { + name: string + fileType: string + url: string +} + +export interface USETFFilesResponse { + files: Array +} + +export interface USCryptoOverview { + symbol: string + name: string + ticker: string + baseAsset: string + currency: string + allTimeHigh: string + allTimeHighDate: string + allTimeLow: string + allTimeLowDate: string + ipoDate: string + issuePrice: string + shares: string + officialWebAddress: string + logo: string + wikiUrl: string + /** Profile / description as a JSON string */ + profile: string +} + +export interface USCashEntry { + currency: string + frozenBuyCash: string + outstanding: string + settledCash: string + totalAmount: string + totalCash: string +} + +export interface USCryptoEntry { + assetType: string + averageCost: string + /** User-facing symbol, e.g. "BTCUSD.BKKT" */ + symbol: string + currency: string + industryName: string +} + +export interface USAssetOverview { + accountType: string + /** Unix timestamp (seconds) */ + assetTimestamp: number + cashBuyPower: string + cashList: Array + cryptoList: Array +} + +export interface USRealizedPLMetric { + amount: string + period: number + rate: string +} + +export interface USRealizedPLEntry { + category: number + currency: string + metrics: Array +} + +export interface USRealizedPL { + realizedPlList: Array +} diff --git a/nodejs/src/fundamental/context.rs b/nodejs/src/fundamental/context.rs index 78ae9285f6..c7e759d0bd 100644 --- a/nodejs/src/fundamental/context.rs +++ b/nodejs/src/fundamental/context.rs @@ -322,4 +322,114 @@ impl FundamentalContext { .map_err(ErrorNewType)? .into()) } + + // ── US-market methods ───────────────────────────────────────────────────── + + /// Get US company overview. US token required. + #[napi] + pub async fn us_company_overview(&self, symbol: String) -> Result { + Ok(self + .ctx + .us_company_overview(symbol) + .await + .map_err(ErrorNewType)? + .into()) + } + + /// Get US valuation overview. US token required. + #[napi] + pub async fn us_valuation_overview(&self, symbol: String) -> Result { + Ok(self + .ctx + .us_valuation_overview(symbol) + .await + .map_err(ErrorNewType)? + .into()) + } + + /// Get US financial overview (JSON string). US token required. + #[napi] + pub async fn us_financial_overview(&self, symbol: String, report: String) -> Result { + let v = self + .ctx + .us_financial_overview(symbol, report) + .await + .map_err(ErrorNewType)?; + serde_json::to_string(&v).map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Get US financial statement v3. kind: "IS"/"BS"/"CF". US token required. + #[napi] + pub async fn us_financial_statement_v3( + &self, + symbol: String, + kind: String, + report: String, + ) -> Result { + Ok(self + .ctx + .us_financial_statement_v3(symbol, kind, report) + .await + .map_err(ErrorNewType)? + .into()) + } + + /// Get US key financial metrics (JSON string). US token required. + #[napi] + pub async fn us_key_financial_metrics(&self, symbol: String, report: String) -> Result { + let v = self + .ctx + .us_key_financial_metrics(symbol, report) + .await + .map_err(ErrorNewType)?; + serde_json::to_string(&v).map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Get US analyst consensus (JSON string). US token required. + #[napi] + pub async fn us_analyst_consensus(&self, symbol: String, report: String) -> Result { + let v = self + .ctx + .us_analyst_consensus(symbol, report) + .await + .map_err(ErrorNewType)?; + serde_json::to_string(&v).map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Get US ETF dividend history. US token required. + #[napi] + pub async fn us_etf_dividend_info(&self, symbol: String) -> Result { + Ok(self + .ctx + .us_etf_dividend_info(symbol) + .await + .map_err(ErrorNewType)? + .into()) + } + + /// Get US company dividends. US token required. + #[napi] + pub async fn us_company_dividends(&self, symbol: String) -> Result { + Ok(self + .ctx + .us_company_dividends(symbol) + .await + .map_err(ErrorNewType)? + .into()) + } + + /// Get US ETF document list. size=None returns all. US token required. + #[napi] + pub async fn us_etf_files( + &self, + symbol: String, + size: Option, + ) -> Result { + Ok(self + .ctx + .us_etf_files(symbol, size) + .await + .map_err(ErrorNewType)? + .into()) + } } diff --git a/nodejs/src/fundamental/types.rs b/nodejs/src/fundamental/types.rs index 0e8526c87d..5ca0bbc4b7 100644 --- a/nodejs/src/fundamental/types.rs +++ b/nodejs/src/fundamental/types.rs @@ -2057,3 +2057,224 @@ impl From for MacroeconomicResponse { } } } + +// ── US-market types +// ─────────────────────────────────────────────────────────── + +use longbridge::fundamental::types as lb_us; + +/// Industry rank tag +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USRankTag { + pub name: String, + pub chg: String, + pub rank_type: i32, +} + +impl From for USRankTag { + fn from(v: lb_us::USRankTag) -> Self { + Self { + name: v.name, + chg: v.chg, + rank_type: v.rank_type, + } + } +} + +/// US company overview +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USCompanyOverview { + pub intro: String, + pub market_cap: String, + pub ccy_symbol: String, + pub top_rank_tags: Vec, + pub detail_url: String, +} + +impl From for USCompanyOverview { + fn from(v: lb_us::USCompanyOverview) -> Self { + Self { + intro: v.intro, + market_cap: v.market_cap, + ccy_symbol: v.ccy_symbol, + top_rank_tags: v.top_rank_tags.into_iter().map(Into::into).collect(), + detail_url: v.detail_url, + } + } +} + +/// US valuation indicator +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USValuationIndicator { + pub circle: String, + pub part: String, + pub metric: String, + pub metric_type: String, + pub desc: String, + pub ccy_symbol: String, +} + +impl From for USValuationIndicator { + fn from(v: lb_us::USValuationIndicator) -> Self { + Self { + circle: v.circle, + part: v.part, + metric: v.metric, + metric_type: v.metric_type, + desc: v.desc, + ccy_symbol: v.ccy_symbol, + } + } +} + +/// US valuation overview +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USValuationOverview { + pub indicator: String, + pub current_indicator: USValuationIndicator, + pub range: i32, + pub date: String, + pub ai_summary: String, +} + +impl From for USValuationOverview { + fn from(v: lb_us::USValuationOverview) -> Self { + Self { + indicator: v.indicator, + current_indicator: v.current_indicator.into(), + range: v.range, + date: v.date, + ai_summary: v.ai_summary, + } + } +} + +/// US financial statement +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USFinancialStatement { + pub revenue: String, + pub net_income: String, + pub net_margin: String, + pub periods: Vec, + pub currency: String, +} + +impl From for USFinancialStatement { + fn from(v: lb_us::USFinancialStatement) -> Self { + Self { + revenue: v.revenue, + net_income: v.net_income, + net_margin: v.net_margin, + periods: v.periods, + currency: v.currency, + } + } +} + +/// US ETF dividend info +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USETFDividendInfo { + pub dividend_ttm: String, + pub dividend_yield_ttm: String, + pub dividend_frequency: String, + pub currency: String, + pub fiscal_year_info: Vec, +} + +impl From for USETFDividendInfo { + fn from(v: lb_us::USETFDividendInfo) -> Self { + Self { + dividend_ttm: v.dividend_ttm, + dividend_yield_ttm: v.dividend_yield_ttm, + dividend_frequency: v.dividend_frequency, + currency: v.currency, + fiscal_year_info: v.fiscal_year_info, + } + } +} + +/// US dividend item +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USDividendItem { + pub dividend: String, + pub dividend_type: String, + pub ex_date: String, + pub payment_date: String, + pub record_date: String, +} + +impl From for USDividendItem { + fn from(v: lb_us::USDividendItem) -> Self { + Self { + dividend: v.dividend, + dividend_type: v.dividend_type, + ex_date: v.ex_date, + payment_date: v.payment_date, + record_date: v.record_date, + } + } +} + +/// US company dividends +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USCompanyDividends { + pub dividend_ttm: String, + pub dividend_yield_ttm: String, + pub payouts: String, + pub currency: String, + pub items: Vec, +} + +impl From for USCompanyDividends { + fn from(v: lb_us::USCompanyDividends) -> Self { + Self { + dividend_ttm: v.dividend_ttm, + dividend_yield_ttm: v.dividend_yield_ttm, + payouts: v.payouts, + currency: v.currency, + items: v.items.into_iter().map(Into::into).collect(), + } + } +} + +/// US ETF file +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USETFFile { + pub name: String, + pub file_type: String, + pub url: String, +} + +impl From for USETFFile { + fn from(v: lb_us::USETFFile) -> Self { + Self { + name: v.name, + file_type: v.file_type, + url: v.url, + } + } +} + +/// US ETF files response +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USETFFilesResponse { + pub files: Vec, +} + +impl From for USETFFilesResponse { + fn from(v: lb_us::USETFFilesResponse) -> Self { + Self { + files: v.files.into_iter().map(Into::into).collect(), + } + } +} diff --git a/nodejs/src/quote/context.rs b/nodejs/src/quote/context.rs index 4ee04a1e25..5cf1f831d4 100644 --- a/nodejs/src/quote/context.rs +++ b/nodejs/src/quote/context.rs @@ -21,8 +21,8 @@ use crate::{ RealtimeQuote, Security, SecurityBrokers, SecurityCalcIndex, SecurityDepth, SecurityListCategory, SecurityQuote, SecurityStaticInfo, ShortPositionsResponse, ShortTradesResponse, SortOrderType, StrikePriceInfo, SubType, SubTypes, Subscription, - Trade, TradeSessions, WarrantInfo, WarrantQuote, WarrantSortBy, WarrantStatus, - WarrantType, WatchlistGroup, + Trade, TradeSessions, USCryptoOverview, WarrantInfo, WarrantQuote, WarrantSortBy, + WarrantStatus, WarrantType, WatchlistGroup, }, }, time::{NaiveDate, NaiveDatetime}, @@ -1290,4 +1290,15 @@ impl QuoteContext { .map_err(ErrorNewType)? .into()) } + + /// Get US cryptocurrency market overview. US token required. + #[napi] + pub async fn us_crypto_overview(&self, symbol: String) -> Result { + Ok(self + .ctx + .us_crypto_overview(symbol) + .await + .map_err(ErrorNewType)? + .into()) + } } diff --git a/nodejs/src/quote/types.rs b/nodejs/src/quote/types.rs index acd0cfdd46..647fcd1e6c 100644 --- a/nodejs/src/quote/types.rs +++ b/nodejs/src/quote/types.rs @@ -1663,3 +1663,50 @@ impl From for OptionVolumeDailyStat { } } } + +/// US cryptocurrency market overview +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct USCryptoOverview { + pub name: String, + pub ticker: String, + pub currency: String, + pub all_time_high: String, + pub all_time_high_date: String, + pub all_time_low: String, + pub all_time_low_date: String, + pub ipo_date: String, + pub issue_price: String, + pub shares: String, + pub official_web_address: String, + /// User-facing symbol (e.g. "BTCUSD.BKKT"), converted from counter_id + pub symbol: String, + pub base_asset: String, + pub logo: String, + pub wiki_url: String, + /// Profile serialized as JSON string + pub profile: String, +} + +impl From for USCryptoOverview { + fn from(v: longbridge::quote::USCryptoOverview) -> Self { + Self { + name: v.name, + ticker: v.ticker, + currency: v.currency, + all_time_high: v.all_time_high, + all_time_high_date: v.all_time_high_date, + all_time_low: v.all_time_low, + all_time_low_date: v.all_time_low_date, + ipo_date: v.ipo_date, + issue_price: v.issue_price, + shares: v.shares, + official_web_address: v.official_web_address, + symbol: v.symbol, + base_asset: v.base_asset, + logo: v.logo, + wiki_url: v.wiki_url, + profile: v.profile, + } + } +} diff --git a/nodejs/src/trade/context.rs b/nodejs/src/trade/context.rs index eca83725b5..036807b5cf 100644 --- a/nodejs/src/trade/context.rs +++ b/nodejs/src/trade/context.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use longbridge::trade::{GetFundPositionsOptions, GetStockPositionsOptions, PushEvent}; +use longbridge::trade::{ + GetFundPositionsOptions, GetStockPositionsOptions, PushEvent, QueryUSOrdersOptions, +}; use napi::{Result, bindgen_prelude::*, threadsafe_function::ThreadsafeFunctionCallMode}; use parking_lot::Mutex; @@ -502,6 +504,86 @@ impl TradeContext { .try_into() } + // ── US-market methods ───────────────────────────────────────────────── + + /// Query US order list. Returns JSON string. US token required. + /// symbol: user-facing symbol e.g. "AAPL.US"; action: 0=all/1=buy/2=sell. + #[napi] + pub fn us_query_orders<'env>( + &self, + env: &'env Env, + symbol: Option, + action: i32, + start_at: i64, + end_at: i64, + query_type: i32, + page: i32, + limit: i32, + ) -> Result> { + let ctx = self.ctx.clone(); + let opts = longbridge::trade::GetUSHistoryOrders { + symbol, + side: match action { + 1 => longbridge::trade::OrderSide::Buy, + 2 => longbridge::trade::OrderSide::Sell, + _ => longbridge::trade::OrderSide::Unknown, + }, + start_at, + end_at, + query_type, + page, + limit, + }; + env.spawn_future(async move { + let resp = ctx.us_query_orders(opts).await.map_err(ErrorNewType)?; + serde_json::to_string(&resp).map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } + + /// Get US order detail. Returns JSON string. US token required. + #[napi] + pub fn us_order_detail<'env>( + &self, + env: &'env Env, + order_id: String, + ) -> Result> { + let ctx = self.ctx.clone(); + env.spawn_future(async move { + let resp = ctx.us_order_detail(order_id).await.map_err(ErrorNewType)?; + serde_json::to_string(&resp).map_err(|e| napi::Error::from_reason(e.to_string())) + }) + } + + /// Get US account asset overview. US token required. + #[napi] + pub fn us_asset_overview<'env>( + &self, + env: &'env Env, + ) -> Result> { + let ctx = self.ctx.clone(); + env.spawn_future( + async move { Ok(ctx.us_asset_overview().await.map_err(ErrorNewType)?.into()) }, + ) + } + + /// Get US realized P&L. US token required. + #[napi] + pub fn us_realized_pl<'env>( + &self, + env: &'env Env, + currency: String, + category: Option, + ) -> Result> { + let ctx = self.ctx.clone(); + env.spawn_future(async move { + Ok(ctx + .us_realized_pl(longbridge::trade::GetUSRealizedPLOptions { currency, category: category.unwrap_or_default() }) + .await + .map_err(ErrorNewType)? + .into()) + }) + } + /// Estimating the maximum purchase quantity for Hong Kong and US stocks, /// warrants, and options /// diff --git a/nodejs/src/trade/types.rs b/nodejs/src/trade/types.rs index be4e0e257e..98edc390c8 100644 --- a/nodejs/src/trade/types.rs +++ b/nodejs/src/trade/types.rs @@ -795,3 +795,132 @@ pub struct EstimateMaxPurchaseQuantityResponse { /// Margin available quantity margin_max_qty: Decimal, } + +// ── US-market types ────────────────────────────────────────────────────────── + +/// One cash currency entry in USAssetOverview +#[napi_derive::napi(object)] +#[derive(Debug, Clone, Default)] +pub struct USCashEntry { + pub currency: String, + pub frozen_buy_cash: String, + pub outstanding: String, + pub settled_cash: String, + pub total_amount: String, + pub total_cash: String, +} + +impl From for USCashEntry { + fn from(v: longbridge::trade::USCashEntry) -> Self { + Self { + currency: v.currency, + frozen_buy_cash: v.frozen_buy_cash, + outstanding: v.outstanding, + settled_cash: v.settled_cash, + total_amount: v.total_amount, + total_cash: v.total_cash, + } + } +} + +/// One cryptocurrency holding in USAssetOverview +#[napi_derive::napi(object)] +#[derive(Debug, Clone, Default)] +pub struct USCryptoEntry { + pub asset_type: String, + pub average_cost: String, + pub symbol: String, + pub currency: String, + pub industry_name: String, +} + +impl From for USCryptoEntry { + fn from(v: longbridge::trade::USCryptoEntry) -> Self { + Self { + asset_type: v.asset_type, + average_cost: v.average_cost, + symbol: v.symbol, + currency: v.currency, + industry_name: v.industry_name, + } + } +} + +/// US account asset snapshot +#[napi_derive::napi(object)] +#[derive(Debug, Clone, Default)] +pub struct USAssetOverview { + pub account_type: String, + pub asset_timestamp: i64, + pub cash_buy_power: String, + pub cash_list: Vec, + pub crypto_list: Vec, +} + +impl From for USAssetOverview { + fn from(v: longbridge::trade::USAssetOverview) -> Self { + Self { + account_type: v.account_type, + asset_timestamp: v + .asset_timestamp + .map(|t| t.unix_timestamp()) + .unwrap_or_default(), + cash_buy_power: v.cash_buy_power, + cash_list: v.cash_list.into_iter().map(Into::into).collect(), + crypto_list: v.crypto_list.into_iter().map(Into::into).collect(), + } + } +} + +/// One time-period metric in USRealizedPLEntry +#[napi_derive::napi(object)] +#[derive(Debug, Clone, Default)] +pub struct USRealizedPLMetric { + pub amount: String, + pub period: i32, + pub rate: String, +} + +impl From for USRealizedPLMetric { + fn from(v: longbridge::trade::USRealizedPLMetric) -> Self { + Self { + amount: v.amount, + period: v.period, + rate: v.rate, + } + } +} + +/// One asset-category entry in USRealizedPL +#[napi_derive::napi(object)] +#[derive(Debug, Clone, Default)] +pub struct USRealizedPLEntry { + pub category: i32, + pub currency: String, + pub metrics: Vec, +} + +impl From for USRealizedPLEntry { + fn from(v: longbridge::trade::USRealizedPLEntry) -> Self { + Self { + category: v.category, + currency: v.currency, + metrics: v.metrics.into_iter().map(Into::into).collect(), + } + } +} + +/// Realized P&L response for a US account +#[napi_derive::napi(object)] +#[derive(Debug, Clone, Default)] +pub struct USRealizedPL { + pub realized_pl_list: Vec, +} + +impl From for USRealizedPL { + fn from(v: longbridge::trade::USRealizedPL) -> Self { + Self { + realized_pl_list: v.realized_pl_list.into_iter().map(Into::into).collect(), + } + } +} diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index b5a518f60f..8f2dc62365 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -4020,6 +4020,17 @@ class QuoteContext: :class:`ShortTradesResponse` with raw JSON data """ + def us_crypto_overview(self, symbol: str) -> "USCryptoOverview": + """Get US cryptocurrency market overview. US token required. + + Args: + symbol: Trading-pair symbol, e.g. ``"BTCUSD.BKKT"`` + + Returns: + :class:`USCryptoOverview` + """ + ... + class AsyncQuoteContext: """ Async quote context for use with asyncio. Create via `AsyncQuoteContext.create(config)` and await inside asyncio. @@ -5372,6 +5383,17 @@ class AsyncQuoteContext: """ ... + def us_crypto_overview(self, symbol: str) -> "Awaitable[USCryptoOverview]": + """Get US cryptocurrency market overview. US token required. Returns awaitable. + + Args: + symbol: Trading-pair symbol, e.g. ``"BTCUSD.BKKT"`` + + Returns: + Awaitable resolving to :class:`USCryptoOverview` + """ + ... + class OrderSide: """ Order side @@ -7319,6 +7341,76 @@ class TradeContext: print(resp) """ + def us_query_orders( + self, + symbol: Optional[str] = None, + action: int = 0, + start_at: int = 0, + end_at: int = 0, + query_type: int = 0, + page: int = 1, + limit: int = 20, + ) -> str: + """Query US order list (paginated). Returns JSON string. US token required. + + Args: + symbol: Optional symbol filter, e.g. ``"AAPL.US"`` + action: Direction filter: 0=all, 1=buy, 2=sell + start_at: Start timestamp (unix seconds); 0 = last 90 days + end_at: End timestamp (unix seconds); 0 = now + query_type: 0=all (incl. Rejected), 1=pending, 2=history (filled only) + page: Page number, 1-based + limit: Page size + + Returns: + JSON string with order list + """ + ... + + def us_order_detail(self, order_id: str) -> str: + """Get US order detail. US token required. + + Returns a JSON string with shape:: + + { + "order": {...}, + "order_histories": [...], + "current_attached_order": null + } + + Path: ``GET /v1/orders/{order_id}`` (no query parameters). + + Args: + order_id: Order ID + is_attached: Whether to include attached sub-orders + + Returns: + JSON string with order detail + """ + ... + + def us_asset_overview(self) -> "USAssetOverview": + """Get US account asset overview. US token required. + + Returns: + :class:`USAssetOverview` with stock, option, and crypto positions + plus purchasing power + """ + ... + + def us_realized_pl(self, currency: str, category: Optional[str] = None) -> "USRealizedPL": + """Get realized P&L for the US account. US token required. + + Args: + currency: Currency (e.g. ``"USD"``) + category: Asset category filter: ``"ALL"``, ``"STOCK"``, + ``"OPTION"``, or ``"CRYPTO"``; ``None`` for all + + Returns: + :class:`USRealizedPL` + """ + ... + class AsyncTradeContext: """ Async trade context for use with asyncio. Create via `AsyncTradeContext.create(config)` and await inside asyncio. @@ -7985,6 +8077,75 @@ class AsyncTradeContext: """ ... + def us_query_orders( + self, + symbol: Optional[str] = None, + action: int = 0, + start_at: int = 0, + end_at: int = 0, + query_type: int = 0, + page: int = 1, + limit: int = 20, + ) -> "Awaitable[str]": + """Query US order list (paginated). Returns awaitable JSON string. US token required. + + Args: + symbol: Optional symbol filter, e.g. ``"AAPL.US"`` + action: Direction filter: 0=all, 1=buy, 2=sell + start_at: Start timestamp (unix seconds); 0 = last 90 days + end_at: End timestamp (unix seconds); 0 = now + query_type: 0=all (incl. Rejected), 1=pending, 2=history (filled only) + page: Page number, 1-based + limit: Page size + + Returns: + Awaitable resolving to JSON string with order list + """ + ... + + def us_order_detail(self, order_id: str) -> "Awaitable[str]": + """Get US order detail (async). US token required. + + Returns a JSON string with shape:: + + { + "order": {...}, + "order_histories": [...], + "current_attached_order": null + } + + Path: ``GET /v1/orders/{order_id}`` (no query parameters). + + Args: + order_id: Order ID + is_attached: Whether to include attached sub-orders + + Returns: + Awaitable resolving to JSON string with order detail + """ + ... + + def us_asset_overview(self) -> "Awaitable[USAssetOverview]": + """Get US account asset overview. US token required. Returns awaitable. + + Returns: + Awaitable resolving to :class:`USAssetOverview` + """ + ... + + def us_realized_pl(self, currency: str, category: Optional[str] = None) -> "Awaitable[USRealizedPL]": + """Get realized P&L for the US account. US token required. Returns awaitable. + + Args: + currency: Currency (e.g. ``"USD"``) + category: Asset category filter: ``"ALL"``, ``"STOCK"``, + ``"OPTION"``, or ``"CRYPTO"``; ``None`` for all + + Returns: + Awaitable resolving to :class:`USRealizedPL` + """ + ... + class StatementType: """ Statement type @@ -9960,6 +10121,111 @@ class FundamentalContext: """ ... + def us_company_overview(self, symbol: str) -> "USCompanyOverview": + """Get US company overview. US token required. + + Args: + counter_id: Counter ID, e.g. ``"ST/US/AAPL"`` + + Returns: + :class:`USCompanyOverview` + """ + ... + + def us_valuation_overview(self, symbol: str) -> "USValuationOverview": + """Get US valuation overview. US token required. + + Args: + counter_id: Counter ID, e.g. ``"ST/US/AAPL"`` + + Returns: + :class:`USValuationOverview` + """ + ... + + def us_financial_overview(self, symbol: str, report: str) -> Any: + """Get US financial overview (revenue, net income, EPS, cash flow). US token required. + + Args: + counter_id: Counter ID, e.g. ``"ST/US/AAPL"`` + report: ``"annual"`` or ``"quarterly"`` + + Returns: + Dict with financial overview data + """ + ... + + def us_financial_statement_v3(self, symbol: str, kind: str, report: str) -> "USFinancialStatement": + """Get US financial statement detail (IS/BS/CF). US token required. + + Args: + counter_id: Counter ID, e.g. ``"ST/US/AAPL"`` + kind: Statement kind: ``"IS"`` (income), ``"BS"`` (balance sheet), ``"CF"`` (cash flow) + report: ``"annual"`` or ``"quarterly"`` + + Returns: + :class:`USFinancialStatement` + """ + ... + + def us_key_financial_metrics(self, symbol: str, report: str) -> Any: + """Get US key financial metrics (ROE, margins, debt ratio). US token required. + + Args: + counter_id: Counter ID, e.g. ``"ST/US/AAPL"`` + report: ``"annual"`` or ``"quarterly"`` + + Returns: + Dict with key metrics + """ + ... + + def us_analyst_consensus(self, symbol: str, report: str) -> Any: + """Get US analyst consensus estimates (EPS, revenue forecasts). US token required. + + Args: + counter_id: Counter ID, e.g. ``"ST/US/AAPL"`` + report: ``"annual"`` or ``"quarterly"`` + + Returns: + Dict with consensus estimates + """ + ... + + def us_etf_dividend_info(self, symbol: str) -> "USETFDividendInfo": + """Get US ETF dividend history. US token required. + + Args: + counter_id: ETF counter ID, e.g. ``"ST/US/SPY"`` + + Returns: + :class:`USETFDividendInfo` + """ + ... + + def us_company_dividends(self, symbol: str) -> "USCompanyDividends": + """Get US company historical dividends. US token required. + + Args: + counter_id: Counter ID, e.g. ``"ST/US/AAPL"`` + + Returns: + :class:`USCompanyDividends` + """ + ... + + def us_etf_files(self, symbol: str, size: Optional[int] = None) -> "USETFFilesResponse": + """Get US ETF document list. US token required. + + Args: + counter_id: ETF counter ID, e.g. ``"ST/US/SPY"`` + size: Number of files to return; ``None`` returns all + + Returns: + :class:`USETFFilesResponse` + """ + ... + # ── FundamentalContext new response types ───────────────────────── @@ -12106,3 +12372,256 @@ class OptionVolumeDaily: stats: list[OptionVolumeDailyStat] """Daily option volume statistics""" + + +# ── US-market API types ──────────────────────────────────────────── + +class USCryptoOverview: + """US cryptocurrency market overview. Returned by QuoteContext.us_crypto_overview.""" + + symbol: str + """User-facing trading-pair symbol, e.g. ``"BTCUSD.BKKT"``""" + name: str + """Cryptocurrency full name""" + ticker: str + """Ticker / pair code, e.g. ``"BTCUSD"``""" + base_asset: str + """Base asset code, e.g. ``"BTC"``""" + currency: str + """Quote currency""" + all_time_high: str + """All-time high price""" + all_time_high_date: str + """All-time high date""" + all_time_low: str + """All-time low price""" + all_time_low_date: str + """All-time low date""" + ipo_date: str + """IPO / genesis date""" + issue_price: str + """Issue price""" + shares: str + """Total supply""" + official_web_address: str + """Official website URL""" + logo: str + """Logo image URL""" + wiki_url: str + """In-app wiki URL""" + profile: str + """Extended profile as JSON string""" + + +class USCashEntry: + """One cash currency entry in USAssetOverview.""" + + currency: str + """Currency code, e.g. ``"USD"``""" + frozen_buy_cash: str + """Cash frozen for pending buy orders""" + outstanding: str + """Unsettled cash (positive = receivable, negative = payable)""" + settled_cash: str + """Settled cash balance""" + total_amount: str + """Total cash amount""" + total_cash: str + """Total available cash""" + + +class USCryptoEntry: + """One cryptocurrency holding in USAssetOverview.""" + + asset_type: str + """Asset type, e.g. ``"CRYPTO"``""" + average_cost: str + """Average cost price""" + symbol: str + """Internal counter_id, e.g. ``"VA/BKKT/BTCUSD"``""" + currency: str + """Settlement currency""" + industry_counter_id: str + """Industry counter_id""" + industry_name: str + """Industry name""" + + +class USAssetOverview: + """US account asset snapshot. Returned by TradeContext.us_asset_overview.""" + + account_type: str + """Account type code""" + asset_timestamp: int + """Snapshot timestamp (Unix seconds string)""" + cash_buy_power: str + """Available cash buying power""" + cash_list: List[USCashEntry] + """Cash balances by currency""" + crypto_list: List[USCryptoEntry] + """Cryptocurrency positions""" + + +class USRealizedPLMetric: + """One time-period metric within a USRealizedPLEntry.""" + + amount: str + """Realized P&L amount""" + period: int + """Period code (server-defined)""" + rate: str + """Realized P&L rate""" + + +class USRealizedPLEntry: + """One asset-category entry in USRealizedPL. category: 0=all, 1=stock, 2=option, 3=crypto.""" + + category: int + """Asset category code""" + currency: str + """Currency""" + metrics: List[USRealizedPLMetric] + """Time-period metrics""" + + +class USRealizedPL: + """Realized P&L response for a US account. Returned by TradeContext.us_realized_pl.""" + + realized_pl_list: List[USRealizedPLEntry] + """Per-category realized P&L entries""" + + +class USRankTag: + """A rank tag entry for a US company overview.""" + + name: str + """Tag name""" + chg: str + """Change value""" + rank_type: int + """Rank type""" + + +class USCompanyOverview: + """US company overview. Returned by FundamentalContext.us_company_overview.""" + + intro: str + """Company introduction""" + market_cap: str + """Market capitalisation""" + ccy_symbol: str + """Currency symbol""" + top_rank_tags: List[USRankTag] + """Top rank tags""" + detail_url: str + """Detail URL""" + + +class USValuationIndicator: + """A single US valuation indicator entry.""" + + circle: str + """Gauge circle value""" + part: str + """Gauge part label""" + metric: str + """Metric value""" + metric_type: str + """Metric type label""" + desc: str + """Description""" + ccy_symbol: str + """Currency symbol""" + + +class USValuationOverview: + """US valuation overview. Returned by FundamentalContext.us_valuation_overview.""" + + indicator: str + """Overall valuation indicator""" + current_indicator: USValuationIndicator + """Current indicator detail""" + range: int + """Valuation range score""" + date: str + """Date of snapshot""" + ai_summary: str + """AI-generated valuation summary""" + + +class USFinancialStatement: + """US financial statement (IS/BS/CF). Returned by FundamentalContext.us_financial_statement_v3.""" + + revenue: str + """Revenue""" + net_income: str + """Net income""" + net_margin: str + """Net margin""" + periods: list + """Per-period data as a list of dicts""" + currency: str + """Currency""" + + +class USETFDividendInfo: + """US ETF dividend history. Returned by FundamentalContext.us_etf_dividend_info.""" + + dividend_ttm: str + """TTM dividend per share""" + dividend_yield_ttm: str + """TTM dividend yield""" + dividend_frequency: str + """Distribution frequency""" + currency: str + """Currency""" + fiscal_year_info: list + """Fiscal year dividend data as a list of dicts""" + + +class USDividendItem: + """A single US dividend payment record.""" + + dividend: str + """Dividend per share""" + dividend_type: str + """Dividend type""" + ex_date: str + """Ex-dividend date""" + payment_date: str + """Payment date""" + record_date: str + """Record date""" + + +class USCompanyDividends: + """US company historical dividends. Returned by FundamentalContext.us_company_dividends.""" + + dividend_ttm: str + """TTM dividend per share""" + dividend_yield_ttm: str + """TTM dividend yield""" + payouts: str + """Number of payouts""" + currency: str + """Currency""" + items: List[USDividendItem] + """Historical dividend records""" + + +class USETFFile: + """A single ETF document entry.""" + + name: str + """Document name""" + file_type: str + """File type (e.g. ``"pdf"``)""" + url: str + """Download URL""" + + +class USETFFilesResponse: + """US ETF document list. Returned by FundamentalContext.us_etf_files.""" + + files: List[USETFFile] + """Document entries""" diff --git a/python/src/fundamental/context.rs b/python/src/fundamental/context.rs index 1804b19193..f165f53078 100644 --- a/python/src/fundamental/context.rs +++ b/python/src/fundamental/context.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use longbridge::blocking::FundamentalContextSync; use pyo3::prelude::*; +#[allow(unused_imports)] +use pythonize; use crate::{config::Config, error::ErrorNewType, fundamental::types::*}; @@ -239,4 +241,115 @@ impl FundamentalContext { .map_err(ErrorNewType)? .into()) } + + // ── US-market methods ───────────────────────────────────────────────────── + + /// Get US company overview. US token required. + fn us_company_overview(&self, symbol: String) -> PyResult { + Ok(self + .ctx + .us_company_overview(symbol) + .map_err(ErrorNewType)? + .into()) + } + + /// Get US valuation overview. US token required. + fn us_valuation_overview(&self, symbol: String) -> PyResult { + Ok(self + .ctx + .us_valuation_overview(symbol) + .map_err(ErrorNewType)? + .into()) + } + + /// Get US financial overview. `report`: "annual" or "quarterly". US token + /// required. + fn us_financial_overview( + &self, + py: Python<'_>, + symbol: String, + report: String, + ) -> PyResult> { + let v = self + .ctx + .us_financial_overview(symbol, report) + .map_err(ErrorNewType)?; + pythonize::pythonize(py, &v) + .map(|b| b.unbind()) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + /// Get US financial statement v3. `kind`: "IS"/"BS"/"CF". US token + /// required. + fn us_financial_statement_v3( + &self, + symbol: String, + kind: String, + report: String, + ) -> PyResult { + Ok(self + .ctx + .us_financial_statement_v3(symbol, kind, report) + .map_err(ErrorNewType)? + .into()) + } + + /// Get US key financial metrics. US token required. + fn us_key_financial_metrics( + &self, + py: Python<'_>, + symbol: String, + report: String, + ) -> PyResult> { + let v = self + .ctx + .us_key_financial_metrics(symbol, report) + .map_err(ErrorNewType)?; + pythonize::pythonize(py, &v) + .map(|b| b.unbind()) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + /// Get US analyst consensus estimates. US token required. + fn us_analyst_consensus( + &self, + py: Python<'_>, + symbol: String, + report: String, + ) -> PyResult> { + let v = self + .ctx + .us_analyst_consensus(symbol, report) + .map_err(ErrorNewType)?; + pythonize::pythonize(py, &v) + .map(|b| b.unbind()) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + /// Get US ETF dividend history. US token required. + fn us_etf_dividend_info(&self, symbol: String) -> PyResult { + Ok(self + .ctx + .us_etf_dividend_info(symbol) + .map_err(ErrorNewType)? + .into()) + } + + /// Get US company historical dividends. US token required. + fn us_company_dividends(&self, symbol: String) -> PyResult { + Ok(self + .ctx + .us_company_dividends(symbol) + .map_err(ErrorNewType)? + .into()) + } + + /// Get US ETF document list. `size`: None = all. US token required. + fn us_etf_files(&self, symbol: String, size: Option) -> PyResult { + Ok(self + .ctx + .us_etf_files(symbol, size) + .map_err(ErrorNewType)? + .into()) + } } diff --git a/python/src/fundamental/context_async.rs b/python/src/fundamental/context_async.rs index d0ef8151a6..ff54ca653f 100644 --- a/python/src/fundamental/context_async.rs +++ b/python/src/fundamental/context_async.rs @@ -2,6 +2,9 @@ use std::sync::Arc; use longbridge::FundamentalContext; use pyo3::{prelude::*, types::PyType}; +// needed for us_financial_overview / us_key_financial_metrics / us_analyst_consensus +#[allow(unused_imports)] +use pythonize; use crate::{config::Config, error::ErrorNewType, fundamental::types::*}; @@ -358,4 +361,159 @@ impl AsyncFundamentalContext { }) .map(|b| b.unbind()) } + + // ── US-market async methods ─────────────────────────────────────────────── + + /// Get US company overview. US token required. Returns awaitable. + fn us_company_overview(&self, py: Python<'_>, symbol: String) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(USCompanyOverview::from( + ctx.us_company_overview(symbol) + .await + .map_err(ErrorNewType)?, + )) + }) + .map(|b| b.unbind()) + } + + /// Get US valuation overview. US token required. Returns awaitable. + fn us_valuation_overview(&self, py: Python<'_>, symbol: String) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(USValuationOverview::from( + ctx.us_valuation_overview(symbol) + .await + .map_err(ErrorNewType)?, + )) + }) + .map(|b| b.unbind()) + } + + /// Get US financial overview (raw dict). Returns awaitable. + fn us_financial_overview( + &self, + py: Python<'_>, + symbol: String, + report: String, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let v = ctx + .us_financial_overview(symbol, report) + .await + .map_err(ErrorNewType)?; + Python::attach(|py| { + pythonize::pythonize(py, &v) + .map(|b| b.unbind()) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + }) + }) + .map(|b| b.unbind()) + } + + /// Get US financial statement v3. Returns awaitable. + fn us_financial_statement_v3( + &self, + py: Python<'_>, + symbol: String, + kind: String, + report: String, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(USFinancialStatement::from( + ctx.us_financial_statement_v3(symbol, kind, report) + .await + .map_err(ErrorNewType)?, + )) + }) + .map(|b| b.unbind()) + } + + /// Get US key financial metrics (raw dict). Returns awaitable. + fn us_key_financial_metrics( + &self, + py: Python<'_>, + symbol: String, + report: String, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let v = ctx + .us_key_financial_metrics(symbol, report) + .await + .map_err(ErrorNewType)?; + Python::attach(|py| { + pythonize::pythonize(py, &v) + .map(|b| b.unbind()) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + }) + }) + .map(|b| b.unbind()) + } + + /// Get US analyst consensus (raw dict). Returns awaitable. + fn us_analyst_consensus( + &self, + py: Python<'_>, + symbol: String, + report: String, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let v = ctx + .us_analyst_consensus(symbol, report) + .await + .map_err(ErrorNewType)?; + Python::attach(|py| { + pythonize::pythonize(py, &v) + .map(|b| b.unbind()) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + }) + }) + .map(|b| b.unbind()) + } + + /// Get US ETF dividend info. Returns awaitable. + fn us_etf_dividend_info(&self, py: Python<'_>, symbol: String) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(USETFDividendInfo::from( + ctx.us_etf_dividend_info(symbol) + .await + .map_err(ErrorNewType)?, + )) + }) + .map(|b| b.unbind()) + } + + /// Get US company dividends. Returns awaitable. + fn us_company_dividends(&self, py: Python<'_>, symbol: String) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(USCompanyDividends::from( + ctx.us_company_dividends(symbol) + .await + .map_err(ErrorNewType)?, + )) + }) + .map(|b| b.unbind()) + } + + /// Get US ETF files. Returns awaitable. + fn us_etf_files( + &self, + py: Python<'_>, + symbol: String, + size: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Ok(USETFFilesResponse::from( + ctx.us_etf_files(symbol, size).await.map_err(ErrorNewType)?, + )) + }) + .map(|b| b.unbind()) + } } diff --git a/python/src/fundamental/mod.rs b/python/src/fundamental/mod.rs index 8101017515..eb4fa08aa9 100644 --- a/python/src/fundamental/mod.rs +++ b/python/src/fundamental/mod.rs @@ -80,6 +80,17 @@ pub(crate) fn register_types(parent: &Bound) -> PyResult<()> { parent.add_class::()?; parent.add_class::()?; parent.add_class::()?; + // US-market types + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; parent.add_class::()?; parent.add_class::()?; Ok(()) diff --git a/python/src/fundamental/types.rs b/python/src/fundamental/types.rs index ea842a54b0..baf113627e 100644 --- a/python/src/fundamental/types.rs +++ b/python/src/fundamental/types.rs @@ -2111,3 +2111,224 @@ impl From for MacroeconomicResponse { } } } + +// ── US-market types +// ─────────────────────────────────────────────────────────── + +use longbridge::fundamental::types as lb_us; + +/// Industry rank tag +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USRankTag { + pub name: String, + pub chg: String, + pub rank_type: i32, +} + +impl From for USRankTag { + fn from(v: lb_us::USRankTag) -> Self { + Self { + name: v.name, + chg: v.chg, + rank_type: v.rank_type, + } + } +} + +/// US company overview +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USCompanyOverview { + pub intro: String, + pub market_cap: String, + pub ccy_symbol: String, + pub top_rank_tags: Vec, + pub detail_url: String, +} + +impl From for USCompanyOverview { + fn from(v: lb_us::USCompanyOverview) -> Self { + Self { + intro: v.intro, + market_cap: v.market_cap, + ccy_symbol: v.ccy_symbol, + top_rank_tags: v.top_rank_tags.into_iter().map(Into::into).collect(), + detail_url: v.detail_url, + } + } +} + +/// US valuation indicator +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USValuationIndicator { + pub circle: String, + pub part: String, + pub metric: String, + pub metric_type: String, + pub desc: String, + pub ccy_symbol: String, +} + +impl From for USValuationIndicator { + fn from(v: lb_us::USValuationIndicator) -> Self { + Self { + circle: v.circle, + part: v.part, + metric: v.metric, + metric_type: v.metric_type, + desc: v.desc, + ccy_symbol: v.ccy_symbol, + } + } +} + +/// US valuation overview +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USValuationOverview { + pub indicator: String, + pub current_indicator: USValuationIndicator, + pub range: i32, + pub date: String, + pub ai_summary: String, +} + +impl From for USValuationOverview { + fn from(v: lb_us::USValuationOverview) -> Self { + Self { + indicator: v.indicator, + current_indicator: v.current_indicator.into(), + range: v.range, + date: v.date, + ai_summary: v.ai_summary, + } + } +} + +/// US financial statement (IS/BS/CF) +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USFinancialStatement { + pub revenue: String, + pub net_income: String, + pub net_margin: String, + pub periods: Vec, + pub currency: String, +} + +impl From for USFinancialStatement { + fn from(v: lb_us::USFinancialStatement) -> Self { + Self { + revenue: v.revenue, + net_income: v.net_income, + net_margin: v.net_margin, + periods: v.periods.into_iter().map(JsonValue).collect(), + currency: v.currency, + } + } +} + +/// US ETF dividend info +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USETFDividendInfo { + pub dividend_ttm: String, + pub dividend_yield_ttm: String, + pub dividend_frequency: String, + pub currency: String, + pub fiscal_year_info: Vec, +} + +impl From for USETFDividendInfo { + fn from(v: lb_us::USETFDividendInfo) -> Self { + Self { + dividend_ttm: v.dividend_ttm, + dividend_yield_ttm: v.dividend_yield_ttm, + dividend_frequency: v.dividend_frequency, + currency: v.currency, + fiscal_year_info: v.fiscal_year_info.into_iter().map(JsonValue).collect(), + } + } +} + +/// A single US dividend payment record +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USDividendItem { + pub dividend: String, + pub dividend_type: String, + pub ex_date: String, + pub payment_date: String, + pub record_date: String, +} + +impl From for USDividendItem { + fn from(v: lb_us::USDividendItem) -> Self { + Self { + dividend: v.dividend, + dividend_type: v.dividend_type, + ex_date: v.ex_date, + payment_date: v.payment_date, + record_date: v.record_date, + } + } +} + +/// US company historical dividends +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USCompanyDividends { + pub dividend_ttm: String, + pub dividend_yield_ttm: String, + pub payouts: String, + pub currency: String, + pub items: Vec, +} + +impl From for USCompanyDividends { + fn from(v: lb_us::USCompanyDividends) -> Self { + Self { + dividend_ttm: v.dividend_ttm, + dividend_yield_ttm: v.dividend_yield_ttm, + payouts: v.payouts, + currency: v.currency, + items: v.items.into_iter().map(Into::into).collect(), + } + } +} + +/// A single ETF document +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USETFFile { + pub name: String, + pub file_type: String, + pub url: String, +} + +impl From for USETFFile { + fn from(v: lb_us::USETFFile) -> Self { + Self { + name: v.name, + file_type: v.file_type, + url: v.url, + } + } +} + +/// US ETF files response +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USETFFilesResponse { + pub files: Vec, +} + +impl From for USETFFilesResponse { + fn from(v: lb_us::USETFFilesResponse) -> Self { + Self { + files: v.files.into_iter().map(Into::into).collect(), + } + } +} diff --git a/python/src/quote/context.rs b/python/src/quote/context.rs index a2d6f9e1e1..a87ee29be4 100644 --- a/python/src/quote/context.rs +++ b/python/src/quote/context.rs @@ -687,4 +687,16 @@ impl QuoteContext { .map_err(ErrorNewType)? .into()) } + + /// Get US cryptocurrency market overview. US token required. + fn us_crypto_overview( + &self, + symbol: String, + ) -> PyResult { + Ok(self + .ctx + .us_crypto_overview(symbol) + .map_err(ErrorNewType)? + .into()) + } } diff --git a/python/src/quote/context_async.rs b/python/src/quote/context_async.rs index cd5ca44c92..7d6e3cbe9e 100644 --- a/python/src/quote/context_async.rs +++ b/python/src/quote/context_async.rs @@ -895,4 +895,19 @@ impl AsyncQuoteContext { }) .map(|b| b.unbind()) } + + /// Get US cryptocurrency market overview. US token required. Returns + /// awaitable. + fn us_crypto_overview(&self, py: Python<'_>, symbol: String) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r: crate::quote::types::USCryptoOverview = ctx + .us_crypto_overview(symbol) + .await + .map_err(ErrorNewType)? + .into(); + Ok(r) + }) + .map(|b| b.unbind()) + } } diff --git a/python/src/quote/mod.rs b/python/src/quote/mod.rs index 749f0deb07..0c89ee2c29 100644 --- a/python/src/quote/mod.rs +++ b/python/src/quote/mod.rs @@ -71,6 +71,7 @@ pub(crate) fn register_types(parent: &Bound) -> PyResult<()> { parent.add_class::()?; parent.add_class::()?; parent.add_class::()?; + parent.add_class::()?; parent.add_class::()?; parent.add_class::()?; diff --git a/python/src/quote/types.rs b/python/src/quote/types.rs index d9849dac0d..a77b3563ff 100644 --- a/python/src/quote/types.rs +++ b/python/src/quote/types.rs @@ -1621,3 +1621,50 @@ impl From for OptionVolumeDailyStat { } } } + +/// US cryptocurrency market overview +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct USCryptoOverview { + pub name: String, + pub ticker: String, + pub currency: String, + pub all_time_high: String, + pub all_time_high_date: String, + pub all_time_low: String, + pub all_time_low_date: String, + pub ipo_date: String, + pub issue_price: String, + pub shares: String, + pub official_web_address: String, + /// User-facing symbol (e.g. "BTCUSD.BKKT"), converted from counter_id + pub symbol: String, + pub base_asset: String, + pub logo: String, + pub wiki_url: String, + /// Profile as JSON string + pub profile: String, +} + +impl From for USCryptoOverview { + fn from(v: longbridge::quote::USCryptoOverview) -> Self { + Self { + name: v.name, + ticker: v.ticker, + currency: v.currency, + all_time_high: v.all_time_high, + all_time_high_date: v.all_time_high_date, + all_time_low: v.all_time_low, + all_time_low_date: v.all_time_low_date, + ipo_date: v.ipo_date, + issue_price: v.issue_price, + shares: v.shares, + official_web_address: v.official_web_address, + symbol: v.symbol, + base_asset: v.base_asset, + logo: v.logo, + wiki_url: v.wiki_url, + profile: v.profile, + } + } +} diff --git a/python/src/trade/context.rs b/python/src/trade/context.rs index 2e7ff3a090..6395ac577f 100644 --- a/python/src/trade/context.rs +++ b/python/src/trade/context.rs @@ -5,7 +5,8 @@ use longbridge::{ trade::{ EstimateMaxPurchaseQuantityOptions, GetCashFlowOptions, GetFundPositionsOptions, GetHistoryExecutionsOptions, GetHistoryOrdersOptions, GetStockPositionsOptions, - GetTodayExecutionsOptions, GetTodayOrdersOptions, ReplaceOrderOptions, SubmitOrderOptions, + GetTodayExecutionsOptions, GetTodayOrdersOptions, QueryUSOrdersOptions, + ReplaceOrderOptions, SubmitOrderOptions, }, }; use parking_lot::Mutex; @@ -409,6 +410,63 @@ impl TradeContext { .try_into() } + // ── US-market methods ───────────────────────────────────────────────── + + /// Query US order list (returns JSON string). US token required. + /// symbol: user-facing symbol e.g. "AAPL.US"; action: 0=all/1=buy/2=sell. + #[pyo3(signature = (symbol = None, action = 0, start_at = 0, end_at = 0, query_type = 0, page = 1, limit = 20))] + fn us_query_orders( + &self, + symbol: Option, + action: i32, + start_at: i64, + end_at: i64, + query_type: i32, + page: i32, + limit: i32, + ) -> PyResult { + let opts = longbridge::trade::GetUSHistoryOrders { + symbol, + side: match action { + 1 => longbridge::trade::OrderSide::Buy, + 2 => longbridge::trade::OrderSide::Sell, + _ => longbridge::trade::OrderSide::Unknown, + }, + start_at, + end_at, + query_type, + page, + limit, + }; + let resp = self.ctx.us_query_orders(opts).map_err(ErrorNewType)?; + serde_json::to_string(&resp) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + /// Get US order detail (returns JSON string). US token required. + fn us_order_detail(&self, order_id: String) -> PyResult { + let resp = self.ctx.us_order_detail(order_id).map_err(ErrorNewType)?; + serde_json::to_string(&resp) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + /// Get US account asset overview. US token required. + fn us_asset_overview(&self) -> PyResult { + Ok(self.ctx.us_asset_overview().map_err(ErrorNewType)?.into()) + } + + /// Get US realized P&L. US token required. + fn us_realized_pl( + &self, + currency: String, + category: Option, + ) -> PyResult { + Ok(self + .ctx + .us_realized_pl(longbridge::trade::GetUSRealizedPLOptions { currency, category: category.unwrap_or_default() }) + .map_err(ErrorNewType)? + .into()) + } + /// Estimating the maximum purchase quantity for Hong Kong and US stocks, /// warrants, and options #[allow(clippy::too_many_arguments)] diff --git a/python/src/trade/context_async.rs b/python/src/trade/context_async.rs index a0c02d3fe9..7ed850708a 100644 --- a/python/src/trade/context_async.rs +++ b/python/src/trade/context_async.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use longbridge::trade::{ EstimateMaxPurchaseQuantityOptions, GetCashFlowOptions, GetFundPositionsOptions, GetHistoryExecutionsOptions, GetHistoryOrdersOptions, GetStockPositionsOptions, - GetTodayExecutionsOptions, GetTodayOrdersOptions, ReplaceOrderOptions, SubmitOrderOptions, - TradeContext, + GetTodayExecutionsOptions, GetTodayOrdersOptions, QueryUSOrdersOptions, ReplaceOrderOptions, + SubmitOrderOptions, TradeContext, }; use parking_lot::Mutex; use pyo3::{prelude::*, types::PyType}; @@ -487,6 +487,87 @@ impl AsyncTradeContext { .map(|b| b.unbind()) } + // ── US-market async methods ─────────────────────────────────────────── + + /// Query US order list (JSON string). US token required. Returns awaitable. + /// symbol: user-facing symbol e.g. "AAPL.US"; action: 0=all/1=buy/2=sell. + #[pyo3(signature = (symbol = None, action = 0, start_at = 0, end_at = 0, query_type = 0, page = 1, limit = 20))] + fn us_query_orders( + &self, + py: Python<'_>, + symbol: Option, + action: i32, + start_at: i64, + end_at: i64, + query_type: i32, + page: i32, + limit: i32, + ) -> PyResult> { + let ctx = self.ctx.clone(); + let opts = longbridge::trade::GetUSHistoryOrders { + symbol, + side: match action { + 1 => longbridge::trade::OrderSide::Buy, + 2 => longbridge::trade::OrderSide::Sell, + _ => longbridge::trade::OrderSide::Unknown, + }, + start_at, + end_at, + query_type, + page, + limit, + }; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp = ctx.us_query_orders(opts).await.map_err(ErrorNewType)?; + let s = serde_json::to_string(&resp) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(s) + }) + .map(|b| b.unbind()) + } + + /// Get US order detail (JSON string). US token required. Returns awaitable. + fn us_order_detail(&self, py: Python<'_>, order_id: String) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp = ctx.us_order_detail(order_id).await.map_err(ErrorNewType)?; + let s = serde_json::to_string(&resp) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(s) + }) + .map(|b| b.unbind()) + } + + /// Get US account asset overview. US token required. Returns awaitable. + fn us_asset_overview(&self, py: Python<'_>) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r: crate::trade::types::USAssetOverview = + ctx.us_asset_overview().await.map_err(ErrorNewType)?.into(); + Ok(r) + }) + .map(|b| b.unbind()) + } + + /// Get US realized P&L. US token required. Returns awaitable. + fn us_realized_pl( + &self, + py: Python<'_>, + currency: String, + category: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let r: crate::trade::types::USRealizedPL = ctx + .us_realized_pl(longbridge::trade::GetUSRealizedPLOptions { currency, category: category.unwrap_or_default() }) + .await + .map_err(ErrorNewType)? + .into(); + Ok(r) + }) + .map(|b| b.unbind()) + } + /// Estimate max purchase quantity. Returns awaitable. #[allow(clippy::too_many_arguments)] #[pyo3(signature = (symbol, order_type, side, price = None, currency = None, order_id = None, fractional_shares = false))] diff --git a/python/src/trade/mod.rs b/python/src/trade/mod.rs index 01f249eef2..eaaa7b8925 100644 --- a/python/src/trade/mod.rs +++ b/python/src/trade/mod.rs @@ -41,6 +41,13 @@ pub(crate) fn register_types(parent: &Bound) -> PyResult<()> { parent.add_class::()?; parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; parent.add_class::()?; Ok(()) diff --git a/python/src/trade/types.rs b/python/src/trade/types.rs index 49a4bd9179..249d5f7e13 100644 --- a/python/src/trade/types.rs +++ b/python/src/trade/types.rs @@ -791,3 +791,132 @@ pub(crate) struct EstimateMaxPurchaseQuantityResponse { /// Margin available quantity pub margin_max_qty: PyDecimal, } + +// ── US-market types ────────────────────────────────────────────────────────── + +/// One cash currency entry in USAssetOverview +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct USCashEntry { + pub currency: String, + pub frozen_buy_cash: String, + pub outstanding: String, + pub settled_cash: String, + pub total_amount: String, + pub total_cash: String, +} + +impl From for USCashEntry { + fn from(v: longbridge::trade::USCashEntry) -> Self { + Self { + currency: v.currency, + frozen_buy_cash: v.frozen_buy_cash, + outstanding: v.outstanding, + settled_cash: v.settled_cash, + total_amount: v.total_amount, + total_cash: v.total_cash, + } + } +} + +/// One cryptocurrency holding in USAssetOverview +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct USCryptoEntry { + pub asset_type: String, + pub average_cost: String, + pub symbol: String, + pub currency: String, + pub industry_name: String, +} + +impl From for USCryptoEntry { + fn from(v: longbridge::trade::USCryptoEntry) -> Self { + Self { + asset_type: v.asset_type, + average_cost: v.average_cost, + symbol: v.symbol, + currency: v.currency, + industry_name: v.industry_name, + } + } +} + +/// US account asset snapshot +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct USAssetOverview { + pub account_type: String, + pub asset_timestamp: i64, + pub cash_buy_power: String, + pub cash_list: Vec, + pub crypto_list: Vec, +} + +impl From for USAssetOverview { + fn from(v: longbridge::trade::USAssetOverview) -> Self { + Self { + account_type: v.account_type, + asset_timestamp: v + .asset_timestamp + .map(|t| t.unix_timestamp()) + .unwrap_or_default(), + cash_buy_power: v.cash_buy_power, + cash_list: v.cash_list.into_iter().map(Into::into).collect(), + crypto_list: v.crypto_list.into_iter().map(Into::into).collect(), + } + } +} + +/// One time-period metric in USRealizedPLEntry +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct USRealizedPLMetric { + pub amount: String, + pub period: i32, + pub rate: String, +} + +impl From for USRealizedPLMetric { + fn from(v: longbridge::trade::USRealizedPLMetric) -> Self { + Self { + amount: v.amount, + period: v.period, + rate: v.rate, + } + } +} + +/// One asset-category entry in USRealizedPL +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct USRealizedPLEntry { + pub category: i32, + pub currency: String, + pub metrics: Vec, +} + +impl From for USRealizedPLEntry { + fn from(v: longbridge::trade::USRealizedPLEntry) -> Self { + Self { + category: v.category, + currency: v.currency, + metrics: v.metrics.into_iter().map(Into::into).collect(), + } + } +} + +/// Realized P&L response for a US account +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct USRealizedPL { + pub realized_pl_list: Vec, +} + +impl From for USRealizedPL { + fn from(v: longbridge::trade::USRealizedPL) -> Self { + Self { + realized_pl_list: v.realized_pl_list.into_iter().map(Into::into).collect(), + } + } +} diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index 930f45ec63..dad4436056 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -62,3 +62,160 @@ pub async fn is_cn() -> bool { IS_CN_DONE.get().copied().unwrap_or(false) } } + +/// HTTP and WebSocket header that selects the data center serving a request. +/// +/// An absent header is treated as [`DcRegion::Ap`] by the API gateway. +pub const DC_REGION_HEADER: &str = "x-dc-region"; + +/// Data center region used for API gateway routing. +/// +/// Independent of [`is_cn`]: that picks the `*.longbridge.cn` vs +/// `*.longbridge.com` host (mainland acceleration), while this selects which +/// data center (`us`/`ap`) the gateway sources data from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DcRegion { + /// Asia-Pacific data center (`ap`). The gateway default. + Ap, + /// US data center (`us`). + Us, +} + +impl DcRegion { + /// Derive the region from a single credential's prefix. + /// + /// Longbridge credentials — the OAuth access token, and the legacy API-key + /// `app_key` / `app_secret` / `access_token` — are prefixed with their data + /// center: `us_…` for the US data center, `ap_…` for Asia-Pacific. A `us_` + /// prefix maps to [`DcRegion::Us`]; everything else — including + /// `ap_`-prefixed and unprefixed credentials — maps to + /// [`DcRegion::Ap`], matching the gateway default. A leading `Bearer ` + /// is tolerated so an `Authorization` value can be passed directly. + pub fn from_credential(credential: &str) -> Self { + let credential = credential.strip_prefix("Bearer ").unwrap_or(credential); + if credential.starts_with("us_") { + DcRegion::Us + } else { + DcRegion::Ap + } + } + + /// Derive the region from a set of credentials, returning [`DcRegion::Us`] + /// if any of them carries the `us_` prefix. + /// + /// Used for legacy API-key auth, where the `app_key`, `app_secret`, and + /// `access_token` all carry the region prefix. + pub fn from_credentials(credentials: &[&str]) -> Self { + if credentials + .iter() + .any(|c| DcRegion::from_credential(c) == DcRegion::Us) + { + DcRegion::Us + } else { + DcRegion::Ap + } + } + + /// The [`DC_REGION_HEADER`] value for this region (`"us"` or `"ap"`). + pub fn as_str(self) -> &'static str { + match self { + DcRegion::Us => "us", + DcRegion::Ap => "ap", + } + } + + /// Whether this session may reach an API limited to `required`. + /// + /// `true` when the session's region matches the API's required region; + /// callers short-circuit with a unified error when it is `false`. + pub fn allows(self, required: DcRegion) -> bool { + self == required + } + + /// Strip any leading `Bearer ` from a credential. + /// + /// Region prefixes (`hk_m_`, `us_m_`, `ap_m_`, …) are routing metadata + /// consumed by [`from_credential`] to derive the [`DC_REGION_HEADER`]. + /// The gateway accepts the full prefixed token and routes via the header, + /// so **no region prefix is stripped** — only `Bearer ` is removed. + pub fn strip_region_prefix(credential: &str) -> &str { + credential.strip_prefix("Bearer ").unwrap_or(credential) + } +} + +impl std::fmt::Display for DcRegion { + /// Human-facing uppercase name (`AP`/`US`), for error messages and display. + /// The lowercase [`DC_REGION_HEADER`] value is [`as_str`](Self::as_str). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + DcRegion::Us => "US", + DcRegion::Ap => "AP", + }) + } +} + +#[cfg(test)] +mod dc_region_tests { + use super::*; + + #[test] + fn from_credential_detects_region() { + assert_eq!(DcRegion::from_credential("us_abc"), DcRegion::Us); + assert_eq!(DcRegion::from_credential("ap_abc"), DcRegion::Ap); + // Unprefixed and unknown prefixes fall back to the AP default. + assert_eq!(DcRegion::from_credential("abc"), DcRegion::Ap); + assert_eq!(DcRegion::from_credential(""), DcRegion::Ap); + // A `Bearer ` prefix is tolerated. + assert_eq!(DcRegion::from_credential("Bearer us_x"), DcRegion::Us); + assert_eq!(DcRegion::from_credential("Bearer ap_x"), DcRegion::Ap); + } + + #[test] + fn from_credentials_is_us_if_any_is_us() { + assert_eq!( + DcRegion::from_credentials(&["ap_key", "us_secret", "ap_token"]), + DcRegion::Us + ); + assert_eq!( + DcRegion::from_credentials(&["ap_key", "ap_secret", "ap_token"]), + DcRegion::Ap + ); + assert_eq!(DcRegion::from_credentials(&[]), DcRegion::Ap); + } + + #[test] + fn as_str_matches_header_value() { + assert_eq!(DcRegion::Us.as_str(), "us"); + assert_eq!(DcRegion::Ap.as_str(), "ap"); + } + + #[test] + fn allows_matches_same_region() { + assert!(DcRegion::Ap.allows(DcRegion::Ap)); + assert!(DcRegion::Us.allows(DcRegion::Us)); + assert!(!DcRegion::Us.allows(DcRegion::Ap)); + assert!(!DcRegion::Ap.allows(DcRegion::Us)); + } + + #[test] + fn display_is_uppercase() { + // Human-facing display is uppercase; the header value stays lowercase. + assert_eq!(DcRegion::Us.to_string(), "US"); + assert_eq!(DcRegion::Ap.to_string(), "AP"); + assert_eq!(DcRegion::Us.as_str(), "us"); + assert_eq!(DcRegion::Ap.as_str(), "ap"); + } + + #[test] + fn strip_region_prefix_only_removes_bearer() { + // Region prefixes are kept as-is; only "Bearer " is stripped. + assert_eq!(DcRegion::strip_region_prefix("us_m_eyJabc"), "us_m_eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("hk_m_eyJabc"), "hk_m_eyJabc"); + assert_eq!( + DcRegion::strip_region_prefix("Bearer us_m_eyJabc"), + "us_m_eyJabc" + ); + assert_eq!(DcRegion::strip_region_prefix("Bearer eyJabc"), "eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("eyJabc"), "eyJabc"); + } +} diff --git a/rust/crates/httpclient/src/client.rs b/rust/crates/httpclient/src/client.rs index 30f59f6ea9..57ca30b39e 100644 --- a/rust/crates/httpclient/src/client.rs +++ b/rust/crates/httpclient/src/client.rs @@ -1,12 +1,15 @@ use std::sync::Arc; +use longbridge_geo::DcRegion; use reqwest::{ Client, Method, header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::Deserialize; -use crate::{HttpClientConfig, HttpClientError, HttpClientResult, Json, RequestBuilder}; +use crate::{ + AuthConfig, HttpClientConfig, HttpClientError, HttpClientResult, Json, RequestBuilder, +}; /// Longbridge HTTP client #[derive(Clone)] @@ -40,6 +43,24 @@ impl HttpClient { self } + /// The data-center region (`us`/`ap`) derived from this client's auth + /// credentials. Used by non-HTTP call sites (e.g. WebSocket quote commands) + /// to apply the same AP-only routing guard the HTTP path enforces. + pub async fn dc_region(&self) -> DcRegion { + match &self.config.auth { + AuthConfig::ApiKey { + app_key, + app_secret, + access_token, + } => DcRegion::from_credentials(&[app_key, access_token, app_secret]), + AuthConfig::OAuth(oauth) => oauth + .access_token() + .await + .map(|token| DcRegion::from_credential(&token)) + .unwrap_or(DcRegion::Ap), + } + } + /// Create a new request builder #[inline] pub fn request( diff --git a/rust/crates/httpclient/src/error.rs b/rust/crates/httpclient/src/error.rs index 9eb87c72ad..ddf57047a6 100644 --- a/rust/crates/httpclient/src/error.rs +++ b/rust/crates/httpclient/src/error.rs @@ -1,5 +1,6 @@ use std::error::Error; +use longbridge_geo::DcRegion; use reqwest::StatusCode; use crate::qs::QsError; @@ -34,6 +35,20 @@ pub enum HttpClientError { #[error("request timeout")] RequestTimeout, + /// The requested API is restricted to one data center and cannot be reached + /// from a session in a different region. + #[error( + "this API ({path}) is only available in the {required} data center and is not supported for your {current}-region account" + )] + DcRegionRestricted { + /// The restricted API path (or WebSocket command) that was requested. + path: String, + /// The data center this API is limited to. + required: DcRegion, + /// The session's current data-center region. + current: DcRegion, + }, + /// OpenAPI error #[error("openapi error: code={code}: {message}")] OpenApi { diff --git a/rust/crates/httpclient/src/lib.rs b/rust/crates/httpclient/src/lib.rs index 9a495ebe87..544c3050bf 100644 --- a/rust/crates/httpclient/src/lib.rs +++ b/rust/crates/httpclient/src/lib.rs @@ -16,7 +16,7 @@ mod timestamp; pub use client::HttpClient; pub use config::{AuthConfig, HttpClientConfig}; pub use error::{HttpClientError, HttpClientResult, HttpError}; -pub use longbridge_geo::is_cn; +pub use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn}; pub use qs::QsError; pub use request::{FromPayload, Json, RequestBuilder, ToPayload}; pub use reqwest::Method; diff --git a/rust/crates/httpclient/src/request.rs b/rust/crates/httpclient/src/request.rs index aee7182578..8477a7fc3e 100644 --- a/rust/crates/httpclient/src/request.rs +++ b/rust/crates/httpclient/src/request.rs @@ -6,7 +6,7 @@ use std::{ time::{Duration, Instant}, }; -use longbridge_geo::is_cn; +use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn}; use reqwest::{ Method, StatusCode, header::{HeaderMap, HeaderName, HeaderValue}, @@ -125,6 +125,7 @@ pub struct RequestBuilder<'a, T, Q, R> { headers: HeaderMap, body: Option, query_params: Option, + dc_restrict: Option, mark_resp: PhantomData, } @@ -137,6 +138,7 @@ impl<'a> RequestBuilder<'a, (), (), ()> { headers: Default::default(), body: None, query_params: None, + dc_restrict: None, mark_resp: PhantomData, } } @@ -156,6 +158,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { headers: self.headers, body: Some(body), query_params: self.query_params, + dc_restrict: self.dc_restrict, mark_resp: self.mark_resp, } } @@ -175,6 +178,19 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { self } + /// Restrict this request to a single data center. + /// + /// When set, [`do_send`](Self::do_send) short-circuits with + /// [`HttpClientError::DcRegionRestricted`] if the session's region differs, + /// instead of forwarding a request the target data center cannot serve. + /// Call sites for region-limited endpoints declare their region here — + /// `Ap` for AP-only APIs, `Us` for US-only ones. + #[must_use] + pub fn dc_restrict(mut self, region: DcRegion) -> Self { + self.dc_restrict = Some(region); + self + } + /// Set the query string #[must_use] pub fn query_params(self, params: Q2) -> RequestBuilder<'a, T, Q2, R> @@ -188,6 +204,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { headers: self.headers, body: self.body, query_params: Some(params), + dc_restrict: self.dc_restrict, mark_resp: self.mark_resp, } } @@ -205,6 +222,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { headers: self.headers, body: self.body, query_params: self.query_params, + dc_restrict: self.dc_restrict, mark_resp: PhantomData, } } @@ -237,8 +255,9 @@ where .and_then(|value| value.parse().ok()) .unwrap_or_else(Timestamp::now); - // Resolve app_key, access_token, and optional app_secret from auth config - let (app_key, access_token, app_secret) = match &config.auth { + // Resolve app_key, access_token, optional app_secret, and the data-center + // region from the auth config. + let (app_key, access_token, app_secret, dc_region) = match &config.auth { AuthConfig::ApiKey { app_key, app_secret, @@ -247,20 +266,38 @@ where app_key.clone(), access_token.clone(), Some(app_secret.clone()), + DcRegion::from_credentials(&[app_key, access_token, app_secret]), ), AuthConfig::OAuth(oauth) => { let token = oauth .access_token() .await .map_err(|e| HttpClientError::OAuth(e.to_string()))?; + // Derive DC region from the token prefix (us_→US, others→AP). + // The token is sent as-is (including any prefix); the gateway + // accepts the full token and routes via the x-dc-region header. + let region = DcRegion::from_credential(&token); ( oauth.client_id().to_string(), format!("Bearer {token}"), None, + region, ) } }; + // Short-circuit region-limited endpoints with a single unified error, + // instead of forwarding a request the target data center cannot serve. + if let Some(required) = self.dc_restrict + && !dc_region.allows(required) + { + return Err(HttpClientError::DcRegionRestricted { + path: self.path.clone(), + required, + current: dc_region, + }); + } + let app_key_value = HeaderValue::from_str(&app_key).map_err(|_| HttpClientError::InvalidApiKey)?; let access_token_value = HeaderValue::from_str(&access_token) @@ -277,6 +314,15 @@ where .header("X-Timestamp", timestamp.to_string()) .header("Content-Type", "application/json; charset=utf-8"); + // Route to the data center matching the credential's region (us/ap), + // unless the caller already set the header explicitly (e.g. via custom + // headers). + let region_already_set = default_headers.contains_key(DC_REGION_HEADER) + || self.headers.contains_key(DC_REGION_HEADER); + if !region_already_set { + request_builder = request_builder.header(DC_REGION_HEADER, dc_region.as_str()); + } + // set the request body if let Some(body) = &self.body { let body = body diff --git a/rust/crates/oauth/src/client.rs b/rust/crates/oauth/src/client.rs index 2b4682d834..1872ecf6a8 100644 --- a/rust/crates/oauth/src/client.rs +++ b/rust/crates/oauth/src/client.rs @@ -16,7 +16,7 @@ use crate::{ const OAUTH_BASE_URL: &str = "https://openapi.longbridge.com/oauth2"; const OAUTH_BASE_URL_CN: &str = "https://openapi.longbridge.cn/oauth2"; -const OAUTH_BASE_URL_TEST: &str = "https://openapi.longbridge.xyz/oauth2"; +const OAUTH_BASE_URL_TEST: &str = "https://openapi-global.longbridge.xyz/oauth2"; async fn oauth_base_url() -> &'static str { if std::env::var("LONGBRIDGE_ENV") diff --git a/rust/src/blocking/fundamental.rs b/rust/src/blocking/fundamental.rs index 532c09bc1d..85c319f6d9 100644 --- a/rust/src/blocking/fundamental.rs +++ b/rust/src/blocking/fundamental.rs @@ -349,4 +349,95 @@ impl FundamentalContextSync { .await }) } + + // ── US-market blocking wrappers ─────────────────────────────────────────── + + /// Get US company overview (blocking) + pub fn us_company_overview( + &self, + counter_id: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_company_overview(counter_id).await }) + } + + /// Get US valuation overview snapshot (blocking) + pub fn us_valuation_overview( + &self, + counter_id: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_valuation_overview(counter_id).await }) + } + + /// Get US financial overview (blocking) + pub fn us_financial_overview( + &self, + counter_id: impl Into + Send + 'static, + report: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_financial_overview(counter_id, report).await }) + } + + /// Get US financial statement v3 (blocking) + pub fn us_financial_statement_v3( + &self, + counter_id: impl Into + Send + 'static, + kind: impl Into + Send + 'static, + report: impl Into + Send + 'static, + ) -> Result { + self.rt.call(move |ctx| async move { + ctx.us_financial_statement_v3(counter_id, kind, report) + .await + }) + } + + /// Get US key financial metrics (blocking) + pub fn us_key_financial_metrics( + &self, + counter_id: impl Into + Send + 'static, + report: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_key_financial_metrics(counter_id, report).await }) + } + + /// Get US analyst consensus estimates (blocking) + pub fn us_analyst_consensus( + &self, + counter_id: impl Into + Send + 'static, + report: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_analyst_consensus(counter_id, report).await }) + } + + /// Get US ETF dividend history (blocking) + pub fn us_etf_dividend_info( + &self, + counter_id: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_etf_dividend_info(counter_id).await }) + } + + /// Get US company historical dividends (blocking) + pub fn us_company_dividends( + &self, + counter_id: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_company_dividends(counter_id).await }) + } + + /// Get US ETF document list (blocking) + pub fn us_etf_files( + &self, + counter_id: impl Into + Send + 'static, + size: Option, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_etf_files(counter_id, size).await }) + } } diff --git a/rust/src/blocking/quote.rs b/rust/src/blocking/quote.rs index a18cfd2174..3bab1c95ed 100644 --- a/rust/src/blocking/quote.rs +++ b/rust/src/blocking/quote.rs @@ -14,8 +14,8 @@ use crate::{ RequestCreateWatchlistGroup, RequestUpdateWatchlistGroup, Security, SecurityBrokers, SecurityCalcIndex, SecurityDepth, SecurityListCategory, SecurityQuote, SecurityStaticInfo, ShortPositionsResponse, ShortTradesResponse, SortOrderType, StrikePriceInfo, SubFlags, - Subscription, Trade, TradeSessions, WarrantInfo, WarrantQuote, WarrantSortBy, - WarrantStatus, WarrantType, WatchlistGroup, + Subscription, Trade, TradeSessions, USCryptoOverview, WarrantInfo, WarrantQuote, + WarrantSortBy, WarrantStatus, WarrantType, WatchlistGroup, }, }; @@ -1232,4 +1232,15 @@ impl QuoteContextSync { self.rt .call(move |ctx| async move { ctx.resolve_counter_ids(symbols).await }) } + + // ── US-market blocking wrappers ─────────────────────────────────────────── + + /// Get cryptocurrency market overview (blocking) + pub fn us_crypto_overview( + &self, + symbol: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_crypto_overview(symbol).await }) + } } diff --git a/rust/src/blocking/trade.rs b/rust/src/blocking/trade.rs index 3573120353..75940a820f 100644 --- a/rust/src/blocking/trade.rs +++ b/rust/src/blocking/trade.rs @@ -7,9 +7,11 @@ use crate::{ AccountBalance, CashFlow, EstimateMaxPurchaseQuantityOptions, EstimateMaxPurchaseQuantityResponse, Execution, FundPositionsResponse, GetCashFlowOptions, GetFundPositionsOptions, GetHistoryExecutionsOptions, GetHistoryOrdersOptions, - GetStockPositionsOptions, GetTodayExecutionsOptions, GetTodayOrdersOptions, MarginRatio, - Order, OrderDetail, PushEvent, ReplaceOrderOptions, StockPositionsResponse, - SubmitOrderOptions, SubmitOrderResponse, TopicType, TradeContext, + GetStockPositionsOptions, GetTodayExecutionsOptions, GetTodayOrdersOptions, + GetUSHistoryOrders, GetUSRealizedPLOptions, MarginRatio, Order, OrderDetail, PushEvent, + QueryUSOrdersOptions, QueryUSOrdersResponse, ReplaceOrderOptions, StockPositionsResponse, + SubmitOrderOptions, SubmitOrderResponse, TopicType, TradeContext, USAssetOverview, + USOrderDetailResponse, USRealizedPL, }, }; @@ -456,4 +458,33 @@ impl TradeContextSync { self.rt .call(move |ctx| async move { ctx.estimate_max_purchase_quantity(opts).await }) } + + // ── US-market blocking wrappers ─────────────────────────────────────────── + + /// Query the paginated US order list (blocking) + pub fn us_query_orders(&self, opts: GetUSHistoryOrders) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_query_orders(opts).await }) + } + + /// Get US order detail (blocking) + pub fn us_order_detail( + &self, + order_id: impl Into + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_order_detail(order_id).await }) + } + + /// Get the full US account asset overview (blocking) + pub fn us_asset_overview(&self) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_asset_overview().await }) + } + + /// Get realized P&L for the US account (blocking) + pub fn us_realized_pl(&self, opts: GetUSRealizedPLOptions) -> Result { + self.rt + .call(move |ctx| async move { ctx.us_realized_pl(opts).await }) + } } diff --git a/rust/src/config.rs b/rust/src/config.rs index c2f0c49c56..94c839a1eb 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -7,7 +7,9 @@ use std::{ }; pub(crate) use http::{HeaderName, HeaderValue, Request, header}; -use longbridge_httpcli::{HttpClient, HttpClientConfig, Json, Method, is_cn}; +use longbridge_httpcli::{ + DC_REGION_HEADER, DcRegion, HttpClient, HttpClientConfig, Json, Method, is_cn, +}; use longbridge_oauth::OAuth; use num_enum::IntoPrimitive; use serde::{Deserialize, Serialize}; @@ -498,7 +500,29 @@ impl Config { .block_on(self.refresh_access_token(expired_at)) } - fn create_ws_request(&self, url: &str) -> tokio_tungstenite::tungstenite::Result> { + /// Resolve the data-center region from the auth credentials. For OAuth the + /// access token is resolved (and refreshed if needed); for legacy API-key + /// mode the `app_key`/`app_secret`/`access_token` prefixes are inspected. + async fn auth_dc_region(&self) -> DcRegion { + match &self.auth { + AuthMode::ApiKey { + app_key, + app_secret, + access_token, + } => DcRegion::from_credentials(&[app_key, access_token, app_secret]), + AuthMode::OAuth(oauth) => oauth + .access_token() + .await + .map(|token| DcRegion::from_credential(&token)) + .unwrap_or(DcRegion::Ap), + } + } + + fn create_ws_request( + &self, + url: &str, + dc_region: DcRegion, + ) -> tokio_tungstenite::tungstenite::Result> { let mut request = url.into_client_request()?; request.headers_mut().append( header::ACCEPT_LANGUAGE, @@ -512,21 +536,34 @@ impl Config { request.headers_mut().append(name, val); } } + // Route the upgrade to the data center matching the credential's region, + // unless the caller already set the header via a custom header. + if !self + .custom_headers + .keys() + .any(|key| key.eq_ignore_ascii_case(DC_REGION_HEADER)) + { + request.headers_mut().append( + HeaderName::from_static(DC_REGION_HEADER), + HeaderValue::from_static(dc_region.as_str()), + ); + } Ok(request) } pub(crate) async fn create_quote_ws_request( &self, ) -> (&str, tokio_tungstenite::tungstenite::Result>) { + let dc_region = self.auth_dc_region().await; match self.quote_ws_url.as_deref() { - Some(url) => (url, self.create_ws_request(url)), + Some(url) => (url, self.create_ws_request(url, dc_region)), None => { let url = if is_cn().await { DEFAULT_QUOTE_WS_URL_CN } else { DEFAULT_QUOTE_WS_URL }; - (url, self.create_ws_request(url)) + (url, self.create_ws_request(url, dc_region)) } } } @@ -534,15 +571,16 @@ impl Config { pub(crate) async fn create_trade_ws_request( &self, ) -> (&str, tokio_tungstenite::tungstenite::Result>) { + let dc_region = self.auth_dc_region().await; match self.trade_ws_url.as_deref() { - Some(url) => (url, self.create_ws_request(url)), + Some(url) => (url, self.create_ws_request(url, dc_region)), None => { let url = if is_cn().await { DEFAULT_TRADE_WS_URL_CN } else { DEFAULT_TRADE_WS_URL }; - (url, self.create_ws_request(url)) + (url, self.create_ws_request(url, dc_region)) } } } @@ -562,6 +600,17 @@ impl Config { self } + /// Override the data-center region for every HTTP and WebSocket request by + /// setting the [`DC_REGION_HEADER`](crate::DC_REGION_HEADER) explicitly. + /// + /// This is rarely needed: the region is otherwise derived automatically + /// from the auth credentials' prefix (`us_`/`ap_`). Use this only to + /// force a specific data center regardless of the credential. + #[must_use] + pub fn dc_region(self, region: DcRegion) -> Self { + self.header(DC_REGION_HEADER, region.as_str()) + } + /// Set the HTTP endpoint URL in place. pub fn set_http_url(&mut self, url: impl Into) { self.http_url = Some(url.into()); diff --git a/rust/src/dca/context.rs b/rust/src/dca/context.rs index d9eaaf028e..8c96f16922 100644 --- a/rust/src/dca/context.rs +++ b/rust/src/dca/context.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use longbridge_httpcli::{HttpClient, Json, Method}; +use longbridge_httpcli::{DcRegion, HttpClient, Json, Method}; use serde::{Serialize, de::DeserializeOwned}; use tracing::{Subscriber, dispatcher, instrument::WithSubscriber}; @@ -55,6 +55,8 @@ impl DCAContext { .0 .http_cli .request(Method::GET, path) + // Recurring investment (DCA) is served only by the AP data center. + .dc_restrict(DcRegion::Ap) .query_params(query) .response::>() .send() @@ -72,6 +74,8 @@ impl DCAContext { .0 .http_cli .request(Method::POST, path) + // Recurring investment (DCA) is served only by the AP data center. + .dc_restrict(DcRegion::Ap) .body(Json(body)) .response::>() .send() diff --git a/rust/src/fundamental/context.rs b/rust/src/fundamental/context.rs index 450848147e..b9a066e002 100644 --- a/rust/src/fundamental/context.rs +++ b/rust/src/fundamental/context.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use longbridge_httpcli::{HttpClient, Json, Method}; +use longbridge_httpcli::{DcRegion, HttpClient, Json, Method}; use serde::{Serialize, de::DeserializeOwned}; use tracing::{Subscriber, dispatcher, instrument::WithSubscriber}; @@ -80,6 +80,26 @@ impl FundamentalContext { .0) } + /// Like [`get`](Self::get), but restricted to a single data center. Used by + /// region-limited endpoints (e.g. AP-only fundamentals). + async fn get_dc(&self, path: &'static str, query: Q, dc_restrict: DcRegion) -> Result + where + R: DeserializeOwned + Send + Sync + 'static, + Q: Serialize + Send + Sync, + { + Ok(self + .0 + .http_cli + .request(Method::GET, path) + .dc_restrict(dc_restrict) + .query_params(query) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + // ── financial_report ───────────────────────────────────────── /// Get financial reports for a security. @@ -466,11 +486,12 @@ impl FundamentalContext { struct Query { counter_id: String, } - self.get( + self.get_dc( "/v1/quote/operatings", Query { counter_id: symbol_to_counter_id(&symbol.into()), }, + DcRegion::Ap, ) .await } @@ -1029,4 +1050,246 @@ impl FundamentalContext { let count = if total > 0 { total } else { count }; Ok(MacroeconomicResponse { info, data, count }) } + + // ── US-market APIs (US token required) ──────────────────────────────────── + + /// Get US company overview. + /// + /// Path: `GET /v1/stock-info/company-overview` + /// + /// US token required — returns + /// [`longbridge_httpcli::HttpClientError::DcRegionRestricted`] + /// for non-US credentials. + pub async fn us_company_overview( + &self, + symbol: impl Into, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + } + self.get_dc( + "/v1/stock-info/company-overview", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + }, + DcRegion::Us, + ) + .await + } + + /// Get US valuation overview snapshot. + /// + /// Path: `GET /v1/stock-info/valuation-overview` + /// + /// US token required. + pub async fn us_valuation_overview( + &self, + symbol: impl Into, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + } + self.get_dc( + "/v1/stock-info/valuation-overview", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + }, + DcRegion::Us, + ) + .await + } + + /// Get US financial overview (revenue, net income, EPS, cash flow). + /// + /// `report`: `"annual"` or `"quarterly"`. + /// + /// Path: `GET /v1/stock-info/finn-overview` + /// + /// US token required. Returns raw JSON for maximum flexibility. + pub async fn us_financial_overview( + &self, + symbol: impl Into, + report: impl Into, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + report: String, + } + self.get_dc( + "/v1/stock-info/finn-overview", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + report: report.into(), + }, + DcRegion::Us, + ) + .await + } + + /// Get US financial statement detail (IS / BS / CF). + /// + /// `kind`: `"IS"` (income statement), `"BS"` (balance sheet), `"CF"` (cash + /// flow). `report`: `"annual"` or `"quarterly"`. + /// + /// Path: `GET /v1/us/quote/financials/statements` + /// + /// US token required. + pub async fn us_financial_statement_v3( + &self, + symbol: impl Into, + kind: impl Into, + report: impl Into, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + kind: String, + report: String, + } + self.get_dc( + "/v1/us/quote/financials/statements", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + kind: kind.into(), + report: report.into(), + }, + DcRegion::Us, + ) + .await + } + + /// Get key financial metrics (ROE, margins, leverage ratios). + /// + /// `report`: `"annual"` or `"quarterly"`. + /// + /// Path: `GET /v1/stock-info/fin-keyfactor` + /// + /// US token required. Returns raw JSON. + pub async fn us_key_financial_metrics( + &self, + symbol: impl Into, + report: impl Into, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + report: String, + } + self.get_dc( + "/v1/stock-info/fin-keyfactor", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + report: report.into(), + }, + DcRegion::Us, + ) + .await + } + + /// Get analyst consensus estimates (EPS and revenue forecasts). + /// + /// `report`: `"annual"` or `"quarterly"`. + /// + /// Path: `GET /v1/stock-info/fin-consensus` + /// + /// US token required. Returns raw JSON. + pub async fn us_analyst_consensus( + &self, + symbol: impl Into, + report: impl Into, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + report: String, + } + self.get_dc( + "/v1/stock-info/fin-consensus", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + report: report.into(), + }, + DcRegion::Us, + ) + .await + } + + /// Get ETF dividend history. + /// + /// Path: `GET /v1/stock-info/etf-dividend-info` + /// + /// US token required. + pub async fn us_etf_dividend_info( + &self, + symbol: impl Into, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + } + self.get_dc( + "/v1/stock-info/etf-dividend-info", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + }, + DcRegion::Us, + ) + .await + } + + /// Get company historical dividend payments. + /// + /// Path: `GET /v1/stock-info/company-dividends` + /// + /// US token required. + pub async fn us_company_dividends( + &self, + symbol: impl Into, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + } + self.get_dc( + "/v1/stock-info/company-dividends", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + }, + DcRegion::Us, + ) + .await + } + + /// Get ETF document list (prospectus, annual reports, etc.). + /// + /// `size`: number of files to return; `None` returns all (server default 0 + /// = all). + /// + /// Path: `GET /v1/stock-info/etf-files` + /// + /// US token required. + pub async fn us_etf_files( + &self, + symbol: impl Into, + size: Option, + ) -> Result { + #[derive(Serialize)] + struct Query { + counter_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + size: Option, + } + self.get_dc( + "/v1/stock-info/etf-files", + Query { + counter_id: symbol_to_counter_id(&symbol.into()), + size, + }, + DcRegion::Us, + ) + .await + } } diff --git a/rust/src/fundamental/types.rs b/rust/src/fundamental/types.rs index fa127e9c43..96c8895a80 100644 --- a/rust/src/fundamental/types.rs +++ b/rust/src/fundamental/types.rs @@ -1720,6 +1720,189 @@ pub struct MacroeconomicResponse { pub count: i32, } +// ── US-market types +// ─────────────────────────────────────────────────────────── + +/// Industry rank tag returned by +/// [`crate::FundamentalContext::us_company_overview`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USRankTag { + /// Tag name + #[serde(default)] + pub name: String, + /// Change value + #[serde(default)] + pub chg: String, + /// Rank type identifier + #[serde(default)] + pub rank_type: i32, +} + +/// Response for [`crate::FundamentalContext::us_company_overview`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USCompanyOverview { + /// Company description + #[serde(default)] + pub intro: String, + /// Market cap + #[serde(default)] + pub market_cap: String, + /// Currency symbol (e.g. `$`) + #[serde(default)] + pub ccy_symbol: String, + /// Industry rank tags + #[serde(default)] + pub top_rank_tags: Vec, + /// Deep-link URL to detail page + #[serde(default)] + pub detail_url: String, +} + +/// Current valuation indicator detail. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USValuationIndicator { + /// Full-circle reference value + #[serde(default)] + pub circle: String, + /// Current partial value + #[serde(default)] + pub part: String, + /// Displayed metric value + #[serde(default)] + pub metric: String, + /// Metric type identifier + #[serde(default)] + pub metric_type: String, + /// Human-readable description + #[serde(default)] + pub desc: String, + /// Currency symbol + #[serde(default)] + pub ccy_symbol: String, +} + +/// Response for [`crate::FundamentalContext::us_valuation_overview`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USValuationOverview { + /// Recommended valuation indicator (e.g. `PE`) + #[serde(default)] + pub indicator: String, + /// Current indicator detail + #[serde(default)] + pub current_indicator: USValuationIndicator, + /// Historical percentile range in years + #[serde(default)] + pub range: i32, + /// Data date string + #[serde(default)] + pub date: String, + /// AI summary text + #[serde(default)] + pub ai_summary: String, +} + +/// Response for [`crate::FundamentalContext::us_financial_statement_v3`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USFinancialStatement { + /// Revenue + #[serde(default)] + pub revenue: String, + /// Net income + #[serde(default)] + pub net_income: String, + /// Net margin + #[serde(default)] + pub net_margin: String, + /// Per-period data + #[serde(default)] + pub periods: Vec, + /// Report currency + #[serde(default)] + pub currency: String, +} + +/// Response for [`crate::FundamentalContext::us_etf_dividend_info`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USETFDividendInfo { + /// Total dividend over trailing 12 months + #[serde(default)] + pub dividend_ttm: String, + /// Dividend yield over trailing 12 months + #[serde(default)] + pub dividend_yield_ttm: String, + /// Dividend frequency + #[serde(default)] + pub dividend_frequency: String, + /// Currency + #[serde(default)] + pub currency: String, + /// Per-fiscal-year dividend records + #[serde(default)] + pub fiscal_year_info: Vec, +} + +/// A single historical dividend payment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USDividendItem { + /// Per-share dividend amount + #[serde(default)] + pub dividend: String, + /// Dividend type (e.g. cash dividend) + #[serde(default)] + pub dividend_type: String, + /// Ex-dividend date + #[serde(default)] + pub ex_date: String, + /// Payment date + #[serde(default)] + pub payment_date: String, + /// Record date + #[serde(default)] + pub record_date: String, +} + +/// Response for [`crate::FundamentalContext::us_company_dividends`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USCompanyDividends { + /// Total dividend over trailing 12 months + #[serde(default)] + pub dividend_ttm: String, + /// Dividend yield over trailing 12 months + #[serde(default)] + pub dividend_yield_ttm: String, + /// Number of dividend payments + #[serde(default)] + pub payouts: String, + /// Currency + #[serde(default)] + pub currency: String, + /// Individual payment records + #[serde(default)] + pub items: Vec, +} + +/// A single file in an ETF document list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USETFFile { + /// File name + #[serde(default)] + pub name: String, + /// File type (e.g. prospectus, annual report) + #[serde(default)] + pub file_type: String, + /// Download URL + #[serde(default)] + pub url: String, +} + +/// Response for [`crate::FundamentalContext::us_etf_files`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USETFFilesResponse { + /// List of ETF documents + #[serde(default)] + pub files: Vec, +} + // ── v2 wire types (internal, used for mapping to existing public types) ────── /// v2 wire: one indicator from GET /v2/quote/macrodata diff --git a/rust/src/lib.rs b/rust/src/lib.rs index df996bbd33..45bcb840a3 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -44,13 +44,23 @@ pub use content::ContentContext; pub use dca::DCAContext; pub use error::{Error, Result, SimpleError, SimpleErrorKind}; pub use fundamental::FundamentalContext; +// ── US-market type re-exports ───────────────────────────────────────────────── +pub use fundamental::types::{ + USCompanyDividends, USCompanyOverview, USDividendItem, USETFDividendInfo, USETFFile, + USETFFilesResponse, USFinancialStatement, USRankTag, USValuationIndicator, USValuationOverview, +}; pub use longbridge_httpcli as httpclient; +pub use longbridge_httpcli::{DC_REGION_HEADER, DcRegion}; pub use longbridge_wscli as wsclient; pub use market::MarketContext; pub use portfolio::PortfolioContext; -pub use quote::QuoteContext; +pub use quote::{QuoteContext, USCryptoOverview}; pub use rust_decimal::Decimal; pub use screener::ScreenerContext; pub use sharelist::SharelistContext; -pub use trade::TradeContext; +pub use trade::{ + GetUSHistoryOrders, GetUSRealizedPLOptions, QueryUSOrdersOptions, QueryUSOrdersResponse, + TradeContext, USAssetOverview, USCashEntry, USCryptoEntry, USOrderDetailResponse, + USOrderHistory, USRealizedPL, USRealizedPLEntry, USRealizedPLMetric, +}; pub use types::Market; diff --git a/rust/src/market/context.rs b/rust/src/market/context.rs index e0a27b4701..c3512cb030 100644 --- a/rust/src/market/context.rs +++ b/rust/src/market/context.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use longbridge_httpcli::{HttpClient, Json, Method}; +use longbridge_httpcli::{DcRegion, HttpClient, Json, Method}; use serde::{Serialize, de::DeserializeOwned}; use tracing::{Subscriber, dispatcher, instrument::WithSubscriber}; @@ -85,6 +85,26 @@ impl MarketContext { .0) } + /// Like [`get`](Self::get), but restricted to a single data center. Used by + /// region-limited endpoints (e.g. AP-only broker holdings). + async fn get_dc(&self, path: &'static str, query: Q, dc_restrict: DcRegion) -> Result + where + R: DeserializeOwned + Send + Sync + 'static, + Q: Serialize + Send + Sync, + { + Ok(self + .0 + .http_cli + .request(Method::GET, path) + .dc_restrict(dc_restrict) + .query_params(query) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + async fn post(&self, path: &'static str, body: B) -> Result where R: DeserializeOwned + Send + Sync + 'static, @@ -135,12 +155,13 @@ impl MarketContext { #[serde(rename = "type")] period: &'static str, } - self.get( + self.get_dc( "/v1/quote/broker-holding", Query { counter_id: symbol_to_counter_id(&symbol.into()), period: period_str, }, + DcRegion::Ap, ) .await } @@ -156,11 +177,12 @@ impl MarketContext { struct Query { counter_id: String, } - self.get( + self.get_dc( "/v1/quote/broker-holding/detail", Query { counter_id: symbol_to_counter_id(&symbol.into()), }, + DcRegion::Ap, ) .await } @@ -178,12 +200,13 @@ impl MarketContext { counter_id: String, parti_number: String, } - self.get( + self.get_dc( "/v1/quote/broker-holding/daily", Query { counter_id: symbol_to_counter_id(&symbol.into()), parti_number: broker_id.into(), }, + DcRegion::Ap, ) .await } diff --git a/rust/src/quote/context.rs b/rust/src/quote/context.rs index 663142c2fc..85be46679f 100644 --- a/rust/src/quote/context.rs +++ b/rust/src/quote/context.rs @@ -4,7 +4,7 @@ use std::{ time::Duration, }; -use longbridge_httpcli::{HttpClient, Json, Method}; +use longbridge_httpcli::{DcRegion, HttpClient, Json, Method}; use longbridge_proto::quote; use longbridge_wscli::WsClientError; use serde::{Deserialize, Serialize}; @@ -668,6 +668,17 @@ impl QuoteContext { /// # }); /// ``` pub async fn brokers(&self, symbol: impl Into) -> Result { + // Broker queue is served only by the AP data center; short-circuit a + // non-AP session with the same unified error the HTTP path returns. + let current = self.0.http_cli.dc_region().await; + if !current.allows(longbridge_httpcli::DcRegion::Ap) { + return Err(longbridge_httpcli::HttpClientError::DcRegionRestricted { + path: "quote/brokers (WebSocket)".to_string(), + required: longbridge_httpcli::DcRegion::Ap, + current, + } + .into()); + } let resp: quote::SecurityBrokersResponse = self .request( cmd_code::GET_SECURITY_BROKERS, @@ -2274,6 +2285,41 @@ impl QuoteContext { } Ok(result) } + + // ── US-market APIs ──────────────────────────────────────────────────────── + + /// Get cryptocurrency market overview. + /// + /// `symbol` must be in `PAIR.EXCHANGE` format. + /// US DC uses the **BKKT** exchange: e.g. `"BTCUSD.BKKT"` → + /// `"VA/BKKT/BTCUSD"`. Pass the exchange suffix explicitly. + /// + /// Path: `GET /v1/gemini/us/crypto-overview` + /// + /// US token required. + pub async fn us_crypto_overview( + &self, + symbol: impl Into, + ) -> Result { + use crate::utils::counter::symbol_to_counter_id; + #[derive(Serialize)] + struct Query { + counter_id: String, + } + Ok(self + .0 + .http_cli + .request(Method::GET, "/v1/gemini/us/crypto-overview") + .dc_restrict(DcRegion::Us) + .query_params(Query { + counter_id: symbol_to_counter_id(&symbol.into()), + }) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } } fn normalize_symbol(symbol: &str) -> &str { diff --git a/rust/src/quote/mod.rs b/rust/src/quote/mod.rs index 78dcba0cba..39d4260e1a 100644 --- a/rust/src/quote/mod.rs +++ b/rust/src/quote/mod.rs @@ -70,6 +70,7 @@ pub use types::{ TradeSession, TradeSessions, TradingSessionInfo, + USCryptoOverview, WarrantInfo, WarrantQuote, WarrantSortBy, diff --git a/rust/src/quote/types.rs b/rust/src/quote/types.rs index 08df9b0074..dbb556ac0d 100644 --- a/rust/src/quote/types.rs +++ b/rust/src/quote/types.rs @@ -2164,6 +2164,69 @@ pub enum PinnedMode { Remove, } +// ── US-market types +// ─────────────────────────────────────────────────────────── + +/// Market overview for a single cryptocurrency. +/// +/// Returned by [`crate::QuoteContext::us_crypto_overview`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct USCryptoOverview { + /// Full name (e.g. `"Bitcoin"`) + #[serde(default)] + pub name: String, + /// Ticker symbol (e.g. `"BTC"`) + #[serde(default)] + pub ticker: String, + /// Pricing currency + #[serde(default)] + pub currency: String, + /// All-time high price + #[serde(default)] + pub all_time_high: String, + /// All-time high date + #[serde(default)] + pub all_time_high_date: String, + /// All-time low price + #[serde(default)] + pub all_time_low: String, + /// All-time low date + #[serde(default)] + pub all_time_low_date: String, + /// Listing date + #[serde(default)] + pub ipo_date: String, + /// Issue price + #[serde(default)] + pub issue_price: String, + /// Circulating supply + #[serde(default)] + pub shares: String, + /// User-facing symbol (e.g. `"BTCUSD.BKKT"`), converted from the API's + /// `counter_id` field (e.g. `"VA/BKKT/BTCUSD"`). + #[serde( + default, + rename = "counter_id", + deserialize_with = "crate::utils::counter::deserialize_counter_id_as_symbol" + )] + pub symbol: String, + /// Base asset code (e.g. `"BTC"`) + #[serde(default)] + pub base_asset: String, + /// Official website URL + #[serde(default)] + pub official_web_address: String, + /// Logo image URL + #[serde(default)] + pub logo: String, + /// In-app wiki URL + #[serde(default)] + pub wiki_url: String, + /// Multi-language profile / description (JSON string) + #[serde(default)] + pub profile: String, +} + #[cfg(test)] mod tests { use serde::Deserialize; diff --git a/rust/src/trade/context.rs b/rust/src/trade/context.rs index 4e4c1c0dbe..cfd8ad4176 100644 --- a/rust/src/trade/context.rs +++ b/rust/src/trade/context.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use longbridge_httpcli::{HttpClient, Json, Method}; +use longbridge_httpcli::{DcRegion, HttpClient, Json, Method}; use longbridge_wscli::WsClientError; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -13,8 +13,10 @@ use crate::{ AccountBalance, CashFlow, EstimateMaxPurchaseQuantityOptions, Execution, FundPositionsResponse, GetCashFlowOptions, GetFundPositionsOptions, GetHistoryExecutionsOptions, GetHistoryOrdersOptions, GetStockPositionsOptions, - GetTodayExecutionsOptions, GetTodayOrdersOptions, MarginRatio, Order, OrderDetail, - PushEvent, ReplaceOrderOptions, StockPositionsResponse, SubmitOrderOptions, TopicType, + GetTodayExecutionsOptions, GetTodayOrdersOptions, GetUSHistoryOrders, + GetUSRealizedPLOptions, MarginRatio, Order, OrderDetail, OrderSide, PushEvent, + QueryUSOrdersOptions, QueryUSOrdersResponse, ReplaceOrderOptions, StockPositionsResponse, + SubmitOrderOptions, TopicType, USAssetOverview, USOrderDetailResponse, USRealizedPL, core::{Command, Core}, }, }; @@ -834,4 +836,157 @@ impl TradeContext { .await? .0) } + + // ── US-market APIs ──────────────────────────────────────────────────────── + + /// Query the paginated US order list. + /// + /// Path: `POST /v1/orders/query` + /// + /// US token required. + pub async fn us_query_orders(&self, opts: GetUSHistoryOrders) -> Result { + use std::time::{SystemTime, UNIX_EPOCH}; + + use crate::utils::counter::symbol_to_counter_id; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let action = match opts.side { + OrderSide::Buy => 1, + OrderSide::Sell => 2, + _ => 0, + }; + + let counter_ids = opts + .symbol + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| vec![symbol_to_counter_id(s)]) + .unwrap_or_default(); + + let start_at = if opts.start_at == 0 { + (now - 90 * 24 * 3600) as f64 + } else { + opts.start_at as f64 + }; + let end_at = if opts.end_at == 0 { + now as f64 + } else { + opts.end_at as f64 + }; + let page = if opts.page <= 0 { 1 } else { opts.page }; + let limit = if opts.limit <= 0 { 20 } else { opts.limit }; + + let body = super::types::USQueryOrdersBody { + account_channel: String::new(), + action, + start_at, + end_at, + counter_ids, + security_types: vec![], + query_type: opts.query_type, + page, + limit, + query_version: now as f64, + }; + + Ok(self + .0 + .http_cli + .request(Method::POST, "/v1/orders/query") + .dc_restrict(DcRegion::Us) + .body(Json(body)) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + + /// Get US order detail. + /// + /// Path: `GET /v1/orders/{order_id}` + /// + /// US token required. + pub async fn us_order_detail( + &self, + order_id: impl Into, + ) -> Result { + let order_id = order_id.into(); + let path = format!("/v1/orders/{order_id}"); + + Ok(self + .0 + .http_cli + .request(Method::GET, path.as_str()) + .dc_restrict(DcRegion::Us) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + + /// Get the full US account asset snapshot (stocks, options, crypto, buying + /// power). + /// + /// Path: `GET /v1/us/assets/overview` + /// + /// US token required. + pub async fn us_asset_overview(&self) -> Result { + Ok(self + .0 + .http_cli + .request(Method::GET, "/v1/us/assets/overview") + .dc_restrict(DcRegion::Us) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + + /// Get realized profit-and-loss for the US account. + /// + /// `currency`: required, e.g. `"USD"`. + /// `category`: optional filter — `"ALL"`, `"STOCK"`, `"OPTION"`, or + /// `"CRYPTO"`. + /// + /// Path: `GET /v1/us/assets/pl/realized` + /// + /// US token required. + pub async fn us_realized_pl(&self, opts: GetUSRealizedPLOptions) -> Result { + #[derive(Serialize)] + struct Query { + currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + category: Option, + } + + let currency = if opts.currency.is_empty() { + "USD".to_string() + } else { + opts.currency + }; + let category = if opts.category.is_empty() { + None + } else { + Some(opts.category) + }; + + Ok(self + .0 + .http_cli + .request(Method::GET, "/v1/us/assets/pl/realized") + .dc_restrict(DcRegion::Us) + .query_params(Query { currency, category }) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } } diff --git a/rust/src/trade/mod.rs b/rust/src/trade/mod.rs index 21df810dcc..4f1b070409 100644 --- a/rust/src/trade/mod.rs +++ b/rust/src/trade/mod.rs @@ -15,10 +15,48 @@ pub use requests::{ GetTodayExecutionsOptions, GetTodayOrdersOptions, ReplaceOrderOptions, SubmitOrderOptions, }; pub use types::{ - AccountBalance, BalanceType, CashFlow, CashFlowDirection, CashInfo, ChargeCategoryCode, - CommissionFreeStatus, DeductionStatus, Execution, FrozenTransactionFee, FundPosition, - FundPositionChannel, FundPositionsResponse, MarginRatio, Order, OrderChargeDetail, - OrderChargeFee, OrderChargeItem, OrderDetail, OrderHistoryDetail, OrderSide, OrderStatus, - OrderTag, OrderType, OutsideRTH, StockPosition, StockPositionChannel, StockPositionsResponse, - TimeInForceType, TriggerPriceType, TriggerStatus, + AccountBalance, + BalanceType, + CashFlow, + CashFlowDirection, + CashInfo, + ChargeCategoryCode, + CommissionFreeStatus, + DeductionStatus, + Execution, + FrozenTransactionFee, + FundPosition, + FundPositionChannel, + FundPositionsResponse, + // US-market types + GetUSHistoryOrders, + GetUSRealizedPLOptions, + MarginRatio, + Order, + OrderChargeDetail, + OrderChargeFee, + OrderChargeItem, + OrderDetail, + OrderHistoryDetail, + OrderSide, + OrderStatus, + OrderTag, + OrderType, + OutsideRTH, + QueryUSOrdersOptions, + QueryUSOrdersResponse, + StockPosition, + StockPositionChannel, + StockPositionsResponse, + TimeInForceType, + TriggerPriceType, + TriggerStatus, + USAssetOverview, + USCashEntry, + USCryptoEntry, + USOrderDetailResponse, + USOrderHistory, + USRealizedPL, + USRealizedPLEntry, + USRealizedPLMetric, }; diff --git a/rust/src/trade/types.rs b/rust/src/trade/types.rs index d78082f73b..b7215ccb62 100644 --- a/rust/src/trade/types.rs +++ b/rust/src/trade/types.rs @@ -804,6 +804,196 @@ impl_default_for_enum_string!( ChargeCategoryCode ); +// ── US-market types +// ─────────────────────────────────────────────────────────── + +/// Request for [`crate::TradeContext::us_query_orders`], modelled after +/// [`crate::GetHistoryOrdersOptions`] for HK/CN orders. +/// +/// `query_type`: 0 = all (includes Rejected), 1 = pending, 2 = history (filled +/// only). Default 0 matches what the app shows as "past orders". +/// +/// `symbol` accepts a user-facing symbol e.g. `"AAPL.US"` or `"DOGEUSD.BKKT"`. +/// The SDK converts it to the internal `counter_id` format automatically. +#[derive(Debug, Clone, Default)] +pub struct GetUSHistoryOrders { + /// Optional symbol filter, e.g. `"AAPL.US"`. Converted to counter_id + /// internally. + pub symbol: Option, + /// Direction filter. [`crate::OrderSide::Unknown`] = all (default). + pub side: OrderSide, + /// Start timestamp (seconds). Defaults to 90 days ago. + pub start_at: i64, + /// End timestamp (seconds). Defaults to now. + pub end_at: i64, + /// 0 = all, 1 = pending, 2 = history (filled only). Default 0. + pub query_type: i32, + /// Page number, 1-based. Default 1. + pub page: i32, + /// Page size. Default 20. + pub limit: i32, +} + +/// Alias kept for backward compatibility. +pub type QueryUSOrdersOptions = GetUSHistoryOrders; + +/// Internal JSON body sent to POST /v1/orders/query. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct USQueryOrdersBody { + pub account_channel: String, + pub action: i32, + pub start_at: f64, + pub end_at: f64, + pub counter_ids: Vec, + pub security_types: Vec, + pub query_type: i32, + pub page: i32, + pub limit: i32, + pub query_version: f64, +} + +/// Response for [`crate::TradeContext::us_query_orders`]. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct QueryUSOrdersResponse { + /// Order list (raw JSON for forward compatibility). + /// Order ID field is `id` (not `order_id`). + #[serde(default)] + pub orders: Vec, + /// Total number of orders matching the query. + #[serde(default)] + pub total_count: i32, +} + +/// One order state-transition entry in [`USOrderDetailResponse`]. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USOrderHistory { + #[serde(default)] + pub exec_type: i32, + #[serde(default)] + pub status: String, + #[serde(default)] + pub price: String, + #[serde(default)] + pub qty: String, + #[serde(default)] + pub time: String, + #[serde(default)] + pub msg: String, +} + +/// Response for [`crate::TradeContext::us_order_detail`]. +/// Path: `GET /v1/orders/{order_id}` +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USOrderDetailResponse { + /// Full order object (raw JSON — field set varies by order type). + pub order: serde_json::Value, + /// Order state-transition history. + #[serde(default)] + pub order_histories: Vec, + /// Current attached stop-loss/take-profit sub-order, if any. + #[serde(default)] + pub current_attached_order: serde_json::Value, +} + +/// One cash currency entry in [`USAssetOverview`]. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USCashEntry { + #[serde(default)] + pub currency: String, + #[serde(default)] + pub frozen_buy_cash: String, + #[serde(default)] + pub outstanding: String, + #[serde(default)] + pub settled_cash: String, + #[serde(default)] + pub total_amount: String, + #[serde(default)] + pub total_cash: String, +} + +/// One cryptocurrency holding in [`USAssetOverview`]. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USCryptoEntry { + #[serde(default)] + pub asset_type: String, + #[serde(default)] + pub average_cost: String, + /// User-facing trading-pair symbol (e.g. `"BTCUSD.BKKT"`), converted from + /// the API's `counter_id` field (e.g. `"VA/BKKT/BTCUSD"`). + #[serde( + default, + rename = "counter_id", + deserialize_with = "crate::utils::counter::deserialize_counter_id_as_symbol" + )] + pub symbol: String, + #[serde(default)] + pub currency: String, + #[serde(default)] + pub industry_name: String, +} + +/// Response for [`crate::TradeContext::us_asset_overview`]. +/// Field names match the actual API response from `GET /v1/us/assets/overview`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USAssetOverview { + #[serde(default)] + pub account_type: String, + /// Account snapshot timestamp (Unix-second string → OffsetDateTime). + #[serde(default, with = "crate::serde_utils::timestamp_opt")] + pub asset_timestamp: Option, + /// Cash buying power (top-level convenience field). + #[serde(default)] + pub cash_buy_power: String, + #[serde(default)] + pub cash_list: Vec, + #[serde(default)] + pub crypto_list: Vec, +} + +/// One time-period metric in a [`USRealizedPLEntry`]. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USRealizedPLMetric { + #[serde(default)] + pub amount: String, + /// Period code (server-defined; 2 = current month observed in testing). + #[serde(default)] + pub period: i32, + #[serde(default)] + pub rate: String, +} + +/// One asset-category entry in [`USRealizedPL`]. +/// `category`: 0 = all, 1 = stock, 2 = option, 3 = crypto (server-defined). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USRealizedPLEntry { + #[serde(default)] + pub category: i32, + #[serde(default)] + pub currency: String, + #[serde(default)] + pub metrics: Vec, +} + +/// Request for [`crate::TradeContext::us_realized_pl`], modelled after +/// [`crate::GetUSHistoryOrders`]. +#[derive(Debug, Clone, Default)] +pub struct GetUSRealizedPLOptions { + /// Currency, e.g. `"USD"`. Defaults to `"USD"` if empty. + pub currency: String, + /// Asset category filter: `""` = all, `"STOCK"`, `"OPTION"`, `"CRYPTO"`. + pub category: String, +} + +/// Response for [`crate::TradeContext::us_realized_pl`]. +/// Field name matches the actual API response from `GET +/// /v1/us/assets/pl/realized`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct USRealizedPL { + #[serde(default)] + pub realized_pl_list: Vec, +} + #[cfg(test)] mod tests { use time::macros::datetime; diff --git a/rust/src/utils/counter.rs b/rust/src/utils/counter.rs index beacec3e01..f6f69b46bd 100644 --- a/rust/src/utils/counter.rs +++ b/rust/src/utils/counter.rs @@ -147,23 +147,40 @@ pub fn lookup_counter_id(symbol: &str) -> Option { /// /// Leading-dot symbols (e.g. `.DJI.US`) are US market indexes and always map /// to `IX/`. All other symbols are checked against the embedded +/// Known crypto exchange identifiers used as symbol suffixes. +/// +/// Region mapping: +/// - US DC: `BKKT` +/// - HK DC: `HAS`, `OSL` +/// +/// e.g. `"BTCUSD.BKKT"` → `"VA/BKKT/BTCUSD"`, `"BTCUSD.HAS"` → +/// `"VA/HAS/BTCUSD"`. +const CRYPTO_EXCHANGES: &[&str] = &["BKKT", "HAS", "OSL"]; + /// ETF + index + warrant set and the remote-resolved cache; a matching entry -/// is returned as-is. Unmatched symbols default to `ST/`. +/// is returned as-is. Crypto symbols whose suffix matches a known exchange +/// (e.g. `HAS`) are converted to `VA/{EXCHANGE}/{PAIR}`. +/// Unmatched symbols default to `ST/`. pub fn symbol_to_counter_id(symbol: &str) -> String { if let Some((code, market)) = symbol.rsplit_once('.') { if let Some(counter_id) = lookup_counter_id(symbol) { return counter_id; } - let market = market.to_uppercase(); + let market_upper = market.to_uppercase(); + // Known crypto exchange suffix → VA/{EXCHANGE}/{PAIR} + if CRYPTO_EXCHANGES + .iter() + .any(|e| e.eq_ignore_ascii_case(&market_upper)) + { + return format!("VA/{market_upper}/{code}"); + } // Strip leading zeros from numeric HK codes (e.g. `00700` → `700`). - // Other markets keep their codes verbatim (A-share codes such as - // `000001.SZ` have significant leading zeros). - let code = if market == "HK" && code.chars().all(|c| c.is_ascii_digit()) { + let code = if market_upper == "HK" && code.chars().all(|c| c.is_ascii_digit()) { code.trim_start_matches('0') } else { code }; - format!("ST/{market}/{code}") + format!("ST/{market_upper}/{code}") } else { symbol.to_string() } @@ -179,16 +196,21 @@ pub fn index_symbol_to_counter_id(symbol: &str) -> String { } } -/// Convert a counter_id (e.g. `ST/US/TSLA`, `ETF/US/SPY`, `IX/US/.DJI`, -/// `ST/HK/700`) back to a display symbol (e.g. `TSLA.US`, `SPY.US`, -/// `.DJI.US`, `700.HK`). +/// Convert a counter_id back to a display symbol. /// -/// US index counter IDs (`IX/US/...`) preserve the leading dot in the code -/// part (e.g. `IX/US/.DJI` → `.DJI.US`). +/// - `ST/US/TSLA` → `TSLA.US` +/// - `ETF/US/SPY` → `SPY.US` +/// - `IX/US/.DJI` → `.DJI.US` +/// - `VA/HAS/BTCUSD` → `BTCUSD.HAS` (crypto: `PAIR.EXCHANGE`) pub fn counter_id_to_symbol(counter_id: &str) -> String { let parts: Vec<&str> = counter_id.splitn(3, '/').collect(); if parts.len() == 3 { - format!("{}.{}", parts[2], parts[1]) + let (prefix, market, code) = (parts[0], parts[1], parts[2]); + if prefix.eq_ignore_ascii_case("VA") { + // Crypto: return PAIR.EXCHANGE format + return format!("{code}.{market}"); + } + format!("{code}.{market}") } else { counter_id.to_string() }