diff --git a/AGENTS.md b/AGENTS.md index 23a9d70..b33c468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,26 @@ bitmex order buy XBTUSD 100 --price 50000 --validate -o json bitmex order buy XBTUSD 100 --price 50000 --yes -o json ``` +### Closing positions (Stop-Loss, Take-Profit, OCO) + +`order close` places a 100% position-closing order. It omits `orderQty`, so the order tracks the **entire** position dynamically — BitMEX renders it as `SL (100%)` / `TP (100%)`, and it never needs resyncing as the position size changes. `--side sell` closes a long; `--side buy` closes a short. A trigger price type (`--trigger last|mark|index`) is required whenever `--stop-px` or `--tp-px` is set; `mark` resists wick-outs and is recommended for risk orders. + +```bash +# Stop-Loss 100% (Stop; add --stop-limit-px for StopLimit) +bitmex order close XBTUSD --side sell --stop-px 50000 --trigger mark --yes -o json + +# Take-Profit 100% (MarketIfTouched; add --tp-limit-px for LimitIfTouched) +bitmex order close XBTUSD --side sell --tp-px 60000 --trigger last --yes -o json + +# OCO bracket: SL + TP linked via clOrdLinkID + contingencyType. Filling one cancels the other. +bitmex order close XBTUSD --side sell --stop-px 50000 --tp-px 60000 --trigger mark --yes -o json + +# Immediate market close of the full position +bitmex order close XBTUSD --side sell --yes -o json +``` + +Preview any of these with `--validate` to inspect the exact request body (for OCO, both legs) before submitting. + ### Stream live data to a pipeline ```bash diff --git a/CONTEXT.md b/CONTEXT.md index 05132f1..a374c4c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -186,6 +186,13 @@ bitmex order cancel --order-id --yes -o json bitmex order cancel-all --symbol XBTUSD --yes -o json bitmex order list --symbol XBTUSD -o json +# 100% position-closing orders (orderQty omitted -> tracks full position; SL/TP 100%) +# --side sell closes a long, buy closes a short. --trigger required with --stop-px/--tp-px. +bitmex order close XBTUSD --side sell --stop-px 50000 --trigger mark --yes -o json # Stop-Loss 100% +bitmex order close XBTUSD --side sell --tp-px 60000 --trigger last --yes -o json # Take-Profit 100% +bitmex order close XBTUSD --side sell --stop-px 50000 --tp-px 60000 --trigger mark --yes -o json # OCO bracket (fill one cancels the other) +bitmex order close XBTUSD --side sell --yes -o json # market close 100% + # Positions bitmex position list -o json bitmex position leverage XBTUSD 10 -o json diff --git a/README.md b/README.md index 63b19e8..4160a4e 100644 --- a/README.md +++ b/README.md @@ -175,10 +175,20 @@ bitmex order amend --order-id --price 51000 bitmex order cancel --order-id bitmex order cancel-all [--symbol XBTUSD] bitmex order cancel-after 60000 # Dead Man's Switch (ms) -bitmex order close-position XBTUSD bitmex order list [--symbol XBTUSD] ``` +100% position-closing orders — `orderQty` is omitted, so they track the full +position dynamically (BitMEX renders them as SL/TP 100%). `--side sell` closes a +long, `--side buy` closes a short. `--trigger` is required with `--stop-px`/`--tp-px`. + +```bash +bitmex order close XBTUSD --side sell --stop-px 50000 --trigger mark # Stop-Loss 100% +bitmex order close XBTUSD --side sell --tp-px 60000 --trigger last # Take-Profit 100% +bitmex order close XBTUSD --side sell --stop-px 50000 --tp-px 60000 --trigger mark # OCO bracket +bitmex order close XBTUSD --side sell # market close 100% +``` + ### Positions (auth required) ```bash diff --git a/agents/tool-catalog.json b/agents/tool-catalog.json index f4dadfe..56f3f3e 100644 --- a/agents/tool-catalog.json +++ b/agents/tool-catalog.json @@ -242,6 +242,28 @@ ], "example": "bitmex order sell XBTUSD 100 --price 50000 --strategy Short -o json" }, + { + "name": "order close", + "command": "bitmex order close --side ", + "group": "order", + "description": "[DANGEROUS] Place a 100% position-closing order: Stop-Loss, Take-Profit, an OCO bracket (both, linked), or an immediate market close. orderQty is omitted so the order tracks the full position dynamically (renders as SL/TP 100%). Pass both --stop-px and --tp-px for an OCO bracket. --side sell closes a long, buy closes a short.", + "auth_required": true, + "dangerous": true, + "parameters": [ + {"name": "symbol", "type": "string", "required": true}, + {"name": "side", "type": "string", "required": true, "description": "buy or sell. sell closes a long, buy closes a short."}, + {"name": "stop_px", "type": "number", "required": false, "description": "Stop-Loss trigger price (ordType Stop, or StopLimit with stop_limit_px)."}, + {"name": "tp_px", "type": "number", "required": false, "description": "Take-Profit trigger price (ordType MarketIfTouched, or LimitIfTouched with tp_limit_px)."}, + {"name": "trigger", "type": "string", "required": false, "description": "Trigger price type: last, mark, or index. Required when stop_px or tp_px is set."}, + {"name": "stop_limit_px", "type": "number", "required": false, "description": "Limit price for the Stop-Loss leg (upgrades Stop to StopLimit)."}, + {"name": "tp_limit_px", "type": "number", "required": false, "description": "Limit price for the Take-Profit leg (upgrades MarketIfTouched to LimitIfTouched)."}, + {"name": "link_id", "type": "string", "required": false, "description": "OCO clOrdLinkID. Auto-generated for brackets when omitted."}, + {"name": "strategy", "type": "string", "required": false, "description": "Hedge Mode position strategy: OneWay, Long, or Short. Requires hedge mode enabled on the account."}, + {"name": "cl_ord_id", "type": "string", "required": false}, + {"name": "validate", "type": "boolean", "required": false} + ], + "example": "bitmex order close XBTUSD --side sell --stop-px 50000 --tp-px 60000 --trigger mark -o json" + }, { "name": "order amend", "command": "bitmex order amend", diff --git a/src/cli/commands/order.rs b/src/cli/commands/order.rs index c3da758..cdc8d90 100644 --- a/src/cli/commands/order.rs +++ b/src/cli/commands/order.rs @@ -1,4 +1,4 @@ -/// Order management commands: buy, sell, amend, cancel, cancel-all, cancel-after, close-position. +/// Order management commands: buy, sell, amend, cancel, cancel-all, cancel-after, close. use clap::{Subcommand, ValueEnum}; use serde_json::{json, Value}; @@ -25,6 +25,42 @@ impl OrderStrategy { } } +/// Side of a position-closing order. `sell` closes a long, `buy` closes a short. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum CloseSide { + Buy, + Sell, +} + +impl CloseSide { + /// The exact string the BitMEX API expects for the `side` field. + fn as_api(self) -> &'static str { + match self { + CloseSide::Buy => "Buy", + CloseSide::Sell => "Sell", + } + } +} + +/// Trigger price type for stop / take-profit orders. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum TriggerType { + Last, + Mark, + Index, +} + +impl TriggerType { + /// The exact `execInst` trigger token the BitMEX API expects. + fn as_api(self) -> &'static str { + match self { + TriggerType::Last => "LastPrice", + TriggerType::Mark => "MarkPrice", + TriggerType::Index => "IndexPrice", + } + } +} + use crate::exchange::client::ExchangeClient; use crate::cli::commands::helpers::confirm_destructive; use crate::config::Credentials; @@ -86,6 +122,48 @@ pub(crate) enum OrderCommand { #[arg(long)] validate: bool, }, + /// Place a 100% position-closing order: Stop-Loss, Take-Profit, an OCO + /// bracket (both, linked), or an immediate market close. + /// + /// `orderQty` is always omitted, so the order tracks the *entire* position + /// dynamically (BitMEX renders it as "SL (100%)" / "TP (100%)") — no resync. + /// Pass both `--stop-px` and `--tp-px` to place an OCO bracket where filling + /// one leg cancels the other. + Close { + symbol: String, + /// `sell` closes a long, `buy` closes a short. + #[arg(long, value_enum)] + side: CloseSide, + /// Stop-Loss trigger price. Sets ordType=Stop (or StopLimit with --stop-limit-px). + #[arg(long)] + stop_px: Option, + /// Take-Profit trigger price. Sets ordType=MarketIfTouched (or LimitIfTouched with --tp-limit-px). + #[arg(long)] + tp_px: Option, + /// Trigger price type. Required when --stop-px or --tp-px is set. + #[arg(long, value_enum)] + trigger: Option, + /// Limit price for the Stop-Loss leg (upgrades Stop -> StopLimit). + #[arg(long)] + stop_limit_px: Option, + /// Limit price for the Take-Profit leg (upgrades MarketIfTouched -> LimitIfTouched). + #[arg(long)] + tp_limit_px: Option, + /// OCO link id (clOrdLinkID). Auto-generated for brackets when omitted. + #[arg(long)] + link_id: Option, + /// Position strategy for Hedge Mode: `long` or `short`. + #[arg(long, value_enum)] + strategy: Option, + /// Client order id. Not allowed for OCO brackets (two orders cannot share one clOrdID). + #[arg(long)] + cl_ord_id: Option, + #[arg(long)] + text: Option, + /// Print the request body without submitting. + #[arg(long)] + validate: bool, + }, /// Amend an existing order. Amend { #[arg(long)] @@ -143,7 +221,7 @@ pub(crate) enum OrderCommand { fn build_order_body( symbol: &str, side: &str, - qty: f64, + qty: Option, order_type: &str, price: Option, stop_px: Option, @@ -156,9 +234,11 @@ fn build_order_body( let mut body = json!({ "symbol": symbol, "side": side, - "orderQty": qty, "ordType": order_type, }); + // Omitting orderQty (None) is what makes a Close order track 100% of the + // position dynamically — see `OrderCommand::Close`. + if let Some(q) = qty { body["orderQty"] = json!(q); } if let Some(p) = price { body["price"] = json!(p); } if let Some(p) = stop_px { body["stopPx"] = json!(p); } if let Some(t) = tif { body["timeInForce"] = Value::String(t); } @@ -169,6 +249,97 @@ fn build_order_body( body } +/// Build the `execInst` for a close order: the trigger price type (if any) +/// followed by `Close`. `Close` implies ReduceOnly and, with `orderQty` +/// omitted, closes the entire position. +fn close_exec_inst(trigger: Option) -> String { + match trigger { + Some(t) => format!("{},Close", t.as_api()), + None => "Close".to_string(), + } +} + +/// A resolved close request: the endpoint to POST to and the JSON body. +struct ClosePlan { + path: &'static str, + body: Value, +} + +/// Build the request for a 100% position-closing order. +/// +/// - both `stop_px` and `tp_px` -> OCO bracket of two legs sharing a +/// `clOrdLinkID` with `contingencyType=OneCancelsTheOther`, posted to +/// `/order/bulk`; +/// - only `stop_px` -> single Stop / StopLimit leg; +/// - only `tp_px` -> single MarketIfTouched / LimitIfTouched leg; +/// - neither -> single Market close. +/// +/// Every leg omits `orderQty` so it closes the full position. Pure (the OCO +/// link id is resolved by the caller) so it is unit-testable without I/O. +#[allow(clippy::too_many_arguments)] +fn build_close_plan( + symbol: &str, + side: &str, + stop_px: Option, + tp_px: Option, + exec_inst: &str, + stop_limit_px: Option, + tp_limit_px: Option, + link_id: Option, + strategy: Option, + cl_ord_id: Option, + text: Option, +) -> ClosePlan { + let sl_leg = |cl_ord_id: Option| { + let ord_type = if stop_limit_px.is_some() { "StopLimit" } else { "Stop" }; + build_order_body( + symbol, side, None, ord_type, stop_limit_px, stop_px, None, + Some(exec_inst.to_string()), strategy.clone(), cl_ord_id, text.clone(), + ) + }; + let tp_leg = |cl_ord_id: Option| { + let ord_type = if tp_limit_px.is_some() { "LimitIfTouched" } else { "MarketIfTouched" }; + build_order_body( + symbol, side, None, ord_type, tp_limit_px, tp_px, None, + Some(exec_inst.to_string()), strategy.clone(), cl_ord_id, text.clone(), + ) + }; + + match (stop_px, tp_px) { + (Some(_), Some(_)) => { + // OCO bracket: link the two legs so filling one cancels the other. + let link = link_id.unwrap_or_else(gen_link_id); + let mut sl = sl_leg(None); + let mut tp = tp_leg(None); + for leg in [&mut sl, &mut tp] { + leg["clOrdLinkID"] = Value::String(link.clone()); + leg["contingencyType"] = Value::String("OneCancelsTheOther".to_string()); + } + ClosePlan { path: "/order/bulk", body: json!({ "orders": [sl, tp] }) } + } + (Some(_), None) => ClosePlan { path: "/order", body: sl_leg(cl_ord_id) }, + (None, Some(_)) => ClosePlan { path: "/order", body: tp_leg(cl_ord_id) }, + (None, None) => { + // Immediate market close of the full position. + let body = build_order_body( + symbol, side, None, "Market", None, None, None, + Some(exec_inst.to_string()), strategy, cl_ord_id, text, + ); + ClosePlan { path: "/order", body } + } + } +} + +/// Generate a unique OCO link id. Uses wall-clock nanos — sufficient to keep +/// each bracket's two legs grouped and distinct from other brackets. +fn gen_link_id() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("cli-oco-{nanos}") +} + pub(crate) async fn run( cmd: OrderCommand, client: &impl ExchangeClient, @@ -179,7 +350,7 @@ pub(crate) async fn run( OrderCommand::Buy { symbol, qty, order_type, price, stop_px, tif, exec_inst, strategy, cl_ord_id, text, validate, } => { - let body = build_order_body(&symbol, "Buy", qty, &order_type, price, stop_px, tif, exec_inst, strategy.map(|s| s.as_api().to_string()), cl_ord_id, text); + let body = build_order_body(&symbol, "Buy", Some(qty), &order_type, price, stop_px, tif, exec_inst, strategy.map(|s| s.as_api().to_string()), cl_ord_id, text); if validate { return Ok(CommandOutput::from_json(body)); } @@ -193,7 +364,7 @@ pub(crate) async fn run( OrderCommand::Sell { symbol, qty, order_type, price, stop_px, tif, exec_inst, strategy, cl_ord_id, text, validate, } => { - let body = build_order_body(&symbol, "Sell", qty, &order_type, price, stop_px, tif, exec_inst, strategy.map(|s| s.as_api().to_string()), cl_ord_id, text); + let body = build_order_body(&symbol, "Sell", Some(qty), &order_type, price, stop_px, tif, exec_inst, strategy.map(|s| s.as_api().to_string()), cl_ord_id, text); if validate { return Ok(CommandOutput::from_json(body)); } @@ -204,6 +375,49 @@ pub(crate) async fn run( Ok(CommandOutput::from_json(val)) } + OrderCommand::Close { + symbol, side, stop_px, tp_px, trigger, stop_limit_px, tp_limit_px, + link_id, strategy, cl_ord_id, text, validate, + } => { + // A trigger price type is mandatory whenever a stop/TP trigger is set. + if (stop_px.is_some() || tp_px.is_some()) && trigger.is_none() { + return Err(crate::errors::BitmexError::Validation { + message: "stop/take-profit close orders require --trigger last|mark|index".into(), + }); + } + // OCO brackets produce two orders; a single clOrdID cannot be shared. + if cl_ord_id.is_some() && stop_px.is_some() && tp_px.is_some() { + return Err(crate::errors::BitmexError::Validation { + message: "--cl-ord-id is not supported for OCO brackets (both --stop-px and --tp-px set): two orders cannot share one clOrdID".into(), + }); + } + let exec_inst = close_exec_inst(trigger); + // Pre-resolve the OCO link id so build_close_plan stays pure/testable. + let link_id = match (stop_px, tp_px, link_id) { + (Some(_), Some(_), None) => Some(gen_link_id()), + (_, _, given) => given, + }; + let plan = build_close_plan( + &symbol, side.as_api(), stop_px, tp_px, &exec_inst, + stop_limit_px, tp_limit_px, link_id, + strategy.map(|s| s.as_api().to_string()), cl_ord_id, text, + ); + if validate { + return Ok(CommandOutput::from_json(plan.body)); + } + if !ctx.force { + let desc = match (stop_px, tp_px) { + (Some(s), Some(t)) => format!("OCO bracket on {symbol} (SL @ {s} / TP @ {t})"), + (Some(s), None) => format!("Stop-Loss on {symbol} @ {s}"), + (None, Some(t)) => format!("Take-Profit on {symbol} @ {t}"), + (None, None) => format!("MARKET close on {symbol}"), + }; + confirm_destructive(&format!("Place 100% {desc}?"))?; + } + let val = client.post(plan.path, &plan.body, creds).await?; + Ok(CommandOutput::from_json(val)) + } + OrderCommand::Amend { order_id, cl_ord_id, qty, price, stop_px, text, } => { @@ -277,7 +491,7 @@ mod tests { fn sample_body(strategy: Option) -> Value { build_order_body( - "XBTUSD", "Buy", 100.0, "Limit", Some(50000.0), None, None, None, strategy, None, None, + "XBTUSD", "Buy", Some(100.0), "Limit", Some(50000.0), None, None, None, strategy, None, None, ) } @@ -299,4 +513,116 @@ mod tests { let body = sample_body(None); assert!(body.get("strategy").is_none()); } + + #[test] + fn build_order_body_includes_qty_when_set() { + let body = sample_body(None); + assert_eq!(body["orderQty"], 100.0); + } + + #[test] + fn build_order_body_omits_qty_when_none() { + let body = build_order_body( + "XBTUSD", "Sell", None, "Stop", None, Some(50000.0), None, + Some("MarkPrice,Close".to_string()), None, None, None, + ); + assert!(body.get("orderQty").is_none()); + } + + #[test] + fn close_side_and_trigger_map_to_exact_api_strings() { + assert_eq!(CloseSide::Buy.as_api(), "Buy"); + assert_eq!(CloseSide::Sell.as_api(), "Sell"); + assert_eq!(TriggerType::Last.as_api(), "LastPrice"); + assert_eq!(TriggerType::Mark.as_api(), "MarkPrice"); + assert_eq!(TriggerType::Index.as_api(), "IndexPrice"); + } + + #[test] + fn close_exec_inst_prefixes_trigger_then_close() { + assert_eq!(close_exec_inst(Some(TriggerType::Mark)), "MarkPrice,Close"); + assert_eq!(close_exec_inst(Some(TriggerType::Last)), "LastPrice,Close"); + assert_eq!(close_exec_inst(None), "Close"); + } + + /// Helper to build a close plan with the common args defaulted. + fn close_plan( + stop_px: Option, + tp_px: Option, + stop_limit_px: Option, + tp_limit_px: Option, + ) -> ClosePlan { + build_close_plan( + "XBTUSD", "Sell", stop_px, tp_px, "MarkPrice,Close", + stop_limit_px, tp_limit_px, Some("link-1".to_string()), None, None, None, + ) + } + + #[test] + fn close_plan_single_stop_loss() { + let plan = close_plan(Some(50000.0), None, None, None); + assert_eq!(plan.path, "/order"); + assert_eq!(plan.body["ordType"], "Stop"); + assert_eq!(plan.body["stopPx"], 50000.0); + assert_eq!(plan.body["execInst"], "MarkPrice,Close"); + assert_eq!(plan.body["side"], "Sell"); + assert!(plan.body.get("orderQty").is_none()); + assert!(plan.body.get("price").is_none()); + } + + #[test] + fn close_plan_stop_limit_when_limit_px_set() { + let plan = close_plan(Some(50000.0), None, Some(49900.0), None); + assert_eq!(plan.body["ordType"], "StopLimit"); + assert_eq!(plan.body["stopPx"], 50000.0); + assert_eq!(plan.body["price"], 49900.0); + } + + #[test] + fn close_plan_single_take_profit() { + let plan = close_plan(None, Some(60000.0), None, None); + assert_eq!(plan.path, "/order"); + assert_eq!(plan.body["ordType"], "MarketIfTouched"); + assert_eq!(plan.body["stopPx"], 60000.0); + assert!(plan.body.get("orderQty").is_none()); + } + + #[test] + fn close_plan_limit_if_touched_when_limit_px_set() { + let plan = close_plan(None, Some(60000.0), None, Some(60100.0)); + assert_eq!(plan.body["ordType"], "LimitIfTouched"); + assert_eq!(plan.body["price"], 60100.0); + } + + #[test] + fn close_plan_market_close_when_no_trigger() { + let plan = build_close_plan( + "XBTUSD", "Sell", None, None, "Close", None, None, None, None, None, None, + ); + assert_eq!(plan.path, "/order"); + assert_eq!(plan.body["ordType"], "Market"); + assert_eq!(plan.body["execInst"], "Close"); + assert!(plan.body.get("orderQty").is_none()); + assert!(plan.body.get("stopPx").is_none()); + } + + #[test] + fn close_plan_oco_bracket_links_both_legs() { + let plan = close_plan(Some(50000.0), Some(60000.0), None, None); + assert_eq!(plan.path, "/order/bulk"); + let orders = plan.body["orders"].as_array().expect("orders array"); + assert_eq!(orders.len(), 2); + + let (sl, tp) = (&orders[0], &orders[1]); + assert_eq!(sl["ordType"], "Stop"); + assert_eq!(sl["stopPx"], 50000.0); + assert_eq!(tp["ordType"], "MarketIfTouched"); + assert_eq!(tp["stopPx"], 60000.0); + + for leg in orders { + assert_eq!(leg["clOrdLinkID"], "link-1"); + assert_eq!(leg["contingencyType"], "OneCancelsTheOther"); + assert!(leg.get("orderQty").is_none()); + } + } } diff --git a/tests/integration/cli_tests.rs b/tests/integration/cli_tests.rs index 222bafa..ee5a643 100644 --- a/tests/integration/cli_tests.rs +++ b/tests/integration/cli_tests.rs @@ -46,6 +46,7 @@ fn order_help_shows_subcommands() { .success() .stdout(predicate::str::contains("buy")) .stdout(predicate::str::contains("sell")) + .stdout(predicate::str::contains("close")) .stdout(predicate::str::contains("cancel")); } @@ -165,3 +166,35 @@ fn order_buy_help_shows_strategy_flag() { .success() .stdout(predicate::str::contains("--strategy")); } + +#[test] +fn order_close_help_shows_flags() { + bitmex() + .args(["order", "close", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--side")) + .stdout(predicate::str::contains("--stop-px")) + .stdout(predicate::str::contains("--tp-px")) + .stdout(predicate::str::contains("--trigger")); +} + +#[test] +fn order_close_rejects_cl_ord_id_for_oco_bracket() { + // cl_ord_id cannot be assigned to an OCO bracket (two orders can't share one clOrdID). + // The guard fires before any network call, so fake credentials are sufficient. + bitmex() + .args([ + "--api-key", "fake", + "--api-secret", "fake", + "order", "close", "XBTUSD", + "--side", "sell", + "--stop-px", "50000", + "--tp-px", "60000", + "--trigger", "mark", + "--cl-ord-id", "my-id", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("--cl-ord-id is not supported for OCO brackets")); +}