use super::{OptionSymbol, OrderRequest, OrderType};
use crate::common::{AssetType, PutCall};
use rust_decimal::Decimal;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DefinedRisk {
pub underlying: String,
pub contracts: Decimal,
pub max_loss: Decimal,
}
pub fn defined_risk(
order: &OrderRequest,
multiplier: impl Fn(&str) -> Option<Decimal>,
) -> Result<DefinedRisk, String> {
let legs = &order.order_leg_collection;
if legs.is_empty() {
return Err("A strategy needs legs".into());
}
let mut parsed = Vec::with_capacity(legs.len());
let mut contract = None;
for leg in legs {
if leg.instrument.asset_type != AssetType::Option {
return Err("Defined risk is computed for option legs only".into());
}
let symbol: OptionSymbol = leg.instrument.symbol.parse()?;
let size = multiplier(&leg.instrument.symbol)
.filter(|m| *m > Decimal::ZERO)
.ok_or("Option contract requires an operator-verified multiplier")?;
if *contract.get_or_insert(size) != size {
return Err("A strategy's legs must share one contract multiplier".into());
}
let sign = match leg.instruction.buys() {
Some(true) => Decimal::ONE,
Some(false) => Decimal::NEGATIVE_ONE,
None => return Err("A leg must buy or sell".into()),
};
if leg.quantity <= Decimal::ZERO || !leg.quantity.fract().is_zero() {
return Err("Leg quantities must be whole contracts".into());
}
parsed.push((symbol, sign, leg.quantity));
}
let (first, _, _) = &parsed[0];
if parsed
.iter()
.any(|(s, _, _)| s.underlying() != first.underlying())
{
return Err("A strategy's legs must share one underlying".into());
}
if parsed
.iter()
.any(|(s, _, _)| s.expiration() != first.expiration())
{
return Err("Legs at different expirations need a pricing model to bound".into());
}
let multiplier = contract.unwrap_or(Decimal::ONE);
let overflow = || "Strategy arithmetic overflow".to_owned();
let units = parsed
.iter()
.map(|(_, _, q)| q.trunc())
.reduce(gcd)
.unwrap_or(Decimal::ONE);
let per_unit = match order.order_type {
Some(OrderType::NetZero) => Decimal::ZERO,
Some(kind @ (OrderType::NetDebit | OrderType::NetCredit)) => {
let price = order
.price
.filter(|p| *p > Decimal::ZERO)
.ok_or("A net-priced strategy needs a positive price")?;
if kind == OrderType::NetDebit {
price
} else {
-price
}
}
_ => return Err("A strategy is priced NET_DEBIT, NET_CREDIT or NET_ZERO".into()),
};
let premium = per_unit
.checked_mul(units)
.and_then(|v| v.checked_mul(multiplier))
.ok_or_else(overflow)?;
let call_slope: Decimal = parsed
.iter()
.filter(|(s, _, _)| s.put_call() == PutCall::Call)
.map(|(_, sign, q)| sign * q)
.sum();
if call_slope < Decimal::ZERO {
return Err("The strategy sells more calls than it buys; its loss has no bound".into());
}
let payoff = |price: Decimal| -> Result<Decimal, String> {
parsed.iter().try_fold(Decimal::ZERO, |sum, (s, sign, q)| {
let intrinsic = match s.put_call() {
PutCall::Call => (price - s.strike()).max(Decimal::ZERO),
PutCall::Put => (s.strike() - price).max(Decimal::ZERO),
};
intrinsic
.checked_mul(*q)
.and_then(|v| v.checked_mul(multiplier))
.and_then(|v| sum.checked_add(*sign * v))
.ok_or_else(overflow)
})
};
let mut worst = Decimal::ZERO;
for price in std::iter::once(Decimal::ZERO).chain(parsed.iter().map(|(s, _, _)| s.strike())) {
let loss = premium.checked_sub(payoff(price)?).ok_or_else(overflow)?;
worst = worst.max(loss);
}
Ok(DefinedRisk {
underlying: first.underlying().to_owned(),
contracts: parsed.iter().map(|(_, _, q)| *q).sum(),
max_loss: worst,
})
}
fn gcd(a: Decimal, b: Decimal) -> Decimal {
let (mut a, mut b) = (a.abs(), b.abs());
while !b.is_zero() {
(a, b) = (b, a % b);
}
a
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn spread(kind: &str, price: Option<&str>, legs: &[(&str, &str, u32)]) -> OrderRequest {
let legs: Vec<_> = legs
.iter()
.map(|(instruction, symbol, quantity)| {
json!({"instruction": instruction, "quantity": quantity,
"instrument": {"symbol": symbol, "assetType": "OPTION"}})
})
.collect();
let mut order = json!({"orderStrategyType": "SINGLE", "orderType": kind,
"session": "NORMAL", "duration": "DAY", "orderLegCollection": legs});
if let Some(price) = price {
order["price"] = json!(price);
}
serde_json::from_value(order).unwrap()
}
const C100: &str = "XYZ 260116C00100000";
const C105: &str = "XYZ 260116C00105000";
const C110: &str = "XYZ 260116C00110000";
const P90: &str = "XYZ 260116P00090000";
const P95: &str = "XYZ 260116P00095000";
fn hundred(_: &str) -> Option<Decimal> {
Some(Decimal::from(100))
}
fn loss(order: &OrderRequest) -> Decimal {
defined_risk(order, hundred).unwrap().max_loss
}
#[test]
fn a_debit_vertical_risks_its_debit() {
let bull = spread(
"NET_DEBIT",
Some("2"),
&[("BUY_TO_OPEN", C100, 3), ("SELL_TO_OPEN", C105, 3)],
);
let risk = defined_risk(&bull, hundred).unwrap();
assert_eq!(risk.max_loss, Decimal::from(600));
assert_eq!(risk.contracts, Decimal::from(6));
assert_eq!(risk.underlying, "XYZ");
}
#[test]
fn a_credit_vertical_risks_its_width_less_the_credit() {
let bear = spread(
"NET_CREDIT",
Some("1.5"),
&[("SELL_TO_OPEN", C100, 2), ("BUY_TO_OPEN", C105, 2)],
);
assert_eq!(loss(&bear), Decimal::from(700));
let puts = spread(
"NET_CREDIT",
Some("1"),
&[("SELL_TO_OPEN", P95, 1), ("BUY_TO_OPEN", P90, 1)],
);
assert_eq!(loss(&puts), Decimal::from(400));
}
#[test]
fn an_iron_condor_risks_its_wider_wing_less_the_credit() {
let condor = spread(
"NET_CREDIT",
Some("1.2"),
&[
("BUY_TO_OPEN", P90, 1),
("SELL_TO_OPEN", P95, 1),
("SELL_TO_OPEN", C105, 1),
("BUY_TO_OPEN", C110, 1),
],
);
assert_eq!(loss(&condor), Decimal::from(380));
}
#[test]
fn a_butterfly_and_a_ratio_count_premium_per_unit() {
let fly = spread(
"NET_DEBIT",
Some("0.8"),
&[
("BUY_TO_OPEN", C100, 1),
("SELL_TO_OPEN", C105, 2),
("BUY_TO_OPEN", C110, 1),
],
);
assert_eq!(loss(&fly), Decimal::from(80));
let five = spread(
"NET_DEBIT",
Some("0.8"),
&[
("BUY_TO_OPEN", C100, 5),
("SELL_TO_OPEN", C105, 10),
("BUY_TO_OPEN", C110, 5),
],
);
assert_eq!(loss(&five), Decimal::from(400));
let free = spread(
"NET_CREDIT",
Some("10"),
&[("SELL_TO_OPEN", C100, 1), ("BUY_TO_OPEN", C105, 1)],
);
assert_eq!(loss(&free), Decimal::ZERO);
let ratio = spread(
"NET_CREDIT",
Some("0.5"),
&[("BUY_TO_OPEN", P95, 1), ("SELL_TO_OPEN", P90, 2)],
);
assert_eq!(loss(&ratio), Decimal::from(8450));
}
#[test]
fn unbounded_or_unpriceable_strategies_are_refused() {
let naked = spread(
"NET_CREDIT",
Some("1"),
&[("SELL_TO_OPEN", C100, 2), ("BUY_TO_OPEN", C105, 1)],
);
assert!(
defined_risk(&naked, hundred)
.unwrap_err()
.contains("no bound")
);
let calendar = spread(
"NET_DEBIT",
Some("1"),
&[
("SELL_TO_OPEN", C100, 1),
("BUY_TO_OPEN", "XYZ 260220C00100000", 1),
],
);
assert!(
defined_risk(&calendar, hundred)
.unwrap_err()
.contains("different expirations")
);
let unpriced = spread(
"NET_DEBIT",
None,
&[("BUY_TO_OPEN", C100, 1), ("SELL_TO_OPEN", C105, 1)],
);
assert!(defined_risk(&unpriced, hundred).is_err());
let mixed = |symbol: &str| {
(symbol == C105)
.then(|| Decimal::from(10))
.or(Some(Decimal::from(100)))
};
let vertical = spread(
"NET_DEBIT",
Some("1"),
&[("BUY_TO_OPEN", C100, 1), ("SELL_TO_OPEN", C105, 1)],
);
assert!(defined_risk(&vertical, mixed).is_err());
assert!(defined_risk(&vertical, |_| None).is_err());
}
}