Skip to main content

alpaca_option/
execution_quote.rs

1use std::collections::{HashMap, HashSet};
2
3use alpaca_core::float;
4use alpaca_time::clock;
5
6use crate::contract;
7use crate::error::{OptionError, OptionResult};
8use crate::numeric;
9use crate::types::{
10    ExecutionAction, ExecutionLeg, ExecutionQuoteRange, OptionPosition, OptionQuote,
11    OptionSnapshot, OrderSide, PositionIntent, PositionSide, QuotedLeg, RollRequest,
12    ScaledExecutionQuote, ScaledExecutionQuoteRange,
13};
14
15pub use crate::types::{
16    ExecutionLegInput, ExecutionSnapshot, Greeks, GreeksInput, RollLegSelection,
17};
18
19const CONTRACT_MULTIPLIER: f64 = 100.0;
20
21pub trait QuoteLike {
22    fn quote(&self) -> OptionQuote;
23}
24
25impl<T: QuoteLike + ?Sized> QuoteLike for &T {
26    fn quote(&self) -> OptionQuote {
27        (*self).quote()
28    }
29}
30
31impl QuoteLike for OptionQuote {
32    fn quote(&self) -> OptionQuote {
33        self.clone()
34    }
35}
36
37impl QuoteLike for OptionSnapshot {
38    fn quote(&self) -> OptionQuote {
39        self.quote.clone()
40    }
41}
42
43impl QuoteLike for OptionPosition {
44    fn quote(&self) -> OptionQuote {
45        self.snapshot_ref()
46            .map(|snapshot| snapshot.quote.clone())
47            .unwrap_or(OptionQuote {
48                bid: None,
49                ask: None,
50                mark: None,
51                last: None,
52            })
53    }
54}
55
56impl QuoteLike for QuotedLeg {
57    fn quote(&self) -> OptionQuote {
58        self.quote.clone()
59    }
60}
61
62pub trait QuoteRangeLike {
63    fn quote_range(&self) -> OptionResult<ExecutionQuoteRange>;
64}
65
66impl<T: QuoteRangeLike + ?Sized> QuoteRangeLike for &T {
67    fn quote_range(&self) -> OptionResult<ExecutionQuoteRange> {
68        (*self).quote_range()
69    }
70}
71
72impl QuoteRangeLike for [OptionPosition] {
73    fn quote_range(&self) -> OptionResult<ExecutionQuoteRange> {
74        range_from_positions(self)
75    }
76}
77
78impl QuoteRangeLike for [QuotedLeg] {
79    fn quote_range(&self) -> OptionResult<ExecutionQuoteRange> {
80        range_from_legs(self)
81    }
82}
83
84fn ensure_finite(name: &str, value: f64) -> OptionResult<()> {
85    if value.is_finite() {
86        Ok(())
87    } else {
88        Err(OptionError::new(
89            "invalid_execution_quote_input",
90            format!("{name} must be finite: {value}"),
91        ))
92    }
93}
94
95fn round_price(value: f64) -> OptionResult<f64> {
96    ensure_finite("quote value", value)?;
97    Ok(float::round(value, 2))
98}
99
100fn quote_value(value: Option<f64>, name: &str) -> OptionResult<f64> {
101    match value {
102        Some(number) => {
103            ensure_finite(name, number)?;
104            Ok(number)
105        }
106        None => Ok(0.0),
107    }
108}
109
110fn normalized_mark(quote: &OptionQuote) -> Option<f64> {
111    if let Some(mark) = quote.mark {
112        return Some(mark);
113    }
114
115    match (quote.bid, quote.ask) {
116        (Some(bid), Some(ask)) => Some(float::round((bid + ask) / 2.0, 12)),
117        (Some(bid), None) => Some(bid),
118        (None, Some(ask)) => Some(ask),
119        (None, None) => quote.last,
120    }
121}
122
123pub fn quote(source: &(impl QuoteLike + ?Sized)) -> OptionQuote {
124    let base = source.quote();
125    let mark = normalized_mark(&base);
126    OptionQuote {
127        bid: base.bid,
128        ask: base.ask,
129        mark,
130        last: base.last.or(mark),
131    }
132}
133
134pub fn limit_price(limit_price: Option<f64>) -> f64 {
135    match limit_price {
136        Some(value) if value.is_finite() => value,
137        _ => 0.0,
138    }
139}
140
141fn quote_bid_ask(option_quote: &OptionQuote) -> OptionResult<(f64, f64)> {
142    let normalized = quote(option_quote);
143    Ok((
144        quote_value(normalized.bid, "bid")?,
145        quote_value(normalized.ask, "ask")?,
146    ))
147}
148
149fn clamp_progress(progress: f64) -> OptionResult<f64> {
150    ensure_finite("progress", progress)?;
151    let normalized = if progress.abs() > 1.0 {
152        progress / 100.0
153    } else {
154        progress
155    };
156    Ok(normalized.clamp(0.0, 1.0))
157}
158
159fn normalized_filter_set(values: Option<&[String]>) -> HashSet<String> {
160    values
161        .into_iter()
162        .flatten()
163        .map(|value| value.trim().to_ascii_lowercase())
164        .filter(|value| !value.is_empty())
165        .collect()
166}
167
168fn derived_leg_type(position: &OptionPosition) -> String {
169    position.leg_type()
170}
171
172fn order_side_for_action(position_side: &PositionSide, action: &ExecutionAction) -> OrderSide {
173    match (position_side, action) {
174        (PositionSide::Long, ExecutionAction::Open) => OrderSide::Buy,
175        (PositionSide::Long, ExecutionAction::Close) => OrderSide::Sell,
176        (PositionSide::Short, ExecutionAction::Open) => OrderSide::Sell,
177        (PositionSide::Short, ExecutionAction::Close) => OrderSide::Buy,
178    }
179}
180
181fn position_intent_for(side: &OrderSide, action: &ExecutionAction) -> PositionIntent {
182    match (side, action) {
183        (OrderSide::Buy, ExecutionAction::Open) => PositionIntent::BuyToOpen,
184        (OrderSide::Sell, ExecutionAction::Open) => PositionIntent::SellToOpen,
185        (OrderSide::Buy, ExecutionAction::Close) => PositionIntent::BuyToClose,
186        (OrderSide::Sell, ExecutionAction::Close) => PositionIntent::SellToClose,
187    }
188}
189
190fn format_snapshot_number(value: Option<f64>, name: &str) -> OptionResult<String> {
191    let rounded = round_price(quote_value(value, name)?)?;
192    Ok(rounded.to_string())
193}
194
195fn execution_snapshot(
196    snapshot: Option<&OptionSnapshot>,
197) -> OptionResult<Option<ExecutionSnapshot>> {
198    Ok(snapshot.map(ExecutionSnapshot::from))
199}
200
201fn execution_leg(
202    symbol: String,
203    leg_type: String,
204    quantity: u32,
205    side: OrderSide,
206    action: &ExecutionAction,
207    snapshot: Option<ExecutionSnapshot>,
208) -> ExecutionLeg {
209    ExecutionLeg {
210        symbol,
211        ratio_qty: quantity.to_string(),
212        position_intent: position_intent_for(&side, action),
213        side,
214        leg_type,
215        snapshot,
216    }
217}
218
219fn normalize_leg_type(leg_type: &str) -> Option<String> {
220    let normalized = leg_type.trim().to_ascii_lowercase();
221    let canonical = match normalized.as_str() {
222        "longcall" | "longcall_low" | "longcall_high" | "bwb_longcall_low"
223        | "bwb_longcall_high" => "longcall",
224        "shortcall" | "bwb_shortcall" => "shortcall",
225        "longput" | "diagonal_longput" => "longput",
226        "shortput" | "diagonal_shortput" => "shortput",
227        _ => return None,
228    };
229    Some(canonical.to_string())
230}
231
232fn normalize_execution_side(side: &str) -> Option<OrderSide> {
233    OrderSide::from_str(side).ok()
234}
235
236fn normalize_position_intent(position_intent: &str) -> Option<PositionIntent> {
237    PositionIntent::from_str(position_intent).ok()
238}
239
240fn leg_side_from_type(leg_type: &str, action: &ExecutionAction) -> Option<OrderSide> {
241    let normalized = normalize_leg_type(leg_type)?;
242    let is_long = normalized.starts_with("long");
243    Some(match (is_long, action) {
244        (true, ExecutionAction::Open) => OrderSide::Buy,
245        (false, ExecutionAction::Open) => OrderSide::Sell,
246        (true, ExecutionAction::Close) => OrderSide::Sell,
247        (false, ExecutionAction::Close) => OrderSide::Buy,
248    })
249}
250
251pub fn leg_type(
252    symbol: &str,
253    side: &str,
254    position_intent: &str,
255    explicit_leg_type: Option<&str>,
256) -> Option<String> {
257    if let Some(explicit_leg_type) = explicit_leg_type.and_then(normalize_leg_type) {
258        return Some(explicit_leg_type);
259    }
260
261    let side = normalize_execution_side(side)?;
262    let position_intent = normalize_position_intent(position_intent)?;
263    let parsed = contract::parse_occ_symbol(symbol)?;
264    let is_close = position_intent.is_close();
265    let is_long = match side {
266        OrderSide::Buy => !is_close,
267        OrderSide::Sell => is_close,
268    };
269
270    Some(format!(
271        "{}{}",
272        if is_long { "long" } else { "short" },
273        parsed.option_right.as_str()
274    ))
275}
276
277fn normalize_roll_quantity(qty: Option<i64>) -> u32 {
278    match qty {
279        Some(value) if value.is_positive() => value.unsigned_abs() as u32,
280        _ => 1,
281    }
282}
283
284pub fn roll_request(
285    current_contract: &str,
286    target_contract: Option<&str>,
287    new_strike: Option<f64>,
288    new_expiration: Option<&str>,
289    leg_type: Option<&str>,
290    qty: Option<i64>,
291) -> Option<RollRequest> {
292    let current_contract = current_contract.trim();
293    if current_contract.is_empty() {
294        return None;
295    }
296
297    let leg_type = match leg_type.map(str::trim).filter(|value| !value.is_empty()) {
298        Some(value) => Some(normalize_leg_type(value)?),
299        None => None,
300    };
301
302    let (new_strike, new_expiration) = match target_contract
303        .map(str::trim)
304        .filter(|value| !value.is_empty())
305    {
306        Some(target_contract) => {
307            let parsed = contract::parse_occ_symbol(target_contract)?;
308            (Some(parsed.strike), parsed.expiration_date)
309        }
310        None => {
311            let new_strike = new_strike.filter(|value| value.is_finite())?;
312            let new_expiration = clock::parse_date(new_expiration?.trim()).ok()?;
313            (Some(new_strike), new_expiration)
314        }
315    };
316
317    Some(RollRequest {
318        current_contract: current_contract.to_string(),
319        leg_type,
320        qty: normalize_roll_quantity(qty),
321        new_strike,
322        new_expiration,
323    })
324}
325
326fn direct_quote(input: &ExecutionLegInput) -> OptionQuote {
327    let bid = input.bid;
328    let ask = input.ask;
329    let price = input.price;
330
331    if bid.is_none() && ask.is_none() && price.is_none() {
332        return OptionQuote {
333            bid: None,
334            ask: None,
335            mark: None,
336            last: None,
337        };
338    }
339
340    if bid.is_none() && ask.is_none() {
341        let price = price.unwrap_or(0.0);
342        if let Some(spread_percent) = input
343            .spread_percent
344            .filter(|value| value.is_finite() && *value > 0.0)
345        {
346            let spread = (price * spread_percent).max(0.0);
347            return quote(&OptionQuote {
348                bid: Some(price - spread / 2.0),
349                ask: Some(price + spread / 2.0),
350                mark: Some(price),
351                last: Some(price),
352            });
353        }
354
355        return quote(&OptionQuote {
356            bid: Some(price),
357            ask: Some(price),
358            mark: Some(price),
359            last: Some(price),
360        });
361    }
362
363    quote(&OptionQuote {
364        bid,
365        ask,
366        mark: price,
367        last: price,
368    })
369}
370
371fn direct_execution_snapshot(input: &ExecutionLegInput) -> OptionResult<Option<ExecutionSnapshot>> {
372    if let Some(snapshot) = &input.snapshot {
373        return Ok(Some(snapshot.clone()));
374    }
375
376    let normalized_quote = direct_quote(input);
377    if normalized_quote.bid.is_none()
378        && normalized_quote.ask.is_none()
379        && normalized_quote.mark.is_none()
380    {
381        return Ok(None);
382    }
383
384    Ok(Some(ExecutionSnapshot {
385        contract: input.contract.clone(),
386        timestamp: input.timestamp.clone().unwrap_or_default(),
387        bid: format_snapshot_number(normalized_quote.bid, "bid")?,
388        ask: format_snapshot_number(normalized_quote.ask, "ask")?,
389        price: format_snapshot_number(normalized_quote.mark.or(normalized_quote.last), "price")?,
390        greeks: input
391            .greeks
392            .as_ref()
393            .map(|greeks| Greeks {
394                delta: greeks.delta.unwrap_or(0.0),
395                gamma: greeks.gamma.unwrap_or(0.0),
396                vega: greeks.vega.unwrap_or(0.0),
397                theta: greeks.theta.unwrap_or(0.0),
398                rho: greeks.rho.unwrap_or(0.0),
399            })
400            .unwrap_or(Greeks {
401                delta: 0.0,
402                gamma: 0.0,
403                vega: 0.0,
404                theta: 0.0,
405                rho: 0.0,
406            }),
407        iv: input.iv.unwrap_or(0.0),
408    }))
409}
410
411pub fn leg(input: ExecutionLegInput) -> Option<ExecutionLeg> {
412    let contract_info = contract::parse_occ_symbol(&input.contract)?;
413    let leg_type = normalize_leg_type(&input.leg_type)?;
414    if !leg_type.ends_with(contract_info.option_right.as_str()) {
415        return None;
416    }
417    let side = leg_side_from_type(&leg_type, &input.action)?;
418
419    Some(execution_leg(
420        input.contract.clone(),
421        leg_type,
422        input.quantity.unwrap_or(1).max(1),
423        side,
424        &input.action,
425        direct_execution_snapshot(&input).ok()?,
426    ))
427}
428
429fn normalized_selection_quantity(quantity: Option<u32>, position_quantity: u32) -> u32 {
430    match quantity {
431        Some(value) if value > 0 => value.min(position_quantity.max(1)),
432        _ => position_quantity.max(1),
433    }
434}
435
436fn range_from_positions(positions: &[OptionPosition]) -> OptionResult<ExecutionQuoteRange> {
437    let mut best = 0.0;
438    let mut worst = 0.0;
439
440    for position in positions {
441        let Some(snapshot) = position.snapshot_ref() else {
442            continue;
443        };
444        let (bid, ask) = quote_bid_ask(&snapshot.quote)?;
445        let quantity = position.quantity() as f64;
446        match position.position_side() {
447            PositionSide::Long => {
448                best += bid * quantity;
449                worst += ask * quantity;
450            }
451            PositionSide::Short => {
452                best -= ask * quantity;
453                worst -= bid * quantity;
454            }
455        }
456    }
457
458    Ok(ExecutionQuoteRange {
459        best_price: round_price(best)?,
460        worst_price: round_price(worst)?,
461    })
462}
463
464fn range_from_legs(legs: &[QuotedLeg]) -> OptionResult<ExecutionQuoteRange> {
465    let mut best = 0.0;
466    let mut worst = 0.0;
467
468    for leg in legs {
469        let (bid, ask) = quote_bid_ask(&leg.quote)?;
470        let quantity = leg.ratio_quantity as f64;
471        match leg.order_side {
472            OrderSide::Buy => {
473                best += bid * quantity;
474                worst += ask * quantity;
475            }
476            OrderSide::Sell => {
477                best -= ask * quantity;
478                worst -= bid * quantity;
479            }
480        }
481    }
482
483    Ok(ExecutionQuoteRange {
484        best_price: round_price(best)?,
485        worst_price: round_price(worst)?,
486    })
487}
488
489pub fn order_legs(
490    positions: &[OptionPosition],
491    action: &str,
492    include_leg_types: Option<&[String]>,
493    exclude_leg_types: Option<&[String]>,
494) -> OptionResult<Vec<ExecutionLeg>> {
495    let action = ExecutionAction::from_str(action)?;
496    let include_leg_types = normalized_filter_set(include_leg_types);
497    let exclude_leg_types = normalized_filter_set(exclude_leg_types);
498    let mut legs = Vec::new();
499
500    for position in positions {
501        if position.quantity() == 0 {
502            continue;
503        }
504
505        let leg_type = derived_leg_type(position);
506        let normalized_leg_type = leg_type.to_ascii_lowercase();
507        if !include_leg_types.is_empty() && !include_leg_types.contains(&normalized_leg_type) {
508            continue;
509        }
510        if exclude_leg_types.contains(&normalized_leg_type) {
511            continue;
512        }
513
514        let side = order_side_for_action(&position.position_side(), &action);
515        legs.push(execution_leg(
516            position.occ_symbol().to_string(),
517            leg_type,
518            position.quantity(),
519            side,
520            &action,
521            execution_snapshot(position.snapshot_ref())?,
522        ));
523    }
524
525    Ok(legs)
526}
527
528pub fn roll_legs(
529    positions: &[OptionPosition],
530    snapshots: &HashMap<String, ExecutionSnapshot>,
531    selections: &[RollLegSelection],
532) -> OptionResult<Vec<ExecutionLeg>> {
533    let mut positions_by_leg_type = HashMap::new();
534    for position in positions {
535        positions_by_leg_type.insert(derived_leg_type(position).to_ascii_lowercase(), position);
536    }
537
538    let mut snapshots_by_leg_type = HashMap::new();
539    for (leg_type, snapshot) in snapshots {
540        snapshots_by_leg_type.insert(leg_type.trim().to_ascii_lowercase(), snapshot.clone());
541    }
542
543    let mut legs = Vec::new();
544    for selection in selections {
545        let normalized_leg_type = selection.leg_type.trim().to_ascii_lowercase();
546        let Some(position) = positions_by_leg_type.get(&normalized_leg_type) else {
547            continue;
548        };
549        let Some(snapshot) = snapshots_by_leg_type.get(&normalized_leg_type) else {
550            continue;
551        };
552
553        let quantity = normalized_selection_quantity(selection.quantity, position.quantity());
554        let close_side = order_side_for_action(&position.position_side(), &ExecutionAction::Close);
555        legs.push(execution_leg(
556            position.occ_symbol().to_string(),
557            normalized_leg_type.clone(),
558            quantity,
559            close_side,
560            &ExecutionAction::Close,
561            execution_snapshot(position.snapshot_ref())?,
562        ));
563
564        let open_side = order_side_for_action(&position.position_side(), &ExecutionAction::Open);
565        legs.push(execution_leg(
566            snapshot.contract.clone(),
567            normalized_leg_type,
568            quantity,
569            open_side,
570            &ExecutionAction::Open,
571            Some(snapshot.clone()),
572        ));
573    }
574
575    Ok(legs)
576}
577
578pub fn best_worst(
579    source: &(impl QuoteRangeLike + ?Sized),
580    structure_quantity: Option<u32>,
581) -> OptionResult<ScaledExecutionQuoteRange> {
582    let per_structure = source.quote_range()?;
583    scale_quote_range(
584        per_structure.best_price,
585        per_structure.worst_price,
586        structure_quantity.unwrap_or(1),
587    )
588}
589
590pub fn scale_quote(price: f64, structure_quantity: u32) -> OptionResult<ScaledExecutionQuote> {
591    ensure_finite("price", price)?;
592    let structure_quantity_f64 = structure_quantity as f64;
593    let normalized_price = round_price(price)?;
594    let total_price = round_price(normalized_price * structure_quantity_f64)?;
595
596    Ok(ScaledExecutionQuote {
597        structure_quantity,
598        price: normalized_price,
599        total_price,
600        total_dollars: round_price(total_price * CONTRACT_MULTIPLIER)?,
601    })
602}
603
604pub fn scale_quote_range(
605    best_price: f64,
606    worst_price: f64,
607    structure_quantity: u32,
608) -> OptionResult<ScaledExecutionQuoteRange> {
609    ensure_finite("best_price", best_price)?;
610    ensure_finite("worst_price", worst_price)?;
611    let structure_quantity_f64 = structure_quantity as f64;
612    let per_structure = ExecutionQuoteRange {
613        best_price: round_price(best_price)?,
614        worst_price: round_price(worst_price)?,
615    };
616    let per_order = ExecutionQuoteRange {
617        best_price: round_price(per_structure.best_price * structure_quantity_f64)?,
618        worst_price: round_price(per_structure.worst_price * structure_quantity_f64)?,
619    };
620    let dollars = ExecutionQuoteRange {
621        best_price: round_price(per_order.best_price * CONTRACT_MULTIPLIER)?,
622        worst_price: round_price(per_order.worst_price * CONTRACT_MULTIPLIER)?,
623    };
624
625    Ok(ScaledExecutionQuoteRange {
626        structure_quantity,
627        per_structure,
628        per_order,
629        dollars,
630    })
631}
632
633pub fn limit_quote_by_progress(
634    best_price: f64,
635    worst_price: f64,
636    progress: f64,
637) -> OptionResult<f64> {
638    ensure_finite("best_price", best_price)?;
639    ensure_finite("worst_price", worst_price)?;
640    let progress = clamp_progress(progress)?;
641    round_price(best_price + (worst_price - best_price) * progress)
642}
643
644pub fn progress_of_limit(best_price: f64, worst_price: f64, limit_price: f64) -> OptionResult<f64> {
645    ensure_finite("best_price", best_price)?;
646    ensure_finite("worst_price", worst_price)?;
647    ensure_finite("limit_price", limit_price)?;
648    if (worst_price - best_price).abs() < 1e-12 {
649        return Ok(0.5);
650    }
651    numeric::round(
652        ((limit_price - best_price) / (worst_price - best_price)).clamp(0.0, 1.0),
653        12,
654    )
655    .map_err(|_| {
656        OptionError::new(
657            "invalid_execution_quote_input",
658            "unable to normalize limit progress",
659        )
660    })
661}