use std::sync::Arc;
use rmcp::model::Tool;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::schema::{parse_input as parse, schema_for};
use super::{ToolClass, ToolEntry, ToolHandlerFn, ToolRegistry};
use crate::context::AdapterContext;
use crate::error::AdapterError;
pub fn register(registry: &mut ToolRegistry) {
registry.insert(place_order_tool());
registry.insert(edit_order_tool());
registry.insert(cancel_order_tool());
registry.insert(cancel_all_by_currency_tool());
registry.insert(cancel_all_by_instrument_tool());
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Side {
Buy,
Sell,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PlaceOrderType {
Limit,
Market,
StopLimit,
StopMarket,
TakeLimit,
TakeMarket,
MarketLimit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PlaceTimeInForce {
GoodTilCancelled,
GoodTilDay,
FillOrKill,
ImmediateOrCancel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PlaceTrigger {
IndexPrice,
MarkPrice,
LastPrice,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct PlaceOrderInput {
pub instrument_name: String,
pub side: Side,
pub amount: f64,
#[serde(rename = "type")]
pub order_type: PlaceOrderType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_in_force: Option<PlaceTimeInForce>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger: Option<PlaceTrigger>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reject_post_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reduce_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mmp: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_until: Option<u64>,
}
fn place_order_tool() -> ToolEntry {
let schema = schema_for::<PlaceOrderInput>();
let descriptor = Tool::new(
"place_order",
"Place a buy or sell order. This sends a real order to the configured Deribit endpoint.",
schema,
);
let handler: ToolHandlerFn = Arc::new(|ctx, input| Box::pin(handle_place_order(ctx, input)));
ToolEntry {
descriptor,
class: ToolClass::Trading,
handler,
}
}
async fn handle_place_order(ctx: &AdapterContext, input: Value) -> Result<Value, AdapterError> {
let input: PlaceOrderInput = parse(input)?;
validate_place_order(&input)?;
enforce_size_cap(ctx, &input.instrument_name, input.amount, input.price).await?;
match ctx.config.order_transport {
crate::config::OrderTransport::Http => place_order_via_http(ctx, input).await,
#[cfg(feature = "fix")]
crate::config::OrderTransport::Fix => place_order_via_fix(ctx, input).await,
#[cfg(not(feature = "fix"))]
crate::config::OrderTransport::Fix => Err(AdapterError::not_enabled(
"place_order",
"build with --features=fix",
)),
}
}
async fn place_order_via_http(
ctx: &AdapterContext,
input: PlaceOrderInput,
) -> Result<Value, AdapterError> {
let (side, request) = build_order_request(input)?;
let result = match side {
Side::Buy => ctx.http.buy_order(request).await?,
Side::Sell => ctx.http.sell_order(request).await?,
};
Ok(serde_json::to_value(&result)?)
}
#[cfg(feature = "fix")]
async fn place_order_via_fix(
ctx: &AdapterContext,
input: PlaceOrderInput,
) -> Result<Value, AdapterError> {
let request = build_fix_new_order_request(input)?;
let fix = ctx.ensure_fix().await?;
let order_id = {
let guard = fix.lock().await;
guard.send_order(request.clone()).await?
};
Ok(synthesize_fix_order_response(&order_id, &request))
}
#[cfg(feature = "fix")]
fn build_fix_new_order_request(
input: PlaceOrderInput,
) -> Result<deribit_fix::model::request::NewOrderRequest, AdapterError> {
use deribit_fix::model::request::{
NewOrderRequest, OrderSide as FixSide, OrderType as FixOrderType, TimeInForce as FixTif,
TriggerType as FixTrigger,
};
if input.mmp.is_some() {
return Err(AdapterError::Validation {
field: "mmp".to_string(),
message: "`mmp` is not supported by the FIX transport (deribit-fix 0.3)".to_string(),
});
}
let order_type = match input.order_type {
PlaceOrderType::Limit => FixOrderType::Limit,
PlaceOrderType::Market => FixOrderType::Market,
PlaceOrderType::StopLimit => FixOrderType::StopLimit,
PlaceOrderType::StopMarket => FixOrderType::StopMarket,
PlaceOrderType::TakeLimit => FixOrderType::TakeLimit,
PlaceOrderType::TakeMarket => FixOrderType::TakeMarket,
PlaceOrderType::MarketLimit => FixOrderType::MarketLimit,
};
let side = match input.side {
Side::Buy => FixSide::Buy,
Side::Sell => FixSide::Sell,
};
let time_in_force = match input.time_in_force {
Some(PlaceTimeInForce::GoodTilCancelled) | None => FixTif::GoodTilCancelled,
Some(PlaceTimeInForce::GoodTilDay) => FixTif::GoodTilDay,
Some(PlaceTimeInForce::FillOrKill) => FixTif::FillOrKill,
Some(PlaceTimeInForce::ImmediateOrCancel) => FixTif::ImmediateOrCancel,
};
let trigger = input.trigger.map(|t| match t {
PlaceTrigger::IndexPrice => FixTrigger::IndexPrice,
PlaceTrigger::MarkPrice => FixTrigger::MarkPrice,
PlaceTrigger::LastPrice => FixTrigger::LastPrice,
});
let valid_until = match input.valid_until {
None => None,
Some(v) => Some(i64::try_from(v).map_err(|_| AdapterError::Validation {
field: "valid_until".to_string(),
message: format!("must fit in i64, got {v}"),
})?),
};
Ok(NewOrderRequest {
instrument_name: input.instrument_name,
amount: input.amount,
order_type,
side,
price: input.price,
time_in_force,
post_only: input.post_only,
reduce_only: input.reduce_only,
label: input.label,
stop_price: input.trigger_price,
trigger,
advanced: None,
max_show: None,
reject_post_only: input.reject_post_only,
valid_until,
client_order_id: None,
})
}
#[cfg(feature = "fix")]
fn synthesize_fix_order_response(
order_id: &str,
request: &deribit_fix::model::request::NewOrderRequest,
) -> Value {
use deribit_fix::model::request::OrderSide as FixSide;
let direction = match request.side {
FixSide::Buy => "buy",
FixSide::Sell => "sell",
};
serde_json::json!({
"order": {
"order_id": order_id,
"instrument_name": request.instrument_name,
"amount": request.amount,
"direction": direction,
"order_type": request.order_type.as_str(),
"price": request.price,
"post_only": request.post_only.unwrap_or(false),
"reduce_only": request.reduce_only.unwrap_or(false),
"label": request.label.clone().unwrap_or_default(),
"order_state": "open",
"time_in_force": request.time_in_force.as_str(),
"transport": "fix"
},
"trades": []
})
}
pub(crate) async fn enforce_size_cap(
ctx: &AdapterContext,
instrument_name: &str,
amount: f64,
price: Option<f64>,
) -> Result<(), AdapterError> {
let Some(cap) = ctx.config.max_order_usd else {
return Ok(());
};
let cap = cap as f64;
let notional = compute_usd_notional(ctx, instrument_name, amount, price).await?;
if !notional.is_finite() {
return Err(AdapterError::Validation {
field: "amount".to_string(),
message: "computed notional is not finite".to_string(),
});
}
if notional > cap {
return Err(AdapterError::SizeCapExceeded {
requested: notional,
cap,
});
}
Ok(())
}
fn is_linear_instrument(name: &str) -> bool {
name.contains("_USDC") || name.contains("_USDT")
}
fn is_option_instrument(name: &str) -> bool {
let mut parts = name.rsplit('-');
let Some(last) = parts.next() else {
return false;
};
if last != "C" && last != "P" {
return false;
}
let Some(strike) = parts.next() else {
return false;
};
strike.parse::<f64>().is_ok()
}
fn option_underlying_base(name: &str) -> Option<&str> {
name.split('-').next()
}
async fn compute_usd_notional(
ctx: &AdapterContext,
instrument_name: &str,
amount: f64,
price: Option<f64>,
) -> Result<f64, AdapterError> {
if is_option_instrument(instrument_name) {
let base = option_underlying_base(instrument_name).unwrap_or("BTC");
let index_name = format!("{}_usd", base.to_lowercase());
let idx = ctx.http.get_index_price(&index_name).await?;
return Ok(amount * idx.index_price);
}
if !is_linear_instrument(instrument_name) {
return Ok(amount);
}
let p = match price {
Some(p) => p,
None => {
let ticker = ctx.http.get_ticker(instrument_name).await?;
ticker.mark_price
}
};
Ok(amount * p)
}
fn validate_place_order(input: &PlaceOrderInput) -> Result<(), AdapterError> {
if !input.amount.is_finite() || input.amount <= 0.0 {
return Err(AdapterError::Validation {
field: "amount".to_string(),
message: format!("must be finite and > 0, got {}", input.amount),
});
}
if let Some(price) = input.price
&& (!price.is_finite() || price <= 0.0)
{
return Err(AdapterError::Validation {
field: "price".to_string(),
message: format!("must be finite and > 0, got {price}"),
});
}
if let Some(tp) = input.trigger_price
&& (!tp.is_finite() || tp <= 0.0)
{
return Err(AdapterError::Validation {
field: "trigger_price".to_string(),
message: format!("must be finite and > 0, got {tp}"),
});
}
let needs_price = matches!(
input.order_type,
PlaceOrderType::Limit | PlaceOrderType::StopLimit | PlaceOrderType::TakeLimit
);
if needs_price && input.price.is_none() {
return Err(AdapterError::Validation {
field: "price".to_string(),
message: "required for limit / stop_limit / take_limit orders".to_string(),
});
}
let needs_trigger = matches!(
input.order_type,
PlaceOrderType::StopLimit
| PlaceOrderType::StopMarket
| PlaceOrderType::TakeLimit
| PlaceOrderType::TakeMarket
);
if needs_trigger {
if input.trigger_price.is_none() {
return Err(AdapterError::Validation {
field: "trigger_price".to_string(),
message: "required for stop / take order types".to_string(),
});
}
if input.trigger.is_none() {
return Err(AdapterError::Validation {
field: "trigger".to_string(),
message: "required for stop / take order types".to_string(),
});
}
}
Ok(())
}
fn build_order_request(
input: PlaceOrderInput,
) -> Result<(Side, deribit_http::model::request::OrderRequest), AdapterError> {
use deribit_http::model::order::OrderType as UpType;
use deribit_http::model::request::OrderRequest;
use deribit_http::model::trigger::Trigger as UpTrigger;
use deribit_http::model::types::TimeInForce as UpTif;
let order_type = match input.order_type {
PlaceOrderType::Limit => UpType::Limit,
PlaceOrderType::Market => UpType::Market,
PlaceOrderType::StopLimit => UpType::StopLimit,
PlaceOrderType::StopMarket => UpType::StopMarket,
PlaceOrderType::TakeLimit => UpType::TakeLimit,
PlaceOrderType::TakeMarket => UpType::TakeMarket,
PlaceOrderType::MarketLimit => UpType::MarketLimit,
};
let time_in_force = input.time_in_force.map(|t| match t {
PlaceTimeInForce::GoodTilCancelled => UpTif::GoodTilCancelled,
PlaceTimeInForce::GoodTilDay => UpTif::GoodTilDay,
PlaceTimeInForce::FillOrKill => UpTif::FillOrKill,
PlaceTimeInForce::ImmediateOrCancel => UpTif::ImmediateOrCancel,
});
let trigger = input.trigger.map(|t| match t {
PlaceTrigger::IndexPrice => UpTrigger::IndexPrice,
PlaceTrigger::MarkPrice => UpTrigger::MarkPrice,
PlaceTrigger::LastPrice => UpTrigger::LastPrice,
});
let valid_until = match input.valid_until {
None => None,
Some(v) => Some(i64::try_from(v).map_err(|_| AdapterError::Validation {
field: "valid_until".to_string(),
message: format!("must fit in i64, got {v}"),
})?),
};
let req = OrderRequest {
order_id: None,
instrument_name: input.instrument_name,
amount: Some(input.amount),
contracts: None,
type_: Some(order_type),
label: input.label,
price: input.price,
time_in_force,
display_amount: None,
post_only: input.post_only,
reject_post_only: input.reject_post_only,
reduce_only: input.reduce_only,
trigger_price: input.trigger_price,
trigger_offset: None,
trigger,
advanced: None,
mmp: input.mmp,
valid_until,
linked_order_type: None,
trigger_fill_condition: None,
otoco_config: None,
};
Ok((input.side, req))
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct EditOrderInput {
pub order_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reject_post_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reduce_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mmp: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_until: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_price: Option<f64>,
}
fn edit_order_tool() -> ToolEntry {
let schema = schema_for::<EditOrderInput>();
let descriptor = Tool::new(
"edit_order",
"Modify amount / price / trigger_price / valid_until / flags (post_only, \
reject_post_only, reduce_only, mmp) of an open order.",
schema,
);
let handler: ToolHandlerFn = Arc::new(|ctx, input| Box::pin(handle_edit_order(ctx, input)));
ToolEntry {
descriptor,
class: ToolClass::Trading,
handler,
}
}
async fn handle_edit_order(ctx: &AdapterContext, input: Value) -> Result<Value, AdapterError> {
let mut input: EditOrderInput = parse(input)?;
validate_edit_order(&input)?;
input.order_id = input.order_id.trim().to_string();
let request = build_edit_request(input)?;
let result = ctx.http.edit_order(request).await?;
Ok(serde_json::to_value(&result)?)
}
fn validate_edit_order(input: &EditOrderInput) -> Result<(), AdapterError> {
if input.order_id.trim().is_empty() {
return Err(AdapterError::Validation {
field: "order_id".to_string(),
message: "must be non-empty".to_string(),
});
}
let any_edit = input.amount.is_some()
|| input.price.is_some()
|| input.post_only.is_some()
|| input.reject_post_only.is_some()
|| input.reduce_only.is_some()
|| input.mmp.is_some()
|| input.valid_until.is_some()
|| input.trigger_price.is_some();
if !any_edit {
return Err(AdapterError::Validation {
field: "arguments".to_string(),
message: "at least one editable field must be present (amount, price, post_only, \
reject_post_only, reduce_only, mmp, valid_until, trigger_price)"
.to_string(),
});
}
if let Some(amount) = input.amount
&& (!amount.is_finite() || amount <= 0.0)
{
return Err(AdapterError::Validation {
field: "amount".to_string(),
message: format!("must be finite and > 0, got {amount}"),
});
}
if let Some(price) = input.price
&& (!price.is_finite() || price <= 0.0)
{
return Err(AdapterError::Validation {
field: "price".to_string(),
message: format!("must be finite and > 0, got {price}"),
});
}
if let Some(tp) = input.trigger_price
&& (!tp.is_finite() || tp <= 0.0)
{
return Err(AdapterError::Validation {
field: "trigger_price".to_string(),
message: format!("must be finite and > 0, got {tp}"),
});
}
Ok(())
}
fn build_edit_request(
input: EditOrderInput,
) -> Result<deribit_http::model::request::OrderRequest, AdapterError> {
use deribit_http::model::request::OrderRequest;
let valid_until = match input.valid_until {
None => None,
Some(v) => Some(i64::try_from(v).map_err(|_| AdapterError::Validation {
field: "valid_until".to_string(),
message: format!("must fit in i64, got {v}"),
})?),
};
Ok(OrderRequest {
order_id: Some(input.order_id),
instrument_name: String::new(),
amount: input.amount,
contracts: None,
type_: None,
label: None,
price: input.price,
time_in_force: None,
display_amount: None,
post_only: input.post_only,
reject_post_only: input.reject_post_only,
reduce_only: input.reduce_only,
trigger_price: input.trigger_price,
trigger_offset: None,
trigger: None,
advanced: None,
mmp: input.mmp,
valid_until,
linked_order_type: None,
trigger_fill_condition: None,
otoco_config: None,
})
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct CancelOrderInput {
pub order_id: String,
}
fn cancel_order_tool() -> ToolEntry {
let schema = schema_for::<CancelOrderInput>();
let descriptor = Tool::new("cancel_order", "Cancel one open order by its id.", schema);
let handler: ToolHandlerFn = Arc::new(|ctx, input| Box::pin(handle_cancel_order(ctx, input)));
ToolEntry {
descriptor,
class: ToolClass::Trading,
handler,
}
}
async fn handle_cancel_order(ctx: &AdapterContext, input: Value) -> Result<Value, AdapterError> {
let input: CancelOrderInput = parse(input)?;
let order_id = input.order_id.trim();
if order_id.is_empty() {
return Err(AdapterError::Validation {
field: "order_id".to_string(),
message: "must be non-empty".to_string(),
});
}
match ctx.config.order_transport {
crate::config::OrderTransport::Http => {
let result = ctx.http.cancel_order(order_id).await?;
Ok(serde_json::to_value(&result)?)
}
#[cfg(feature = "fix")]
crate::config::OrderTransport::Fix => {
let fix = ctx.ensure_fix().await?;
{
let guard = fix.lock().await;
guard.cancel_order(order_id.to_string()).await?;
}
Ok(serde_json::json!({
"order_id": order_id,
"order_state": "cancelled",
"transport": "fix"
}))
}
#[cfg(not(feature = "fix"))]
crate::config::OrderTransport::Fix => Err(AdapterError::not_enabled(
"cancel_order",
"build with --features=fix",
)),
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct CancelAllByCurrencyInput {
pub currency: String,
}
fn cancel_all_by_currency_tool() -> ToolEntry {
let schema = schema_for::<CancelAllByCurrencyInput>();
let descriptor = Tool::new(
"cancel_all_by_currency",
"Mass-cancel every open order for a currency. Returns \
{\"cancelled\": <count>}. Irreversible — use with care.",
schema,
);
let handler: ToolHandlerFn =
Arc::new(|ctx, input| Box::pin(handle_cancel_all_by_currency(ctx, input)));
ToolEntry {
descriptor,
class: ToolClass::Trading,
handler,
}
}
async fn handle_cancel_all_by_currency(
ctx: &AdapterContext,
input: Value,
) -> Result<Value, AdapterError> {
let input: CancelAllByCurrencyInput = parse(input)?;
let currency = input.currency.trim();
if currency.is_empty() {
return Err(AdapterError::Validation {
field: "currency".to_string(),
message: "must be non-empty".to_string(),
});
}
warn_if_fix_transport(ctx, "cancel_all_by_currency");
let cancelled = ctx.http.cancel_all_by_currency(currency).await?;
Ok(serde_json::json!({ "cancelled": cancelled }))
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct CancelAllByInstrumentInput {
pub instrument_name: String,
}
fn cancel_all_by_instrument_tool() -> ToolEntry {
let schema = schema_for::<CancelAllByInstrumentInput>();
let descriptor = Tool::new(
"cancel_all_by_instrument",
"Mass-cancel every open order on an instrument. Returns \
{\"cancelled\": <count>}. Irreversible — use with care.",
schema,
);
let handler: ToolHandlerFn =
Arc::new(|ctx, input| Box::pin(handle_cancel_all_by_instrument(ctx, input)));
ToolEntry {
descriptor,
class: ToolClass::Trading,
handler,
}
}
async fn handle_cancel_all_by_instrument(
ctx: &AdapterContext,
input: Value,
) -> Result<Value, AdapterError> {
let input: CancelAllByInstrumentInput = parse(input)?;
let instrument = input.instrument_name.trim();
if instrument.is_empty() {
return Err(AdapterError::Validation {
field: "instrument_name".to_string(),
message: "must be non-empty".to_string(),
});
}
warn_if_fix_transport(ctx, "cancel_all_by_instrument");
let cancelled = ctx.http.cancel_all_by_instrument(instrument).await?;
Ok(serde_json::json!({ "cancelled": cancelled }))
}
fn warn_if_fix_transport(ctx: &AdapterContext, tool: &str) {
match ctx.config.order_transport {
crate::config::OrderTransport::Http => {}
crate::config::OrderTransport::Fix => {
tracing::warn!(
tool,
"`{tool}` always dispatches through HTTP — `deribit-fix 0.3` has \
no native mass-cancel helper. The active FIX session is unused \
for this call."
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn limit_input() -> PlaceOrderInput {
PlaceOrderInput {
instrument_name: "BTC-PERPETUAL".to_string(),
side: Side::Buy,
amount: 10.0,
order_type: PlaceOrderType::Limit,
price: Some(50_000.0),
time_in_force: None,
label: None,
trigger_price: None,
trigger: None,
post_only: None,
reject_post_only: None,
reduce_only: None,
mmp: None,
valid_until: None,
}
}
#[test]
fn limit_without_price_rejected() {
let mut input = limit_input();
input.price = None;
let err = validate_place_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "price"));
}
#[test]
fn stop_limit_without_trigger_price_rejected() {
let mut input = limit_input();
input.order_type = PlaceOrderType::StopLimit;
input.trigger = Some(PlaceTrigger::IndexPrice);
let err = validate_place_order(&input).unwrap_err();
assert!(
matches!(err, AdapterError::Validation { ref field, .. } if field == "trigger_price")
);
}
#[test]
fn stop_limit_without_trigger_rejected() {
let mut input = limit_input();
input.order_type = PlaceOrderType::StopLimit;
input.trigger_price = Some(45_000.0);
let err = validate_place_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "trigger"));
}
#[test]
fn negative_amount_rejected() {
let mut input = limit_input();
input.amount = -1.0;
let err = validate_place_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "amount"));
}
#[test]
fn nan_amount_rejected() {
let mut input = limit_input();
input.amount = f64::NAN;
let err = validate_place_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "amount"));
}
#[test]
fn nan_price_rejected() {
let mut input = limit_input();
input.price = Some(f64::NAN);
let err = validate_place_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "price"));
}
#[test]
fn market_without_price_accepted() {
let mut input = limit_input();
input.order_type = PlaceOrderType::Market;
input.price = None;
validate_place_order(&input).unwrap();
}
#[test]
fn full_stop_limit_accepted() {
let mut input = limit_input();
input.order_type = PlaceOrderType::StopLimit;
input.trigger_price = Some(45_000.0);
input.trigger = Some(PlaceTrigger::MarkPrice);
validate_place_order(&input).unwrap();
}
#[test]
fn build_request_round_trips_buy_side() {
let input = limit_input();
let (side, req) = build_order_request(input).unwrap();
assert_eq!(side, Side::Buy);
assert_eq!(req.instrument_name, "BTC-PERPETUAL");
assert_eq!(req.amount, Some(10.0));
assert_eq!(req.price, Some(50_000.0));
}
#[test]
fn valid_until_overflow_rejected() {
let mut input = limit_input();
input.valid_until = Some(u64::MAX);
let err = build_order_request(input).unwrap_err();
assert!(
matches!(err, AdapterError::Validation { ref field, .. } if field == "valid_until")
);
}
fn edit_input() -> EditOrderInput {
EditOrderInput {
order_id: "ORDER-1".to_string(),
amount: Some(20.0),
price: Some(51_000.0),
post_only: None,
reject_post_only: None,
reduce_only: None,
mmp: None,
valid_until: None,
trigger_price: None,
}
}
#[test]
fn edit_empty_order_id_rejected() {
let mut input = edit_input();
input.order_id = " ".to_string();
let err = validate_edit_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "order_id"));
}
#[test]
fn edit_negative_amount_rejected() {
let mut input = edit_input();
input.amount = Some(-1.0);
let err = validate_edit_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "amount"));
}
#[test]
fn edit_nan_price_rejected() {
let mut input = edit_input();
input.price = Some(f64::NAN);
let err = validate_edit_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "price"));
}
#[test]
fn edit_partial_input_accepted() {
let mut input = edit_input();
input.price = None;
validate_edit_order(&input).unwrap();
let req = build_edit_request(input).unwrap();
assert_eq!(req.order_id.as_deref(), Some("ORDER-1"));
assert_eq!(req.amount, Some(20.0));
assert_eq!(req.price, None);
}
#[test]
fn linear_instrument_classification() {
assert!(is_linear_instrument("BTC_USDC-PERPETUAL"));
assert!(is_linear_instrument("ETH_USDT-29SEP24"));
assert!(!is_linear_instrument("BTC-PERPETUAL"));
assert!(!is_linear_instrument("ETH-29SEP24"));
assert!(!is_linear_instrument("BTC-29SEP24-50000-C"));
}
#[test]
fn option_instrument_classification() {
assert!(is_option_instrument("BTC-29SEP24-50000-C"));
assert!(is_option_instrument("ETH-30JUN23-1500-P"));
assert!(!is_option_instrument("BTC-PERPETUAL"));
assert!(!is_option_instrument("BTC-29SEP24"));
assert!(!is_option_instrument("BTC-FOO-BAR-C"));
assert_eq!(option_underlying_base("BTC-29SEP24-50000-C"), Some("BTC"));
assert_eq!(option_underlying_base("ETH-30JUN23-1500-P"), Some("ETH"));
}
fn ctx_with_cap(cap: Option<u64>) -> AdapterContext {
use crate::config::{Config, LogFormat, OrderTransport, Transport};
use std::net::SocketAddr;
let cfg = Config {
endpoint: "https://test.deribit.com".to_string(),
client_id: Some("id".to_string()),
client_secret: Some("secret".to_string()),
allow_trading: true,
max_order_usd: cap,
transport: Transport::Stdio,
http_listen: SocketAddr::from(([127, 0, 0, 1], 8723)),
http_bearer_token: None,
log_format: LogFormat::Text,
order_transport: OrderTransport::Http,
};
AdapterContext::new(Arc::new(cfg)).expect("ctx")
}
#[tokio::test]
async fn cap_unset_is_noop() {
let ctx = ctx_with_cap(None);
enforce_size_cap(&ctx, "BTC-PERPETUAL", 1_000_000.0, Some(50_000.0))
.await
.unwrap();
}
#[tokio::test]
async fn inverse_notional_uses_amount() {
let ctx = ctx_with_cap(Some(10_000));
let err = enforce_size_cap(&ctx, "BTC-PERPETUAL", 100_000.0, Some(50_000.0))
.await
.unwrap_err();
match err {
AdapterError::SizeCapExceeded { requested, cap } => {
assert!((requested - 100_000.0).abs() < 1e-6);
assert!((cap - 10_000.0).abs() < 1e-6);
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn inverse_under_cap_passes() {
let ctx = ctx_with_cap(Some(10_000));
enforce_size_cap(&ctx, "BTC-PERPETUAL", 5_000.0, Some(50_000.0))
.await
.unwrap();
}
#[tokio::test]
async fn linear_notional_uses_price_times_amount() {
let ctx = ctx_with_cap(Some(10_000));
let err = enforce_size_cap(&ctx, "BTC_USDC-PERPETUAL", 0.5, Some(100_000.0))
.await
.unwrap_err();
assert!(matches!(err, AdapterError::SizeCapExceeded { .. }));
}
#[test]
fn edit_no_fields_rejected() {
let input = EditOrderInput {
order_id: "ORDER-1".to_string(),
amount: None,
price: None,
post_only: None,
reject_post_only: None,
reduce_only: None,
mmp: None,
valid_until: None,
trigger_price: None,
};
let err = validate_edit_order(&input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "arguments"));
}
#[test]
fn edit_valid_until_overflow_rejected() {
let mut input = edit_input();
input.valid_until = Some(u64::MAX);
let err = build_edit_request(input).unwrap_err();
assert!(
matches!(err, AdapterError::Validation { ref field, .. } if field == "valid_until")
);
}
#[cfg(feature = "fix")]
#[test]
fn build_fix_new_order_request_round_trips_buy_limit() {
use deribit_fix::model::request::{
OrderSide as FixSide, OrderType as FixOrderType, TimeInForce as FixTif,
};
let input = limit_input();
let request = build_fix_new_order_request(input).unwrap();
assert_eq!(request.instrument_name, "BTC-PERPETUAL");
assert!((request.amount - 10.0).abs() < 1e-9);
assert!(matches!(request.side, FixSide::Buy));
assert!(matches!(request.order_type, FixOrderType::Limit));
assert!(matches!(request.time_in_force, FixTif::GoodTilCancelled));
assert_eq!(request.price, Some(50_000.0));
assert_eq!(request.stop_price, None);
assert!(request.post_only.is_none());
}
#[cfg(feature = "fix")]
#[test]
fn build_fix_new_order_request_maps_stop_limit_with_trigger() {
use deribit_fix::model::request::{OrderType as FixOrderType, TriggerType as FixTrigger};
let mut input = limit_input();
input.order_type = PlaceOrderType::StopLimit;
input.trigger_price = Some(45_000.0);
input.trigger = Some(PlaceTrigger::MarkPrice);
let request = build_fix_new_order_request(input).unwrap();
assert!(matches!(request.order_type, FixOrderType::StopLimit));
assert_eq!(request.stop_price, Some(45_000.0));
assert!(matches!(request.trigger, Some(FixTrigger::MarkPrice)));
}
#[cfg(feature = "fix")]
#[test]
fn build_fix_new_order_request_overflow_rejects_valid_until() {
let mut input = limit_input();
input.valid_until = Some(u64::MAX);
let err = build_fix_new_order_request(input).unwrap_err();
assert!(
matches!(err, AdapterError::Validation { ref field, .. } if field == "valid_until")
);
}
#[cfg(feature = "fix")]
#[test]
fn build_fix_new_order_request_rejects_mmp() {
let mut input = limit_input();
input.mmp = Some(true);
let err = build_fix_new_order_request(input).unwrap_err();
assert!(matches!(err, AdapterError::Validation { ref field, .. } if field == "mmp"));
}
#[cfg(feature = "fix")]
#[test]
fn synthesize_fix_order_response_matches_documented_shape() {
use deribit_fix::model::request::{
NewOrderRequest, OrderSide as FixSide, OrderType as FixOrderType, TimeInForce as FixTif,
};
let request = NewOrderRequest {
instrument_name: "BTC-PERPETUAL".to_string(),
amount: 10.0,
order_type: FixOrderType::Limit,
side: FixSide::Buy,
price: Some(50_000.0),
time_in_force: FixTif::GoodTilCancelled,
post_only: Some(true),
reduce_only: None,
label: Some("test".to_string()),
stop_price: None,
trigger: None,
advanced: None,
max_show: None,
reject_post_only: None,
valid_until: None,
client_order_id: None,
};
let response = synthesize_fix_order_response("ORDER-1", &request);
assert_eq!(response["order"]["order_id"], "ORDER-1");
assert_eq!(response["order"]["instrument_name"], "BTC-PERPETUAL");
assert_eq!(response["order"]["direction"], "buy");
assert_eq!(response["order"]["order_type"], "limit");
assert_eq!(response["order"]["price"], 50_000.0);
assert_eq!(response["order"]["post_only"], true);
assert_eq!(response["order"]["reduce_only"], false);
assert_eq!(response["order"]["label"], "test");
assert_eq!(response["order"]["order_state"], "open");
assert_eq!(response["order"]["transport"], "fix");
assert_eq!(response["trades"], serde_json::json!([]));
}
}