From 4c93c4ec4f46569bd7e05eb5e1bca8abbdff75a6 Mon Sep 17 00:00:00 2001 From: Sunli Date: Thu, 18 Jun 2026 08:38:12 +0800 Subject: [PATCH 1/6] feat(trade): add attached order (take-profit/stop-loss) support - Switch openapi-sdk dependency to feat/attached-orders branch (PR #549) - Add attached order params to submit_order and replace_order tools - Add is_attached filter to today_orders and order_detail tools - Add AttachedOrderDetailResponse to output schema --- Cargo.lock | 14 +-- Cargo.toml | 2 +- src/tools/mod.rs | 14 +-- src/tools/output/mod.rs | 48 +++++++++ src/tools/trade.rs | 212 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 268 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9670de2..9782f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1521,7 +1521,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "longbridge" version = "4.3.2" -source = "git+https://github.com/longbridge/openapi.git?branch=main#3c995f576e245c5b3235a88f1aad5d4c2343a6c2" +source = "git+https://github.com/longbridge/openapi.git?branch=feat%2Fattached-orders#e996b05f0c162a4894e983844408428e9bd47c9d" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "longbridge-candlesticks" version = "4.3.2" -source = "git+https://github.com/longbridge/openapi.git?branch=main#3c995f576e245c5b3235a88f1aad5d4c2343a6c2" +source = "git+https://github.com/longbridge/openapi.git?branch=feat%2Fattached-orders#e996b05f0c162a4894e983844408428e9bd47c9d" dependencies = [ "bitflags 2.11.1", "num-traits", @@ -1569,7 +1569,7 @@ dependencies = [ [[package]] name = "longbridge-geo" version = "4.3.2" -source = "git+https://github.com/longbridge/openapi.git?branch=main#3c995f576e245c5b3235a88f1aad5d4c2343a6c2" +source = "git+https://github.com/longbridge/openapi.git?branch=feat%2Fattached-orders#e996b05f0c162a4894e983844408428e9bd47c9d" dependencies = [ "reqwest 0.12.28", ] @@ -1577,7 +1577,7 @@ dependencies = [ [[package]] name = "longbridge-httpcli" version = "4.3.2" -source = "git+https://github.com/longbridge/openapi.git?branch=main#3c995f576e245c5b3235a88f1aad5d4c2343a6c2" +source = "git+https://github.com/longbridge/openapi.git?branch=feat%2Fattached-orders#e996b05f0c162a4894e983844408428e9bd47c9d" dependencies = [ "dotenv", "futures-util", @@ -1629,7 +1629,7 @@ dependencies = [ [[package]] name = "longbridge-oauth" version = "4.3.2" -source = "git+https://github.com/longbridge/openapi.git?branch=main#3c995f576e245c5b3235a88f1aad5d4c2343a6c2" +source = "git+https://github.com/longbridge/openapi.git?branch=feat%2Fattached-orders#e996b05f0c162a4894e983844408428e9bd47c9d" dependencies = [ "dirs", "futures-util", @@ -1646,7 +1646,7 @@ dependencies = [ [[package]] name = "longbridge-proto" version = "4.3.2" -source = "git+https://github.com/longbridge/openapi.git?branch=main#3c995f576e245c5b3235a88f1aad5d4c2343a6c2" +source = "git+https://github.com/longbridge/openapi.git?branch=feat%2Fattached-orders#e996b05f0c162a4894e983844408428e9bd47c9d" dependencies = [ "prost", "serde", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "longbridge-wscli" version = "4.3.2" -source = "git+https://github.com/longbridge/openapi.git?branch=main#3c995f576e245c5b3235a88f1aad5d4c2343a6c2" +source = "git+https://github.com/longbridge/openapi.git?branch=feat%2Fattached-orders#e996b05f0c162a4894e983844408428e9bd47c9d" dependencies = [ "byteorder", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 9360de2..a80f7e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] # TODO: switch back to crates.io after the next openapi release -longbridge = { git = "https://github.com/longbridge/openapi.git", branch = "main" } +longbridge = { git = "https://github.com/longbridge/openapi.git", branch = "feat/attached-orders" } rust_decimal = "1" rmcp = { version = "1.4", features = [ "server", diff --git a/src/tools/mod.rs b/src/tools/mod.rs index f826dc2..9bceb93 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -495,8 +495,8 @@ use crate::tools::quote::{ WarrantListParam, }; use crate::tools::trade::{ - CashFlowParam, EstimateMaxQtyParam, HistoryOrdersParam, OrderIdParam, ReplaceOrderParam, - SubmitOrderParam, + CashFlowParam, EstimateMaxQtyParam, HistoryOrdersParam, OrderDetailParam, OrderIdParam, + ReplaceOrderParam, SubmitOrderParam, }; #[tool_router(vis = "pub(crate)")] @@ -1196,7 +1196,7 @@ impl Longbridge { idempotent_hint = true, open_world_hint = true ), - description = "Get orders placed today. Returns orders[]{order_id, symbol, side, order_type, status, quantity, price, submitted_at, executed_quantity, executed_price}. Pass symbol to filter." + description = "Get orders placed today. Returns orders[]{order_id, symbol, side, order_type, status, quantity, price, submitted_at, executed_quantity, executed_price, attached_orders[]}. Pass symbol to filter. Set is_attached=true to list only attached (take-profit/stop-loss) orders." )] async fn today_orders( &self, @@ -1212,12 +1212,12 @@ impl Longbridge { title = "Order Detail", annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = true), output_schema = schema_for::(), - description = "Get detailed information about a specific order. Returns {order_id, symbol, status, side, order_type, quantity, price, executed_quantity, executed_price, submitted_at, time_in_force, msg}." + description = "Get detailed information about a specific order. Returns {order_id, symbol, status, side, order_type, quantity, price, executed_quantity, executed_price, submitted_at, time_in_force, msg, attached_orders[]}. Set is_attached=true to query an attached order (take-profit/stop-loss leg) by its own order_id." )] async fn order_detail( &self, ctx: RequestContext, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { let mctx = extract_context(&ctx)?; measured_tool_call("order_detail", || trade::order_detail(&mctx, p)).await @@ -1333,7 +1333,7 @@ impl Longbridge { open_world_hint = true ), output_schema = schema_for::(), - description = "Submit a buy/sell order. order_type: LO (Limit) / ELO (Enhanced Limit, HK) / MO (Market) / AO (At-auction, HK) / ALO (At-auction Limit, HK) / ODD (Odd Lots, HK) / LIT (Limit If Touched) / MIT (Market If Touched) / TSLPAMT (Trailing Limit by Amount) / TSLPPCT (Trailing Limit by Percent) / SLO (Special Limit, HK). side: Buy/Sell. time_in_force: Day/GTC/GTD" + description = "Submit a buy/sell order. order_type: LO (Limit) / ELO (Enhanced Limit, HK) / MO (Market) / AO (At-auction, HK) / ALO (At-auction Limit, HK) / ODD (Odd Lots, HK) / LIT (Limit If Touched) / MIT (Market If Touched) / TSLPAMT (Trailing Limit by Amount) / TSLPPCT (Trailing Limit by Percent) / SLO (Special Limit, HK). side: Buy/Sell. time_in_force: Day/GTC/GTD. To attach a take-profit/stop-loss: set attached_order_type (ProfitTaker/StopLoss/Bracket) with profit_taker_price and/or stop_loss_price." )] async fn submit_order( &self, @@ -1353,7 +1353,7 @@ impl Longbridge { idempotent_hint = true, open_world_hint = true ), - description = "Modify an open order's quantity, price, trigger_price, or trailing params. Returns \"order replaced\" on success. Only open/pending orders can be modified." + description = "Modify an open order's quantity, price, trigger_price, or trailing params. Returns \"order replaced\" on success. Only open/pending orders can be modified. To modify attached take-profit/stop-loss: provide attached_order_type with updated prices. To cancel all attached orders: set cancel_all_attached=true. To update a specific attached leg by ID: use profit_taker_id or stop_loss_id." )] async fn replace_order( &self, diff --git a/src/tools/output/mod.rs b/src/tools/output/mod.rs index 2cdfcf4..8929b46 100644 --- a/src/tools/output/mod.rs +++ b/src/tools/output/mod.rs @@ -227,6 +227,51 @@ pub struct BrokerLevel { pub broker_ids: Vec, } +/// One take-profit or stop-loss attached order leg, returned inside `OrderDetailResponse.attached_orders`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AttachedOrderDetailResponse { + /// Attached order ID. + pub order_id: String, + /// Attached order type: `ProfitTaker`, `StopLoss`, or `Bracket`. + pub attached_type_display: String, + /// Order status. + pub status: String, + /// Trigger price (null if not set). + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger_price: Option, + /// Submitted limit price (null for market-style legs). + #[serde(skip_serializing_if = "Option::is_none")] + pub submit_price: Option, + /// Submitted quantity. + pub quantity: String, + /// Quantity already executed. + pub executed_qty: String, + /// Volume-weighted average executed price (null when unfilled). + #[serde(skip_serializing_if = "Option::is_none")] + pub executed_price: Option, + /// Order type for the triggered leg, e.g. `LO`, `MO`. + pub activate_order_type: String, + /// Time-in-force: `Day` / `GTC` / `GTD`. + pub time_in_force: String, + /// GTD expiry date (yyyy-mm-dd, null when not GTD). + #[serde(skip_serializing_if = "Option::is_none")] + pub gtd: Option, + /// Trigger status, e.g. `Deactive` / `Active` / `Released`. + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger_status: Option, + /// Order submission time (RFC3339). + pub submitted_at: String, + /// Last update time (RFC3339). + pub updated_at: String, + /// Whether this leg has been withdrawn. + pub withdrawn: bool, + /// Whether this leg has been reviewed. + pub reviewed: bool, + /// RTH setting for the triggered leg. + #[serde(skip_serializing_if = "Option::is_none")] + pub activate_rth: Option, +} + /// Returned by `order_detail`. Single order with full lifecycle metadata. #[derive(Debug, Serialize, JsonSchema)] pub struct OrderDetailResponse { @@ -292,4 +337,7 @@ pub struct OrderDetailResponse { /// Outside-RTH setting: `RTH_ONLY` / `ANY_TIME` / `OVERNIGHT`. #[serde(skip_serializing_if = "Option::is_none")] pub outside_rth: Option, + /// Attached take-profit / stop-loss legs. Empty when none. + pub attached_orders: Vec, } + diff --git a/src/tools/trade.rs b/src/tools/trade.rs index e2a15f7..99211d7 100644 --- a/src/tools/trade.rs +++ b/src/tools/trade.rs @@ -1,4 +1,6 @@ -use longbridge::trade::{GetTodayExecutionsOptions, GetTodayOrdersOptions, TradeContext}; +use longbridge::trade::{ + GetOrderDetailOptions, GetTodayExecutionsOptions, GetTodayOrdersOptions, TradeContext, +}; use rmcp::ErrorData as McpError; use rmcp::model::CallToolResult; use rmcp::schemars::JsonSchema; @@ -17,6 +19,14 @@ pub struct OrderIdParam { pub order_id: String, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OrderDetailParam { + /// Order ID to look up + pub order_id: String, + /// Set to true when order_id belongs to an attached order (take-profit / stop-loss leg) + pub is_attached: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct AccountBalanceParam { /// Filter by currency code (e.g. "USD", "HKD"). Omit to return all currencies. @@ -27,6 +37,8 @@ pub struct AccountBalanceParam { pub struct TodayOrdersParam { /// Filter by symbol, e.g. "700.HK". Omit to return all today's orders. pub symbol: Option, + /// Set to true to list only attached orders (take-profit / stop-loss legs). + pub is_attached: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -76,6 +88,24 @@ pub struct SubmitOrderParam { pub outside_rth: Option, /// Order remark (max 255 characters) pub remark: Option, + /// Attach a take-profit/stop-loss order. Values: "ProfitTaker", "StopLoss", "Bracket" + pub attached_order_type: Option, + /// Take-profit trigger price (required for ProfitTaker / Bracket) + pub profit_taker_price: Option, + /// Stop-loss trigger price (required for StopLoss / Bracket) + pub stop_loss_price: Option, + /// Limit price for the take-profit leg (use with LO activate_order_type) + pub profit_taker_submit_price: Option, + /// Limit price for the stop-loss leg (use with LO activate_order_type) + pub stop_loss_submit_price: Option, + /// Time-in-force for the attached order: Day / GTC / GTD + pub attached_time_in_force: Option, + /// Expiry for the attached order as unix timestamp seconds (required when attached_time_in_force is GTD) + pub attached_expire_time: Option, + /// Order type for the triggered leg, e.g. "LO" or "MO" + pub attached_activate_order_type: Option, + /// RTH setting for the triggered leg: "RTH_ONLY" / "ANY_TIME" / "OVERNIGHT" + pub attached_outside_rth: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -94,6 +124,30 @@ pub struct ReplaceOrderParam { pub trailing_amount: Option, /// New trailing percent as decimal e.g. 0.05 = 5% (for TSLPPCT) pub trailing_percent: Option, + /// Set to true to cancel all attached orders on this parent order + pub cancel_all_attached: Option, + /// Attached order type to set/update: "ProfitTaker", "StopLoss", or "Bracket" + pub attached_order_type: Option, + /// ID of an existing take-profit attached order to modify + pub profit_taker_id: Option, + /// ID of an existing stop-loss attached order to modify + pub stop_loss_id: Option, + /// New take-profit trigger price + pub profit_taker_price: Option, + /// New stop-loss trigger price + pub stop_loss_price: Option, + /// New limit price for the take-profit leg + pub profit_taker_submit_price: Option, + /// New limit price for the stop-loss leg + pub stop_loss_submit_price: Option, + /// New time-in-force for the attached order: Day / GTC / GTD + pub attached_time_in_force: Option, + /// New expiry for the attached order as unix timestamp seconds + pub attached_expire_time: Option, + /// New order type for the triggered leg, e.g. "LO" or "MO" + pub attached_activate_order_type: Option, + /// New RTH setting for the triggered leg: "RTH_ONLY" / "ANY_TIME" / "OVERNIGHT" + pub attached_outside_rth: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -180,6 +234,9 @@ pub async fn today_orders( if let Some(symbol) = p.symbol { opts = opts.symbol(symbol); } + if p.is_attached == Some(true) { + opts = opts.is_attached(); + } let (ctx, _) = TradeContext::new(mctx.create_config()); let result = ctx.today_orders(opts).await.map_err(Error::longbridge)?; tool_json(&result) @@ -187,13 +244,14 @@ pub async fn today_orders( pub async fn order_detail( mctx: &crate::tools::McpContext, - p: OrderIdParam, + p: OrderDetailParam, ) -> Result { + let mut opts = GetOrderDetailOptions::new(p.order_id); + if p.is_attached == Some(true) { + opts = opts.is_attached(); + } let (ctx, _) = TradeContext::new(mctx.create_config()); - let result = ctx - .order_detail(p.order_id) - .await - .map_err(Error::longbridge)?; + let result = ctx.order_detail(opts).await.map_err(Error::longbridge)?; tool_json(&result) } @@ -392,6 +450,61 @@ pub async fn submit_order( opts = opts.remark(v.clone()); } + if let Some(ref at) = p.attached_order_type { + use longbridge::trade::{AttachedOrderType, SubmitAttachedParams}; + let at = at.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_order_type: {e}"), None) + })?; + let mut ap = SubmitAttachedParams::new(at); + if let Some(ref v) = p.profit_taker_price { + ap = ap.profit_taker_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params(format!("invalid profit_taker_price: {e}"), None) + })?); + } + if let Some(ref v) = p.stop_loss_price { + ap = ap.stop_loss_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params(format!("invalid stop_loss_price: {e}"), None) + })?); + } + if let Some(ref v) = p.profit_taker_submit_price { + ap = ap.profit_taker_submit_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params( + format!("invalid profit_taker_submit_price: {e}"), + None, + ) + })?); + } + if let Some(ref v) = p.stop_loss_submit_price { + ap = ap.stop_loss_submit_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params(format!("invalid stop_loss_submit_price: {e}"), None) + })?); + } + if let Some(ref v) = p.attached_time_in_force { + ap = ap.time_in_force(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_time_in_force: {e}"), None) + })?); + } + if let Some(ref v) = p.attached_expire_time { + ap = ap.expire_time(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_expire_time: {e}"), None) + })?); + } + if let Some(ref v) = p.attached_activate_order_type { + ap = ap.activate_order_type(v.parse::().map_err(|e| { + McpError::invalid_params( + format!("invalid attached_activate_order_type: {e}"), + None, + ) + })?); + } + if let Some(ref v) = p.attached_outside_rth { + ap = ap.activate_rth(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_outside_rth: {e}"), None) + })?); + } + opts = opts.attached_params(ap); + } + let (ctx, _) = TradeContext::new(mctx.create_config()); let result = ctx.submit_order(opts).await.map_err(Error::longbridge)?; tool_json(&result) @@ -402,7 +515,7 @@ pub async fn replace_order( p: ReplaceOrderParam, ) -> Result { use longbridge::Decimal; - use longbridge::trade::ReplaceOrderOptions; + use longbridge::trade::{OrderType, OutsideRTH, ReplaceOrderOptions, TimeInForceType}; use std::str::FromStr; let quantity = Decimal::from_str(&p.quantity) @@ -436,6 +549,91 @@ pub async fn replace_order( McpError::invalid_params(format!("invalid trailing_percent: {e}"), None) })?); } + + let has_attached = p.cancel_all_attached.is_some() + || p.attached_order_type.is_some() + || p.profit_taker_id.is_some() + || p.stop_loss_id.is_some() + || p.profit_taker_price.is_some() + || p.stop_loss_price.is_some() + || p.profit_taker_submit_price.is_some() + || p.stop_loss_submit_price.is_some() + || p.attached_time_in_force.is_some() + || p.attached_expire_time.is_some() + || p.attached_activate_order_type.is_some() + || p.attached_outside_rth.is_some(); + + if has_attached { + use longbridge::trade::{AttachedOrderType, ReplaceAttachedParams}; + let at = match p.attached_order_type.as_deref() { + Some(s) => s.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_order_type: {e}"), None) + })?, + None => AttachedOrderType::Unknown, + }; + let mut ap = ReplaceAttachedParams::new(at); + if p.cancel_all_attached == Some(true) { + ap = ap.cancel_all_attached(); + } + if let Some(ref v) = p.profit_taker_id { + ap = ap.profit_taker_id(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid profit_taker_id: {e}"), None) + })?); + } + if let Some(ref v) = p.stop_loss_id { + ap = ap.stop_loss_id(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid stop_loss_id: {e}"), None) + })?); + } + if let Some(ref v) = p.profit_taker_price { + ap = ap.profit_taker_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params(format!("invalid profit_taker_price: {e}"), None) + })?); + } + if let Some(ref v) = p.stop_loss_price { + ap = ap.stop_loss_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params(format!("invalid stop_loss_price: {e}"), None) + })?); + } + if let Some(ref v) = p.profit_taker_submit_price { + ap = ap.profit_taker_submit_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params( + format!("invalid profit_taker_submit_price: {e}"), + None, + ) + })?); + } + if let Some(ref v) = p.stop_loss_submit_price { + ap = ap.stop_loss_submit_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params(format!("invalid stop_loss_submit_price: {e}"), None) + })?); + } + if let Some(ref v) = p.attached_time_in_force { + ap = ap.time_in_force(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_time_in_force: {e}"), None) + })?); + } + if let Some(ref v) = p.attached_expire_time { + ap = ap.expire_time(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_expire_time: {e}"), None) + })?); + } + if let Some(ref v) = p.attached_activate_order_type { + ap = ap.activate_order_type(v.parse::().map_err(|e| { + McpError::invalid_params( + format!("invalid attached_activate_order_type: {e}"), + None, + ) + })?); + } + if let Some(ref v) = p.attached_outside_rth { + ap = ap.activate_rth(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_outside_rth: {e}"), None) + })?); + } + opts = opts.attached_params(ap); + } + let (ctx, _) = TradeContext::new(mctx.create_config()); ctx.replace_order(opts).await.map_err(Error::longbridge)?; Ok(tool_result("order replaced".to_string())) From 2425daef721d00e19ef3594b7e794bfab9ebdaac Mon Sep 17 00:00:00 2001 From: Sunli Date: Thu, 18 Jun 2026 08:44:00 +0800 Subject: [PATCH 2/6] feat(trade): expose remaining attached order fields - ReplaceOrderParam: add attached_main_id, attached_quantity, attached_market_price - AttachedOrderDetailResponse: add counter_id, executed_amount, tag, force_only_rth --- src/tools/output/mod.rs | 9 +++++++++ src/tools/trade.rs | 26 +++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/tools/output/mod.rs b/src/tools/output/mod.rs index 8929b46..8835835 100644 --- a/src/tools/output/mod.rs +++ b/src/tools/output/mod.rs @@ -270,6 +270,15 @@ pub struct AttachedOrderDetailResponse { /// RTH setting for the triggered leg. #[serde(skip_serializing_if = "Option::is_none")] pub activate_rth: Option, + /// RTH enforcement flag for the attached order itself. + #[serde(skip_serializing_if = "Option::is_none")] + pub force_only_rth: Option, + /// Order tag (e.g. `Normal`, `LongTerm`). + pub tag: String, + /// Total executed amount (quantity × price). + pub executed_amount: String, + /// Internal counter ID. + pub counter_id: String, } /// Returned by `order_detail`. Single order with full lifecycle metadata. diff --git a/src/tools/trade.rs b/src/tools/trade.rs index 99211d7..889f10a 100644 --- a/src/tools/trade.rs +++ b/src/tools/trade.rs @@ -148,6 +148,12 @@ pub struct ReplaceOrderParam { pub attached_activate_order_type: Option, /// New RTH setting for the triggered leg: "RTH_ONLY" / "ANY_TIME" / "OVERNIGHT" pub attached_outside_rth: Option, + /// Main order ID that owns the attached order (used when modifying an attached order independently) + pub attached_main_id: Option, + /// New quantity for the attached order leg + pub attached_quantity: Option, + /// Market price reference for the attached order leg + pub attached_market_price: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -561,7 +567,10 @@ pub async fn replace_order( || p.attached_time_in_force.is_some() || p.attached_expire_time.is_some() || p.attached_activate_order_type.is_some() - || p.attached_outside_rth.is_some(); + || p.attached_outside_rth.is_some() + || p.attached_main_id.is_some() + || p.attached_quantity.is_some() + || p.attached_market_price.is_some(); if has_attached { use longbridge::trade::{AttachedOrderType, ReplaceAttachedParams}; @@ -631,6 +640,21 @@ pub async fn replace_order( McpError::invalid_params(format!("invalid attached_outside_rth: {e}"), None) })?); } + if let Some(ref v) = p.attached_main_id { + ap = ap.main_id(v.parse::().map_err(|e| { + McpError::invalid_params(format!("invalid attached_main_id: {e}"), None) + })?); + } + if let Some(ref v) = p.attached_quantity { + ap = ap.quantity(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params(format!("invalid attached_quantity: {e}"), None) + })?); + } + if let Some(ref v) = p.attached_market_price { + ap = ap.market_price(Decimal::from_str(v).map_err(|e| { + McpError::invalid_params(format!("invalid attached_market_price: {e}"), None) + })?); + } opts = opts.attached_params(ap); } From d5fed389af178a67077f70e3a3b094130fb72254 Mon Sep 17 00:00:00 2001 From: Sunli Date: Thu, 18 Jun 2026 08:49:28 +0800 Subject: [PATCH 3/6] refactor(trade): unify attached order field naming with attached_ prefix --- src/tools/trade.rs | 58 +++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/tools/trade.rs b/src/tools/trade.rs index 889f10a..1370fa8 100644 --- a/src/tools/trade.rs +++ b/src/tools/trade.rs @@ -91,13 +91,13 @@ pub struct SubmitOrderParam { /// Attach a take-profit/stop-loss order. Values: "ProfitTaker", "StopLoss", "Bracket" pub attached_order_type: Option, /// Take-profit trigger price (required for ProfitTaker / Bracket) - pub profit_taker_price: Option, + pub attached_profit_taker_price: Option, /// Stop-loss trigger price (required for StopLoss / Bracket) - pub stop_loss_price: Option, + pub attached_stop_loss_price: Option, /// Limit price for the take-profit leg (use with LO activate_order_type) - pub profit_taker_submit_price: Option, + pub attached_profit_taker_submit_price: Option, /// Limit price for the stop-loss leg (use with LO activate_order_type) - pub stop_loss_submit_price: Option, + pub attached_stop_loss_submit_price: Option, /// Time-in-force for the attached order: Day / GTC / GTD pub attached_time_in_force: Option, /// Expiry for the attached order as unix timestamp seconds (required when attached_time_in_force is GTD) @@ -125,21 +125,21 @@ pub struct ReplaceOrderParam { /// New trailing percent as decimal e.g. 0.05 = 5% (for TSLPPCT) pub trailing_percent: Option, /// Set to true to cancel all attached orders on this parent order - pub cancel_all_attached: Option, + pub attached_cancel_all: Option, /// Attached order type to set/update: "ProfitTaker", "StopLoss", or "Bracket" pub attached_order_type: Option, /// ID of an existing take-profit attached order to modify - pub profit_taker_id: Option, + pub attached_profit_taker_id: Option, /// ID of an existing stop-loss attached order to modify - pub stop_loss_id: Option, + pub attached_stop_loss_id: Option, /// New take-profit trigger price - pub profit_taker_price: Option, + pub attached_profit_taker_price: Option, /// New stop-loss trigger price - pub stop_loss_price: Option, + pub attached_stop_loss_price: Option, /// New limit price for the take-profit leg - pub profit_taker_submit_price: Option, + pub attached_profit_taker_submit_price: Option, /// New limit price for the stop-loss leg - pub stop_loss_submit_price: Option, + pub attached_stop_loss_submit_price: Option, /// New time-in-force for the attached order: Day / GTC / GTD pub attached_time_in_force: Option, /// New expiry for the attached order as unix timestamp seconds @@ -462,17 +462,17 @@ pub async fn submit_order( McpError::invalid_params(format!("invalid attached_order_type: {e}"), None) })?; let mut ap = SubmitAttachedParams::new(at); - if let Some(ref v) = p.profit_taker_price { + if let Some(ref v) = p.attached_profit_taker_price { ap = ap.profit_taker_price(Decimal::from_str(v).map_err(|e| { McpError::invalid_params(format!("invalid profit_taker_price: {e}"), None) })?); } - if let Some(ref v) = p.stop_loss_price { + if let Some(ref v) = p.attached_stop_loss_price { ap = ap.stop_loss_price(Decimal::from_str(v).map_err(|e| { McpError::invalid_params(format!("invalid stop_loss_price: {e}"), None) })?); } - if let Some(ref v) = p.profit_taker_submit_price { + if let Some(ref v) = p.attached_profit_taker_submit_price { ap = ap.profit_taker_submit_price(Decimal::from_str(v).map_err(|e| { McpError::invalid_params( format!("invalid profit_taker_submit_price: {e}"), @@ -480,7 +480,7 @@ pub async fn submit_order( ) })?); } - if let Some(ref v) = p.stop_loss_submit_price { + if let Some(ref v) = p.attached_stop_loss_submit_price { ap = ap.stop_loss_submit_price(Decimal::from_str(v).map_err(|e| { McpError::invalid_params(format!("invalid stop_loss_submit_price: {e}"), None) })?); @@ -556,14 +556,14 @@ pub async fn replace_order( })?); } - let has_attached = p.cancel_all_attached.is_some() + let has_attached = p.attached_cancel_all.is_some() || p.attached_order_type.is_some() - || p.profit_taker_id.is_some() - || p.stop_loss_id.is_some() - || p.profit_taker_price.is_some() - || p.stop_loss_price.is_some() - || p.profit_taker_submit_price.is_some() - || p.stop_loss_submit_price.is_some() + || p.attached_profit_taker_id.is_some() + || p.attached_stop_loss_id.is_some() + || p.attached_profit_taker_price.is_some() + || p.attached_stop_loss_price.is_some() + || p.attached_profit_taker_submit_price.is_some() + || p.attached_stop_loss_submit_price.is_some() || p.attached_time_in_force.is_some() || p.attached_expire_time.is_some() || p.attached_activate_order_type.is_some() @@ -581,30 +581,30 @@ pub async fn replace_order( None => AttachedOrderType::Unknown, }; let mut ap = ReplaceAttachedParams::new(at); - if p.cancel_all_attached == Some(true) { + if p.attached_cancel_all == Some(true) { ap = ap.cancel_all_attached(); } - if let Some(ref v) = p.profit_taker_id { + if let Some(ref v) = p.attached_profit_taker_id { ap = ap.profit_taker_id(v.parse::().map_err(|e| { McpError::invalid_params(format!("invalid profit_taker_id: {e}"), None) })?); } - if let Some(ref v) = p.stop_loss_id { + if let Some(ref v) = p.attached_stop_loss_id { ap = ap.stop_loss_id(v.parse::().map_err(|e| { McpError::invalid_params(format!("invalid stop_loss_id: {e}"), None) })?); } - if let Some(ref v) = p.profit_taker_price { + if let Some(ref v) = p.attached_profit_taker_price { ap = ap.profit_taker_price(Decimal::from_str(v).map_err(|e| { McpError::invalid_params(format!("invalid profit_taker_price: {e}"), None) })?); } - if let Some(ref v) = p.stop_loss_price { + if let Some(ref v) = p.attached_stop_loss_price { ap = ap.stop_loss_price(Decimal::from_str(v).map_err(|e| { McpError::invalid_params(format!("invalid stop_loss_price: {e}"), None) })?); } - if let Some(ref v) = p.profit_taker_submit_price { + if let Some(ref v) = p.attached_profit_taker_submit_price { ap = ap.profit_taker_submit_price(Decimal::from_str(v).map_err(|e| { McpError::invalid_params( format!("invalid profit_taker_submit_price: {e}"), @@ -612,7 +612,7 @@ pub async fn replace_order( ) })?); } - if let Some(ref v) = p.stop_loss_submit_price { + if let Some(ref v) = p.attached_stop_loss_submit_price { ap = ap.stop_loss_submit_price(Decimal::from_str(v).map_err(|e| { McpError::invalid_params(format!("invalid stop_loss_submit_price: {e}"), None) })?); From 83d4d22be6cac239e73b08f9a13b9d6eb9143917 Mon Sep 17 00:00:00 2001 From: Sunli Date: Thu, 18 Jun 2026 09:16:58 +0800 Subject: [PATCH 4/6] fix(trade): clarify is_attached semantics for today_orders is_attached only applies when order_id is also set; it tells the server the given order_id belongs to an attached order. It does not filter results to show only attached orders. Also expose order_id param. --- src/tools/mod.rs | 2 +- src/tools/trade.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 9bceb93..bdab751 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1196,7 +1196,7 @@ impl Longbridge { idempotent_hint = true, open_world_hint = true ), - description = "Get orders placed today. Returns orders[]{order_id, symbol, side, order_type, status, quantity, price, submitted_at, executed_quantity, executed_price, attached_orders[]}. Pass symbol to filter. Set is_attached=true to list only attached (take-profit/stop-loss) orders." + description = "Get orders placed today. Returns orders[]{order_id, symbol, side, order_type, status, quantity, price, submitted_at, executed_quantity, executed_price, attached_orders[]}. Pass symbol or order_id to filter. When querying by order_id of an attached order (take-profit/stop-loss leg), also set is_attached=true so the server resolves it as an attached order ID rather than a regular order ID." )] async fn today_orders( &self, diff --git a/src/tools/trade.rs b/src/tools/trade.rs index 1370fa8..9fe0751 100644 --- a/src/tools/trade.rs +++ b/src/tools/trade.rs @@ -37,7 +37,12 @@ pub struct AccountBalanceParam { pub struct TodayOrdersParam { /// Filter by symbol, e.g. "700.HK". Omit to return all today's orders. pub symbol: Option, - /// Set to true to list only attached orders (take-profit / stop-loss legs). + /// Filter by a specific order ID. + pub order_id: Option, + /// When set together with order_id, tells the server that order_id is an + /// attached order ID (take-profit / stop-loss leg), not a regular order ID. + /// Has no effect without order_id and does NOT filter results to show only + /// attached orders. pub is_attached: Option, } @@ -240,6 +245,9 @@ pub async fn today_orders( if let Some(symbol) = p.symbol { opts = opts.symbol(symbol); } + if let Some(order_id) = p.order_id { + opts = opts.order_id(order_id); + } if p.is_attached == Some(true) { opts = opts.is_attached(); } From b1faffb7c250a3427888d0a5a8fe53522e96d15e Mon Sep 17 00:00:00 2001 From: Sunli Date: Thu, 18 Jun 2026 09:23:43 +0800 Subject: [PATCH 5/6] docs(trade): clarify is_attached semantics for order_detail and today_orders --- src/tools/mod.rs | 4 ++-- src/tools/trade.rs | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/tools/mod.rs b/src/tools/mod.rs index bdab751..8b4eb85 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1196,7 +1196,7 @@ impl Longbridge { idempotent_hint = true, open_world_hint = true ), - description = "Get orders placed today. Returns orders[]{order_id, symbol, side, order_type, status, quantity, price, submitted_at, executed_quantity, executed_price, attached_orders[]}. Pass symbol or order_id to filter. When querying by order_id of an attached order (take-profit/stop-loss leg), also set is_attached=true so the server resolves it as an attached order ID rather than a regular order ID." + description = "Get orders placed today. Returns orders[]{order_id, symbol, side, order_type, status, quantity, price, submitted_at, executed_quantity, executed_price, attached_orders[]}. Pass symbol to filter by symbol, or order_id to filter by a specific order. To query a take-profit/stop-loss sub-order by its own order_id, set both order_id and is_attached=true; the response returns that sub-order itself as an Order entry. is_attached has no effect without order_id." )] async fn today_orders( &self, @@ -1212,7 +1212,7 @@ impl Longbridge { title = "Order Detail", annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = true), output_schema = schema_for::(), - description = "Get detailed information about a specific order. Returns {order_id, symbol, status, side, order_type, quantity, price, executed_quantity, executed_price, submitted_at, time_in_force, msg, attached_orders[]}. Set is_attached=true to query an attached order (take-profit/stop-loss leg) by its own order_id." + description = "Get detailed information about a specific order. For a parent order, returns full detail including attached_orders[] (take-profit/stop-loss legs). To query a sub-order by its own order_id instead, set is_attached=true; the response returns the sub-order itself, with charge_detail=null." )] async fn order_detail( &self, diff --git a/src/tools/trade.rs b/src/tools/trade.rs index 9fe0751..a40892c 100644 --- a/src/tools/trade.rs +++ b/src/tools/trade.rs @@ -21,9 +21,11 @@ pub struct OrderIdParam { #[derive(Debug, Deserialize, JsonSchema)] pub struct OrderDetailParam { - /// Order ID to look up + /// Order ID to look up. Can be a parent order ID or an attached sub-order ID. pub order_id: String, - /// Set to true when order_id belongs to an attached order (take-profit / stop-loss leg) + /// Set to true when order_id is an attached sub-order ID (take-profit / stop-loss + /// leg). Returns the sub-order itself as an OrderDetail. charge_detail will be + /// null for attached orders. Omit (or false) for regular parent order IDs. pub is_attached: Option, } @@ -37,12 +39,12 @@ pub struct AccountBalanceParam { pub struct TodayOrdersParam { /// Filter by symbol, e.g. "700.HK". Omit to return all today's orders. pub symbol: Option, - /// Filter by a specific order ID. + /// Filter by order ID. Can be a parent order ID or (with is_attached=true) an attached order ID. pub order_id: Option, - /// When set together with order_id, tells the server that order_id is an - /// attached order ID (take-profit / stop-loss leg), not a regular order ID. - /// Has no effect without order_id and does NOT filter results to show only - /// attached orders. + /// Only meaningful when order_id is also set. Tells the server that order_id + /// is an attached sub-order ID (take-profit / stop-loss leg); the response + /// returns that sub-order itself as an Order entry. Has no effect without + /// order_id. pub is_attached: Option, } From 62bbd43693d23ec2ff285c7c947213dcb8f3e253 Mon Sep 17 00:00:00 2001 From: Sunli Date: Thu, 18 Jun 2026 09:43:07 +0800 Subject: [PATCH 6/6] style: cargo +nightly fmt --- src/tools/output/mod.rs | 1 - src/tools/trade.rs | 20 ++++---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/tools/output/mod.rs b/src/tools/output/mod.rs index 8835835..d8c0ea6 100644 --- a/src/tools/output/mod.rs +++ b/src/tools/output/mod.rs @@ -349,4 +349,3 @@ pub struct OrderDetailResponse { /// Attached take-profit / stop-loss legs. Empty when none. pub attached_orders: Vec, } - diff --git a/src/tools/trade.rs b/src/tools/trade.rs index a40892c..87838ee 100644 --- a/src/tools/trade.rs +++ b/src/tools/trade.rs @@ -484,10 +484,7 @@ pub async fn submit_order( } if let Some(ref v) = p.attached_profit_taker_submit_price { ap = ap.profit_taker_submit_price(Decimal::from_str(v).map_err(|e| { - McpError::invalid_params( - format!("invalid profit_taker_submit_price: {e}"), - None, - ) + McpError::invalid_params(format!("invalid profit_taker_submit_price: {e}"), None) })?); } if let Some(ref v) = p.attached_stop_loss_submit_price { @@ -507,10 +504,7 @@ pub async fn submit_order( } if let Some(ref v) = p.attached_activate_order_type { ap = ap.activate_order_type(v.parse::().map_err(|e| { - McpError::invalid_params( - format!("invalid attached_activate_order_type: {e}"), - None, - ) + McpError::invalid_params(format!("invalid attached_activate_order_type: {e}"), None) })?); } if let Some(ref v) = p.attached_outside_rth { @@ -616,10 +610,7 @@ pub async fn replace_order( } if let Some(ref v) = p.attached_profit_taker_submit_price { ap = ap.profit_taker_submit_price(Decimal::from_str(v).map_err(|e| { - McpError::invalid_params( - format!("invalid profit_taker_submit_price: {e}"), - None, - ) + McpError::invalid_params(format!("invalid profit_taker_submit_price: {e}"), None) })?); } if let Some(ref v) = p.attached_stop_loss_submit_price { @@ -639,10 +630,7 @@ pub async fn replace_order( } if let Some(ref v) = p.attached_activate_order_type { ap = ap.activate_order_type(v.parse::().map_err(|e| { - McpError::invalid_params( - format!("invalid attached_activate_order_type: {e}"), - None, - ) + McpError::invalid_params(format!("invalid attached_activate_order_type: {e}"), None) })?); } if let Some(ref v) = p.attached_outside_rth {