From 235ca843856fb3fcbab5538d0e44afd3d5ef8f12 Mon Sep 17 00:00:00 2001 From: Balakrishnan Vallanezhath Krishnakumar Date: Wed, 24 Jun 2026 13:56:11 +0800 Subject: [PATCH] feat: add Hedge Mode (MultiWay position mode) support Lets users hold independent Long and Short positions on the same contract. - account position-mode (alias: position mode) -> POST /user/positionMode - order buy/sell --strategy -> order `strategy` field - show `strategy` column in `position list` and `order list` - register the position-mode tool in the MCP tool-catalog (dangerous) - document Hedge Mode in README/AGENTS/CONTEXT Verified: cargo build + cargo test green (98 unit + 16 integration); clippy clean on changed files. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 16 +++++ CONTEXT.md | 10 ++- README.md | 27 +++++++- agents/tool-catalog.json | 19 ++++- src/cli/commands/account.rs | 20 ++++++ src/cli/commands/mod.rs | 1 + src/cli/commands/order.rs | 76 ++++++++++++++++++-- src/cli/commands/position.rs | 20 +++++- src/cli/commands/position_mode.rs | 111 ++++++++++++++++++++++++++++++ src/lib.rs | 2 +- src/mcp/registry.rs | 17 +++++ tests/integration/cli_tests.rs | 32 ++++++++- 12 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 src/cli/commands/position_mode.rs diff --git a/AGENTS.md b/AGENTS.md index 221700b..23a9d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,6 +273,22 @@ bitmex execution trade-history --count 20 -o json bitmex wallet balance -o json ``` +### Hedge Mode (MultiWay) + +Hedge Mode is an account-level setting that lets you hold independent Long and Short positions on the same contract (uncapped derivatives only). Switching is rejected while the account has open orders or isolated-margin positions. + +```bash +bitmex account position-mode multiway --yes -o json # enable (alias: hedge) +bitmex account position-mode oneway --yes -o json # disable (netting) +``` + +Once enabled, tag each order leg with `--strategy Long` or `--strategy Short`. Each position carries a `strategy` field (`Long`/`Short`/`OneWay`); the account carries `positionMode`. + +```bash +bitmex order buy XBTUSD 100 --price 50000 --strategy Long --yes -o json +bitmex order sell XBTUSD 100 --price 52000 --strategy Short --yes -o json +``` + ### Tick size and lot size alignment Every instrument enforces a minimum price increment (`tickSize`) and minimum quantity increment (`lotSize`). Submitting a price or quantity that isn't a multiple of these will return a `400 Invalid price` or `400 Invalid quantity` error. diff --git a/CONTEXT.md b/CONTEXT.md index 7a7ca23..05132f1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -82,8 +82,8 @@ Use `bitmex market instrument --active -o json` to list all tradeable instrument | Group | Auth | Description | |-------|------|-------------| | `market` | No | Instruments, quotes, trades, orderbook, funding, liquidations, stats | -| `order` | Yes | Place, amend, cancel orders | -| `position` | Yes | List positions, set leverage, manage margin | +| `order` | Yes | Place, amend, cancel orders (`--strategy Long/Short` for Hedge Mode legs) | +| `position` | Yes | List positions, set leverage, manage margin, switch Hedge Mode | | `execution` | Yes | Fill history, trade history with PnL | | `wallet` | Yes | Balances, deposits, withdrawals, transfers | | `staking` | Yes | Staking positions and instruments | @@ -190,6 +190,12 @@ bitmex order list --symbol XBTUSD -o json bitmex position list -o json bitmex position leverage XBTUSD 10 -o json +# Hedge Mode (MultiWay): independent Long + Short on one contract +bitmex account position-mode multiway --yes -o json # enable (alias: hedge) +bitmex account position-mode oneway --yes -o json # disable (netting) +bitmex order buy XBTUSD 100 --price 50000 --strategy Long --yes -o json +bitmex order sell XBTUSD 100 --price 52000 --strategy Short --yes -o json + # WebSocket (NDJSON to stdout) bitmex ws trade:XBTUSD orderBookL2_25:XBTUSD bitmex ws --auth position order execution diff --git a/README.md b/README.md index 3686c63..63b19e8 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ bitmex announce urgent ```bash bitmex order buy XBTUSD 100 --price 50000 [--order-type Limit] [--validate] bitmex order sell XBTUSD 100 --price 52000 [--tif GoodTillCancel] +bitmex order buy XBTUSD 100 --price 50000 --strategy Long # Hedge Mode leg +bitmex order sell XBTUSD 100 --price 52000 --strategy Short # Hedge Mode leg bitmex order amend --order-id --price 51000 bitmex order cancel --order-id bitmex order cancel-all [--symbol XBTUSD] @@ -180,7 +182,7 @@ bitmex order list [--symbol XBTUSD] ### Positions (auth required) ```bash -bitmex position list [--symbol XBTUSD] +bitmex position list [--symbol XBTUSD] # table shows the `strategy` column bitmex position leverage XBTUSD 10 bitmex position cross-leverage XBTUSD 5 bitmex position isolate XBTUSD --enabled @@ -188,6 +190,29 @@ bitmex position risk-limit XBTUSD 20000000000 bitmex position transfer-margin XBTUSD 100000 # satoshis ``` +### Hedge Mode (MultiWay) (auth required) + +Hedge Mode lets you hold independent Long and Short positions on the same +contract instead of netting into one. It is an account-level setting and applies +to uncapped derivatives only. Switching is rejected while the account has open +orders or isolated-margin positions. + +```bash +bitmex account position-mode multiway # enable Hedge Mode (alias: hedge) +bitmex account position-mode oneway # back to One-Way (netting) +bitmex position mode multiway # alias for `account position-mode` +``` + +Once enabled, tag each order leg with its strategy: + +```bash +bitmex order buy XBTUSD 100 --price 50000 --strategy Long +bitmex order sell XBTUSD 100 --price 52000 --strategy Short +``` + +See [What is Hedge Mode](https://support.bitmex.com/hc/en-gb/articles/32985620308381-What-is-Hedge-Mode) +and [How traders use Hedge Mode](https://support.bitmex.com/hc/en-gb/articles/36691242524317-How-can-traders-use-Hedge-Mode-to-improve-their-trading-and-strategies). + ### Execution history (auth required) ```bash diff --git a/agents/tool-catalog.json b/agents/tool-catalog.json index 0ba3c9b..f4dadfe 100644 --- a/agents/tool-catalog.json +++ b/agents/tool-catalog.json @@ -220,9 +220,10 @@ {"name": "price", "type": "number", "required": false}, {"name": "tif", "type": "string", "required": false}, {"name": "exec_inst", "type": "string", "required": false}, + {"name": "strategy", "type": "string", "required": false, "description": "Hedge Mode position strategy: OneWay, Long, or Short. Requires hedge mode enabled on the account."}, {"name": "validate", "type": "boolean", "required": false} ], - "example": "bitmex order buy XBTUSD 100 --price 50000 -o json" + "example": "bitmex order buy XBTUSD 100 --price 50000 --strategy Long -o json" }, { "name": "order sell", @@ -236,9 +237,10 @@ {"name": "qty", "type": "number", "required": true}, {"name": "order_type", "type": "string", "required": false, "default": "Limit"}, {"name": "price", "type": "number", "required": false}, + {"name": "strategy", "type": "string", "required": false, "description": "Hedge Mode position strategy: OneWay, Long, or Short. Requires hedge mode enabled on the account."}, {"name": "validate", "type": "boolean", "required": false} ], - "example": "bitmex order sell XBTUSD 100 --price 50000 -o json" + "example": "bitmex order sell XBTUSD 100 --price 50000 --strategy Short -o json" }, { "name": "order amend", @@ -719,6 +721,19 @@ ], "example": "bitmex account margining-mode REGULAR_MARGIN -o json" }, + { + "name": "account position-mode", + "command": "bitmex account position-mode ", + "group": "account", + "description": "[DANGEROUS] Switch position mode: oneway (netting) or multiway (Hedge Mode). Rejected if the account has open orders or isolated-margin positions.", + "auth_required": true, + "dangerous": true, + "parameters": [ + {"name": "mode", "type": "string", "required": true, "description": "oneway or multiway (alias hedge)."}, + {"name": "target_account_id", "type": "integer", "required": false, "description": "Paired/sub-account to switch."} + ], + "example": "bitmex account position-mode multiway -o json" + }, { "name": "account quote-fill-ratio", "command": "bitmex account quote-fill-ratio", diff --git a/src/cli/commands/account.rs b/src/cli/commands/account.rs index 6715e20..9c3756a 100644 --- a/src/cli/commands/account.rs +++ b/src/cli/commands/account.rs @@ -4,9 +4,11 @@ use serde_json::{json, Value}; use crate::exchange::client::ExchangeClient; use crate::cli::commands::helpers::build_query; +use crate::cli::commands::position_mode::{self, PositionModeArg}; use crate::config::Credentials; use crate::errors::Result; use crate::cli::output::CommandOutput; +use crate::AppContext; #[derive(Debug, Subcommand)] pub(crate) enum AccountCommand { @@ -52,12 +54,26 @@ pub(crate) enum AccountCommand { #[arg(long)] currency: Option, }, + /// Switch position mode: oneway (netting) or multiway (Hedge Mode). + /// + /// Hedge Mode lets you hold independent Long and Short positions on the + /// same contract. The switch is rejected if the account has open orders or + /// isolated-margin positions. + PositionMode { + /// Target mode: `oneway` or `multiway` (alias `hedge`). + #[arg(value_enum)] + mode: PositionModeArg, + /// Paired/sub-account to switch (defaults to the calling account). + #[arg(long)] + target_account_id: Option, + }, } pub(crate) async fn run( cmd: AccountCommand, client: &impl ExchangeClient, creds: &Credentials, + ctx: &AppContext, ) -> Result { match cmd { AccountCommand::Me => { @@ -122,5 +138,9 @@ pub(crate) async fn run( let val = client.post("/user/marginingMode", &body, creds).await?; Ok(CommandOutput::from_json(val)) } + AccountCommand::PositionMode { + mode, + target_account_id, + } => position_mode::run(client, creds, ctx, mode, target_account_id).await, } } diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 362ae80..3382036 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod notifications; pub(crate) mod order; pub(crate) mod porl; pub(crate) mod position; +pub(crate) mod position_mode; pub(crate) mod referral; pub(crate) mod staking; pub(crate) mod subaccount; diff --git a/src/cli/commands/order.rs b/src/cli/commands/order.rs index 44c99b9..c3da758 100644 --- a/src/cli/commands/order.rs +++ b/src/cli/commands/order.rs @@ -1,7 +1,30 @@ /// Order management commands: buy, sell, amend, cancel, cancel-all, cancel-after, close-position. -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use serde_json::{json, Value}; +/// Position strategy for an order leg under Hedge (MultiWay) mode. +/// +/// In One-Way mode leave this unset (or `oneway`); in Hedge Mode pass `long` +/// or `short` to target the corresponding position bucket. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum OrderStrategy { + #[value(name = "oneway", alias = "one-way")] + OneWay, + Long, + Short, +} + +impl OrderStrategy { + /// The exact string the BitMEX API expects for the `strategy` field. + fn as_api(self) -> &'static str { + match self { + OrderStrategy::OneWay => "OneWay", + OrderStrategy::Long => "Long", + OrderStrategy::Short => "Short", + } + } +} + use crate::exchange::client::ExchangeClient; use crate::cli::commands::helpers::confirm_destructive; use crate::config::Credentials; @@ -26,6 +49,10 @@ pub(crate) enum OrderCommand { /// Execution instructions (e.g. ParticipateDoNotInitiate, ReduceOnly). #[arg(long)] exec_inst: Option, + /// Position strategy for Hedge Mode: `long` or `short`. Requires hedge + /// mode enabled; omit (or `oneway`) for One-Way accounts. + #[arg(long, value_enum)] + strategy: Option, #[arg(long)] cl_ord_id: Option, #[arg(long)] @@ -48,6 +75,10 @@ pub(crate) enum OrderCommand { tif: Option, #[arg(long)] exec_inst: Option, + /// Position strategy for Hedge Mode: `long` or `short`. Requires hedge + /// mode enabled; omit (or `oneway`) for One-Way accounts. + #[arg(long, value_enum)] + strategy: Option, #[arg(long)] cl_ord_id: Option, #[arg(long)] @@ -108,6 +139,7 @@ pub(crate) enum OrderCommand { }, } +#[allow(clippy::too_many_arguments)] fn build_order_body( symbol: &str, side: &str, @@ -117,6 +149,7 @@ fn build_order_body( stop_px: Option, tif: Option, exec_inst: Option, + strategy: Option, cl_ord_id: Option, text: Option, ) -> Value { @@ -130,6 +163,7 @@ fn build_order_body( if let Some(p) = stop_px { body["stopPx"] = json!(p); } if let Some(t) = tif { body["timeInForce"] = Value::String(t); } if let Some(e) = exec_inst { body["execInst"] = Value::String(e); } + if let Some(s) = strategy { body["strategy"] = Value::String(s); } if let Some(c) = cl_ord_id { body["clOrdID"] = Value::String(c); } body["text"] = Value::String(text.unwrap_or_else(|| "Submitted via CLI.".to_string())); body @@ -143,9 +177,9 @@ pub(crate) async fn run( ) -> Result { match cmd { OrderCommand::Buy { - symbol, qty, order_type, price, stop_px, tif, exec_inst, cl_ord_id, text, validate, + 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, cl_ord_id, text); + 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); if validate { return Ok(CommandOutput::from_json(body)); } @@ -157,9 +191,9 @@ pub(crate) async fn run( } OrderCommand::Sell { - symbol, qty, order_type, price, stop_px, tif, exec_inst, cl_ord_id, text, validate, + 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, cl_ord_id, text); + 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); if validate { return Ok(CommandOutput::from_json(body)); } @@ -229,10 +263,40 @@ pub(crate) async fn run( Ok(CommandOutput::builder() .data(val) .columns(&[ - "orderID", "symbol", "side", "orderQty", "price", + "orderID", "symbol", "side", "strategy", "orderQty", "price", "ordType", "ordStatus", "avgPx", "leavesQty", "timestamp", ]) .build()) } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_body(strategy: Option) -> Value { + build_order_body( + "XBTUSD", "Buy", 100.0, "Limit", Some(50000.0), None, None, None, strategy, None, None, + ) + } + + #[test] + fn order_strategy_maps_to_exact_api_strings() { + assert_eq!(OrderStrategy::OneWay.as_api(), "OneWay"); + assert_eq!(OrderStrategy::Long.as_api(), "Long"); + assert_eq!(OrderStrategy::Short.as_api(), "Short"); + } + + #[test] + fn build_order_body_includes_strategy_when_set() { + let body = sample_body(Some(OrderStrategy::Long.as_api().to_string())); + assert_eq!(body["strategy"], "Long"); + } + + #[test] + fn build_order_body_omits_strategy_when_unset() { + let body = sample_body(None); + assert!(body.get("strategy").is_none()); + } +} diff --git a/src/cli/commands/position.rs b/src/cli/commands/position.rs index 004e0ef..86ed147 100644 --- a/src/cli/commands/position.rs +++ b/src/cli/commands/position.rs @@ -3,6 +3,7 @@ use serde_json::json; use crate::exchange::client::ExchangeClient; use crate::cli::commands::helpers::{build_query, confirm_destructive}; +use crate::cli::commands::position_mode::{self, PositionModeArg}; use crate::config::Credentials; use crate::errors::Result; use crate::cli::output::CommandOutput; @@ -47,6 +48,18 @@ pub(crate) enum PositionCommand { /// Amount in satoshis. amount: i64, }, + /// Switch position mode: oneway (netting) or multiway (Hedge Mode). + /// + /// Alias for `account position-mode`. Hedge Mode lets you hold independent + /// Long and Short positions on the same contract. + Mode { + /// Target mode: `oneway` or `multiway` (alias `hedge`). + #[arg(value_enum)] + mode: PositionModeArg, + /// Paired/sub-account to switch (defaults to the calling account). + #[arg(long)] + target_account_id: Option, + }, } pub(crate) async fn run( @@ -70,7 +83,7 @@ pub(crate) async fn run( Ok(CommandOutput::builder() .data(val) .columns(&[ - "symbol", "currentQty", "avgEntryPrice", "markPrice", + "symbol", "strategy", "currentQty", "avgEntryPrice", "markPrice", "liquidationPrice", "unrealisedPnl", "realisedPnl", "leverage", "crossMargin", "marginCallPrice", ]) @@ -129,5 +142,10 @@ pub(crate) async fn run( .await?; Ok(CommandOutput::from_json(val)) } + + PositionCommand::Mode { + mode, + target_account_id, + } => position_mode::run(client, creds, ctx, mode, target_account_id).await, } } diff --git a/src/cli/commands/position_mode.rs b/src/cli/commands/position_mode.rs new file mode 100644 index 0000000..a11c57f --- /dev/null +++ b/src/cli/commands/position_mode.rs @@ -0,0 +1,111 @@ +/// Hedge Mode (MultiWay position mode) switching, shared by the canonical +/// `account position-mode` command and the `position mode` alias. +/// +/// Hedge Mode is an account-level setting: when enabled (MultiWay), derivative +/// positions split into independent Long and Short buckets on the same +/// instrument instead of netting into a single One-Way position. The exchange +/// rejects the switch if the account has open orders or isolated-margin +/// positions, and only uncapped derivatives support per-leg strategies. +use clap::ValueEnum; +use serde_json::{json, Value}; + +use crate::cli::commands::helpers::confirm_destructive; +use crate::cli::output::CommandOutput; +use crate::config::Credentials; +use crate::errors::Result; +use crate::exchange::client::ExchangeClient; +use crate::AppContext; + +/// Account position mode: One-Way (netting) vs MultiWay (Hedge Mode). +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum PositionModeArg { + /// One-Way / netting: a single net position per contract. + #[value(name = "oneway", alias = "one-way")] + OneWay, + /// MultiWay / Hedge Mode: independent Long and Short positions per contract. + #[value(name = "multiway", aliases = ["multi-way", "hedge"])] + MultiWay, +} + +impl PositionModeArg { + fn is_multiway(self) -> bool { + matches!(self, PositionModeArg::MultiWay) + } +} + +/// Build the `POST /user/positionMode` request body. +/// +/// MultiWay sends `{"positionMode":"MultiWay"}`; One-Way omits the field +/// entirely (the API treats an absent `positionMode` as One-Way). An optional +/// `targetAccountId` targets a paired/sub-account. +pub(crate) fn build_position_mode_body( + mode: PositionModeArg, + target_account_id: Option, +) -> Value { + let mut body = json!({}); + if mode.is_multiway() { + body["positionMode"] = Value::String("MultiWay".to_string()); + } + if let Some(id) = target_account_id { + body["targetAccountId"] = json!(id); + } + body +} + +/// Switch the account between One-Way and Hedge (MultiWay) position mode. +pub(crate) async fn run( + client: &impl ExchangeClient, + creds: &Credentials, + ctx: &AppContext, + mode: PositionModeArg, + target_account_id: Option, +) -> Result { + if !ctx.force { + let label = if mode.is_multiway() { + "MultiWay (Hedge Mode)" + } else { + "One-Way" + }; + confirm_destructive(&format!( + "Switch account position mode to {label}? This requires no open orders \ + and no isolated-margin positions." + ))?; + } + let body = build_position_mode_body(mode, target_account_id); + let val = client.post("/user/positionMode", &body, creds).await?; + Ok(CommandOutput::from_json(val)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multiway_body_sets_position_mode() { + let body = build_position_mode_body(PositionModeArg::MultiWay, None); + assert_eq!(body["positionMode"], "MultiWay"); + assert!(body.get("targetAccountId").is_none()); + } + + #[test] + fn oneway_body_omits_position_mode() { + let body = build_position_mode_body(PositionModeArg::OneWay, None); + // One-Way is signalled by the absence of the field, per the API contract. + assert!(body.get("positionMode").is_none()); + assert_eq!(body, json!({})); + } + + #[test] + fn target_account_id_threaded() { + let body = build_position_mode_body(PositionModeArg::MultiWay, Some(12345)); + assert_eq!(body["positionMode"], "MultiWay"); + assert_eq!(body["targetAccountId"], 12345); + } + + #[test] + fn oneway_body_keeps_target_account_id() { + let body = build_position_mode_body(PositionModeArg::OneWay, Some(777)); + assert!(body.get("positionMode").is_none()); + assert_eq!(body["targetAccountId"], 777); + } +} diff --git a/src/lib.rs b/src/lib.rs index b912109..23984b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -331,7 +331,7 @@ pub(crate) async fn execute_command(ctx: &AppContext, command: Command) -> Resul ctx.api_secret.as_deref(), ctx.no_keychain, )?; - account::run(cmd, &client, &creds).await + account::run(cmd, &client, &creds, ctx).await } Command::Subaccount { cmd } => { diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index 58fb684..ddccbfb 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -425,6 +425,23 @@ mod tests { assert!(orderbook.is_some(), "Should find bitmex.market.orderbook tool"); } + #[test] + fn account_position_mode_registered_and_dangerous() { + let registry = + ToolRegistry::build(&["account".into(), "position".into()]).unwrap(); + let entry = registry + .get_by_name("bitmex.account.position.mode") + .expect("account position-mode should register as an MCP tool"); + assert!(entry.dangerous, "position-mode must be marked dangerous"); + // The `position mode` alias is intentionally CLI-only (no catalog entry), + // so it must not surface as a second, duplicate MCP tool even when the + // position group is loaded. + assert!( + registry.get_by_name("bitmex.position.mode").is_none(), + "position mode alias should not be an MCP tool" + ); + } + #[test] fn auth_excluded_commands() { let registry = ToolRegistry::build(&["auth".into()]).unwrap(); diff --git a/tests/integration/cli_tests.rs b/tests/integration/cli_tests.rs index 4920c4e..222bafa 100644 --- a/tests/integration/cli_tests.rs +++ b/tests/integration/cli_tests.rs @@ -133,5 +133,35 @@ fn position_help_shows_subcommands() { .args(["position", "--help"]) .assert() .success() - .stdout(predicate::str::contains("list")); + .stdout(predicate::str::contains("list")) + .stdout(predicate::str::contains("mode")); +} + +#[test] +fn account_help_shows_position_mode() { + bitmex() + .args(["account", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("position-mode")); +} + +#[test] +fn position_mode_help_lists_modes() { + bitmex() + .args(["account", "position-mode", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("oneway")) + .stdout(predicate::str::contains("multiway")) + .stdout(predicate::str::contains("hedge")); +} + +#[test] +fn order_buy_help_shows_strategy_flag() { + bitmex() + .args(["order", "buy", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--strategy")); }