1use anyhow::{Context, Result};
2use chrono::NaiveDate;
3use schwab_market_data::endpoints::chains::ChainQuery;
4use schwab_market_data::MarketDataApi;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7
8use crate::options::{
9 days_to_expiry, group_option_legs, list_option_positions, position_group_id,
10 spread_contract_count, OptionPositionGroup, OptionPositionLeg,
11};
12use crate::rules::{ExitRules, RulesConfig};
13
14use super::market_context::vertical_open_position_context;
15use super::state::{AgentState, TrackedPosition};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SpreadMark {
19 pub entry_credit: f64,
20 pub debit_to_close: f64,
21 pub profit_pct: f64,
22 pub dte: i64,
23 pub source: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExitEvaluation {
28 pub reason: String,
29 pub mark: SpreadMark,
30}
31
32pub fn stable_position_key(account_hash: &str, group: &OptionPositionGroup) -> String {
33 position_group_id(account_hash, group)
34}
35
36pub fn find_tracked_position<'a>(
37 state: &'a AgentState,
38 account_hash: &str,
39 group: &OptionPositionGroup,
40) -> Option<&'a TrackedPosition> {
41 let stable_key = stable_position_key(account_hash, group);
42 state
43 .open_positions
44 .get(&stable_key)
45 .or_else(|| state.open_positions.get(&group.id))
46 .or_else(|| {
47 state.open_positions.values().find(|p| {
48 p.account_hash == account_hash
49 && p.underlying == group.underlying
50 && p.expiry == group.expiry
51 })
52 })
53}
54
55pub fn infer_entry_credit_from_legs(legs: &[OptionPositionLeg]) -> Option<f64> {
56 if legs.len() != 2 {
57 return None;
58 }
59 let mut short_premium = None;
60 let mut long_premium = None;
61 for leg in legs {
62 let avg = leg.average_price?;
63 if leg.quantity < 0.0 {
64 short_premium = Some(avg.abs());
65 } else if leg.quantity > 0.0 {
66 long_premium = Some(avg.abs());
67 }
68 }
69 match (short_premium, long_premium) {
70 (Some(s), Some(l)) => Some((s - l).max(0.0)),
71 (Some(s), None) => Some(s),
72 _ => None,
73 }
74}
75
76#[derive(Debug, Clone)]
77pub struct PositionMonitorResult {
78 pub exit: Option<ExitEvaluation>,
79 pub snapshot: Value,
80}
81
82struct VerticalChainSnapshot {
83 chain: Value,
84 strike_map: Value,
85 short_strike: f64,
86 long_strike: f64,
87 is_put: bool,
88 debit_to_close: f64,
89}
90
91pub async fn evaluate_position_monitor(
93 market: &MarketDataApi,
94 group: &OptionPositionGroup,
95 rules: &RulesConfig,
96 today: NaiveDate,
97 tracked: Option<&TrackedPosition>,
98) -> Result<PositionMonitorResult> {
99 let entry_credit = tracked
100 .and_then(|p| p.entry_credit)
101 .or_else(|| infer_entry_credit_from_legs(&group.legs));
102
103 let dte = group
104 .legs
105 .first()
106 .and_then(|l| l.parsed.as_ref())
107 .map(|p| days_to_expiry(p.expiry, today))
108 .unwrap_or(0);
109
110 let chain_result = fetch_vertical_chain_snapshot(market, group).await;
111
112 let (exit, mark_opt, market_context, chain_error) = match chain_result {
113 Ok(chain_snap) => {
114 let profit_pct = entry_credit
115 .filter(|c| *c > f64::EPSILON)
116 .map(|entry| ((entry - chain_snap.debit_to_close) / entry) * 100.0);
117 let mark = SpreadMark {
118 entry_credit: entry_credit.unwrap_or(0.0),
119 debit_to_close: chain_snap.debit_to_close,
120 profit_pct: profit_pct.unwrap_or(0.0),
121 dte,
122 source: "chain".into(),
123 };
124 let exit = evaluate_exit_from_mark(rules, entry_credit, &mark);
125 let expiry_date = chrono::NaiveDate::parse_from_str(&group.expiry, "%Y-%m-%d")
126 .ok()
127 .or_else(|| {
128 group
129 .legs
130 .first()
131 .and_then(|l| l.parsed.as_ref())
132 .map(|p| p.expiry)
133 })
134 .unwrap_or(today);
135 let ctx = vertical_open_position_context(
136 &chain_snap.chain,
137 &group.underlying,
138 today,
139 expiry_date,
140 &chain_snap.strike_map,
141 chain_snap.short_strike,
142 chain_snap.long_strike,
143 chain_snap.is_put,
144 entry_credit,
145 Some(chain_snap.debit_to_close),
146 profit_pct,
147 dte,
148 );
149 (exit, Some(mark), Some(ctx), None)
150 }
151 Err(e) => {
152 let exit = if let Some(credit) = entry_credit.filter(|c| *c > 0.0) {
153 evaluate_dte_only_with_credit(group, rules, today, credit, dte)?
154 } else {
155 evaluate_dte_only(group, rules, today)?
156 };
157 (exit, None, None, Some(e.to_string()))
158 }
159 };
160
161 let snapshot = monitor_snapshot_json(
162 group,
163 tracked,
164 &exit,
165 mark_opt.as_ref(),
166 market_context,
167 chain_error.as_deref(),
168 &rules.exit_rules,
169 );
170 Ok(PositionMonitorResult { exit, snapshot })
171}
172
173fn evaluate_exit_from_mark(
174 rules: &RulesConfig,
175 entry_credit: Option<f64>,
176 mark: &SpreadMark,
177) -> Option<ExitEvaluation> {
178 let entry_credit = entry_credit.filter(|c| *c > f64::EPSILON)?;
179 let mark = SpreadMark {
180 entry_credit,
181 ..mark.clone()
182 };
183
184 if mark.profit_pct >= rules.exit_rules.profit_target_pct {
185 return Some(ExitEvaluation {
186 reason: "profit_target".into(),
187 mark,
188 });
189 }
190
191 let stop_debit = entry_credit * (rules.exit_rules.stop_loss_pct / 100.0);
192 if mark.debit_to_close >= stop_debit {
193 return Some(ExitEvaluation {
194 reason: "stop_loss".into(),
195 mark,
196 });
197 }
198
199 if mark.dte <= rules.exit_rules.dte_close as i64 {
200 return Some(ExitEvaluation {
201 reason: "dte_close".into(),
202 mark,
203 });
204 }
205
206 None
207}
208
209async fn fetch_vertical_chain_snapshot(
210 market: &MarketDataApi,
211 group: &OptionPositionGroup,
212) -> Result<VerticalChainSnapshot> {
213 let (short_leg, long_leg) = vertical_legs(group)?;
214 let short_strike = short_leg
215 .parsed
216 .as_ref()
217 .map(|p| p.strike)
218 .context("short leg missing strike")?;
219 let long_strike = long_leg
220 .parsed
221 .as_ref()
222 .map(|p| p.strike)
223 .context("long leg missing strike")?;
224 let is_put = short_leg.parsed.as_ref().is_some_and(|p| p.put_call == 'P');
225
226 let contract_type = if is_put { "PUT" } else { "CALL" };
227 let map_key = if is_put {
228 "putExpDateMap"
229 } else {
230 "callExpDateMap"
231 };
232
233 let mut last_err = None;
234 for strike_count in [50u32, 100] {
235 match fetch_vertical_chain_at_strikes(
236 market,
237 group,
238 contract_type,
239 map_key,
240 short_strike,
241 long_strike,
242 is_put,
243 strike_count,
244 )
245 .await
246 {
247 Ok(snap) => return Ok(snap),
248 Err(e) => last_err = Some(e),
249 }
250 }
251
252 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chain fetch failed")))
253}
254
255#[allow(clippy::too_many_arguments)]
256async fn fetch_vertical_chain_at_strikes(
257 market: &MarketDataApi,
258 group: &OptionPositionGroup,
259 contract_type: &str,
260 map_key: &str,
261 short_strike: f64,
262 long_strike: f64,
263 is_put: bool,
264 strike_count: u32,
265) -> Result<VerticalChainSnapshot> {
266 let strike_anchor = format_chain_strike(short_strike);
267 let chain = market
268 .chains()
269 .get(&ChainQuery {
270 symbol: &group.underlying,
271 contract_type: Some(contract_type),
272 strike: Some(&strike_anchor),
273 strike_count: Some(strike_count),
274 include_underlying_quote: Some(true),
275 from_date: Some(&group.expiry),
276 to_date: Some(&group.expiry),
277 ..Default::default()
278 })
279 .await?;
280
281 let strike_map =
282 find_expiry_strikes(&chain, map_key, &group.expiry).context("expiry not found in chain")?;
283
284 let short_ask = strike_quote_field(&strike_map, short_strike, "ask")?;
285 let long_bid = strike_quote_field(&strike_map, long_strike, "bid")?;
286 let debit_to_close = (short_ask - long_bid).max(0.0);
287
288 Ok(VerticalChainSnapshot {
289 chain,
290 strike_map,
291 short_strike,
292 long_strike,
293 is_put,
294 debit_to_close,
295 })
296}
297
298fn format_chain_strike(strike: f64) -> String {
299 if (strike.fract() * 10.0).round() as i64 % 10 == 0 {
300 format!("{strike:.1}")
301 } else {
302 format!("{strike:.2}")
303 }
304}
305
306pub fn monitor_snapshot_json(
307 group: &OptionPositionGroup,
308 tracked: Option<&TrackedPosition>,
309 exit_eval: &Option<ExitEvaluation>,
310 mark: Option<&SpreadMark>,
311 market_context: Option<Value>,
312 chain_error: Option<&str>,
313 exit_rules: &ExitRules,
314) -> Value {
315 let entry_credit = tracked
316 .and_then(|p| p.entry_credit)
317 .or_else(|| infer_entry_credit_from_legs(&group.legs));
318
319 let status = match exit_eval {
320 Some(e) => format!("exit: {}", e.reason),
321 None => "holding".into(),
322 };
323
324 let contracts = tracked
325 .map(|p| p.contracts.max(1))
326 .unwrap_or_else(|| spread_contract_count(group));
327
328 let mut snapshot = json!({
329 "position_id": tracked
330 .map(|t| t.position_id.as_str())
331 .unwrap_or(group.id.as_str()),
332 "legacy_position_id": group.id,
333 "underlying": group.underlying,
334 "expiry": group.expiry,
335 "strategy": tracked
336 .map(|t| t.strategy.as_str())
337 .unwrap_or_else(|| group.strategy_hint.as_str()),
338 "contracts": contracts,
339 "entry_credit": entry_credit,
340 "max_loss_usd": tracked.map(|p| p.max_loss_usd),
341 "net_market_value": group.net_market_value,
342 "status": status,
343 });
344
345 if let Some(eval) = exit_eval {
346 snapshot["profit_pct"] = json!(eval.mark.profit_pct);
347 snapshot["dte"] = json!(eval.mark.dte);
348 snapshot["debit_to_close"] = json!(eval.mark.debit_to_close);
349 } else if let Some(m) = mark {
350 snapshot["profit_pct"] = json!(m.profit_pct);
351 snapshot["dte"] = json!(m.dte);
352 snapshot["debit_to_close"] = json!(m.debit_to_close);
353 }
354
355 if let Some(ctx) = market_context {
356 snapshot["market_context"] = ctx;
357 } else if let Some(err) = chain_error {
358 snapshot["market_context_error"] = json!(err);
359 snapshot["market_context_note"] = json!(
360 "Live chain greeks unavailable; mechanical exits still use chain debit when fetch succeeds on exit ticks."
361 );
362 }
363
364 if let Some(m) = mark.or(exit_eval.as_ref().map(|e| &e.mark)) {
365 let entry = m.entry_credit;
366 let stop_debit = entry * (exit_rules.stop_loss_pct / 100.0);
367 snapshot["mechanical_rules"] = json!({
368 "profit_target_pct": exit_rules.profit_target_pct,
369 "stop_loss_pct": exit_rules.stop_loss_pct,
370 "stop_debit_threshold_per_share": stop_debit,
371 "current_debit_to_close": m.debit_to_close,
372 "stop_triggered": m.debit_to_close >= stop_debit,
373 "profit_target_triggered": m.profit_pct >= exit_rules.profit_target_pct,
374 "note": "Mechanical exits use debit_to_close from the chain, NOT net_market_value. If stop_triggered is false, do not alert that the stop was hit."
375 });
376 }
377
378 snapshot["net_market_value_note"] = json!(
379 "Schwab leg market_value sum in dollars; not comparable to per-share entry_credit or stop_debit_threshold."
380 );
381
382 snapshot
383}
384
385fn evaluate_dte_only(
386 group: &OptionPositionGroup,
387 rules: &RulesConfig,
388 today: NaiveDate,
389) -> Result<Option<ExitEvaluation>> {
390 let dte = group
391 .legs
392 .first()
393 .and_then(|l| l.parsed.as_ref())
394 .map(|p| days_to_expiry(p.expiry, today))
395 .unwrap_or(0);
396 if dte > rules.exit_rules.dte_close as i64 {
397 return Ok(None);
398 }
399 Ok(Some(ExitEvaluation {
400 reason: "dte_close".into(),
401 mark: SpreadMark {
402 entry_credit: 0.0,
403 debit_to_close: 0.0,
404 profit_pct: 0.0,
405 dte,
406 source: "dte_only".into(),
407 },
408 }))
409}
410
411fn evaluate_dte_only_with_credit(
412 _group: &OptionPositionGroup,
413 rules: &RulesConfig,
414 _today: NaiveDate,
415 entry_credit: f64,
416 dte: i64,
417) -> Result<Option<ExitEvaluation>> {
418 if dte > rules.exit_rules.dte_close as i64 {
419 return Ok(None);
420 }
421 Ok(Some(ExitEvaluation {
422 reason: "dte_close".into(),
423 mark: SpreadMark {
424 entry_credit,
425 debit_to_close: 0.0,
426 profit_pct: 0.0,
427 dte,
428 source: "dte_fallback".into(),
429 },
430 }))
431}
432
433fn vertical_legs(group: &OptionPositionGroup) -> Result<(&OptionPositionLeg, &OptionPositionLeg)> {
434 let short = group
435 .legs
436 .iter()
437 .find(|l| l.quantity < 0.0)
438 .context("no short leg")?;
439 let long = group
440 .legs
441 .iter()
442 .find(|l| l.quantity > 0.0)
443 .context("no long leg")?;
444 Ok((short, long))
445}
446
447fn find_expiry_strikes(chain: &Value, map_key: &str, expiry: &str) -> Result<Value> {
448 let map = chain
449 .get(map_key)
450 .context("chain missing exp date map")?
451 .as_object()
452 .context("exp date map not an object")?;
453
454 for (key, strikes) in map {
455 let date_part = key.split(':').next().unwrap_or(key);
456 if date_part == expiry || key.starts_with(expiry) {
457 return Ok(strikes.clone());
458 }
459 }
460 anyhow::bail!("expiry {expiry} not in chain")
461}
462
463fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
464 for key in strike_key_candidates(strike) {
465 if let Some(val) = strike_map
466 .get(&key)
467 .and_then(|contracts| contracts.as_array()?.first())
468 .and_then(|c| c.get(field))
469 .and_then(|v| v.as_f64())
470 {
471 return Ok(val);
472 }
473 }
474 anyhow::bail!("missing {field} for strike {strike}")
475}
476
477fn strike_key_candidates(strike: f64) -> Vec<String> {
478 vec![
479 format!("{strike:.1}"),
480 format!("{strike:.0}"),
481 strike.to_string(),
482 ]
483}
484
485pub fn exit_signal_json_for_account(
486 account_hash: &str,
487 group: &OptionPositionGroup,
488 eval: &ExitEvaluation,
489) -> Value {
490 let position_id = stable_position_key(account_hash, group);
491 json!({
492 "type": "exit",
493 "reason": eval.reason,
494 "position_id": position_id,
495 "legacy_position_id": group.id,
496 "underlying": group.underlying,
497 "expiry": group.expiry,
498 "mark": eval.mark,
499 })
500}
501
502pub async fn reconcile_open_positions(
503 trader: &schwab_api::TraderApi,
504 state: &mut AgentState,
505 rules: &RulesConfig,
506) -> Result<()> {
507 let mut live_keys = std::collections::HashSet::new();
508 for account in rules.enabled_accounts() {
509 let legs = list_option_positions(trader, Some(&account.hash)).await?;
510 let groups = group_option_legs(&legs);
511 for group in groups {
512 let stable_id = stable_position_key(&account.hash, &group);
513 live_keys.insert(stable_id.clone());
514 let live_contracts = spread_contract_count(&group);
515 let entry_credit = infer_entry_credit_from_legs(&group.legs);
516 let inferred_max_loss = infer_max_loss_from_group(&group);
517
518 if let Some(mut tracked) =
519 take_existing_tracked_position(state, &stable_id, &account.hash, &group)
520 {
521 tracked.position_id = stable_id.clone();
522 tracked.account_hash = account.hash.clone();
523 let prev_contracts = tracked.contracts.max(1);
524 if let Some(max_loss) = inferred_max_loss {
525 tracked.max_loss_usd = max_loss;
526 } else if live_contracts != prev_contracts && tracked.max_loss_usd > 0.0 {
527 let per_contract = tracked.max_loss_usd / prev_contracts as f64;
528 tracked.max_loss_usd = per_contract * live_contracts as f64;
529 }
530 tracked.contracts = live_contracts;
531 if entry_credit.is_some() {
532 tracked.entry_credit = entry_credit;
533 }
534 state.open_positions.insert(stable_id, tracked);
535 } else {
536 state.open_positions.insert(
537 stable_id.clone(),
538 TrackedPosition {
539 position_id: stable_id,
540 account_hash: account.hash.clone(),
541 underlying: group.underlying.clone(),
542 expiry: group.expiry.clone(),
543 strategy: group.strategy_hint.clone(),
544 opened_at: chrono::Utc::now(),
545 entry_credit,
546 max_loss_usd: inferred_max_loss.unwrap_or(0.0),
547 contracts: live_contracts,
548 },
549 );
550 }
551 }
552 }
553 state.open_positions.retain(|id, _| live_keys.contains(id));
554 Ok(())
555}
556
557fn take_existing_tracked_position(
558 state: &mut AgentState,
559 stable_id: &str,
560 account_hash: &str,
561 group: &OptionPositionGroup,
562) -> Option<TrackedPosition> {
563 if let Some(tracked) = state.open_positions.remove(stable_id) {
564 return Some(tracked);
565 }
566 if let Some(tracked) = state.open_positions.remove(&group.id) {
567 return Some(tracked);
568 }
569 let key = state.open_positions.iter().find_map(|(key, tracked)| {
570 (tracked.account_hash == account_hash
571 && tracked.underlying == group.underlying
572 && tracked.expiry == group.expiry
573 && tracked.strategy == group.strategy_hint)
574 .then(|| key.clone())
575 })?;
576 state.open_positions.remove(&key)
577}
578
579pub fn infer_max_loss_from_group(group: &OptionPositionGroup) -> Option<f64> {
580 let contracts = spread_contract_count(group) as f64;
581 let entry_credit = infer_entry_credit_from_legs(&group.legs).unwrap_or(0.0);
582 match group.strategy_hint.as_str() {
583 "vertical" => {
584 let (short, long) = vertical_legs(group).ok()?;
585 let short_strike = short.parsed.as_ref()?.strike;
586 let long_strike = long.parsed.as_ref()?.strike;
587 let width = (short_strike - long_strike).abs();
588 Some((width - entry_credit).max(0.0) * 100.0 * contracts)
589 }
590 "iron_condor" => {
591 let put_width = wing_width(group, 'P')?;
592 let call_width = wing_width(group, 'C')?;
593 Some((put_width.max(call_width) - entry_credit).max(0.0) * 100.0 * contracts)
594 }
595 _ => None,
596 }
597}
598
599fn wing_width(group: &OptionPositionGroup, put_call: char) -> Option<f64> {
600 let mut short = None;
601 let mut long = None;
602 for leg in &group.legs {
603 let parsed = leg.parsed.as_ref()?;
604 if parsed.put_call != put_call {
605 continue;
606 }
607 if leg.quantity < 0.0 {
608 short = Some(parsed.strike);
609 } else if leg.quantity > 0.0 {
610 long = Some(parsed.strike);
611 }
612 }
613 Some((short? - long?).abs())
614}
615
616pub fn exit_rules_summary(rules: &ExitRules) -> Value {
617 json!({
618 "profit_target_pct": rules.profit_target_pct,
619 "stop_loss_pct": rules.stop_loss_pct,
620 "dte_close": rules.dte_close,
621 })
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use crate::rules::{ExitRules, RulesConfig};
628
629 #[test]
630 fn evaluate_exit_from_mark_profit_target() {
631 let exit_rules = ExitRules {
632 profit_target_pct: 50.0,
633 stop_loss_pct: 200.0,
634 dte_close: 21,
635 };
636 let rules = RulesConfig {
637 version: 1,
638 agent_id: "t".into(),
639 accounts: vec![],
640 schedule: Default::default(),
641 strategies: Default::default(),
642 watchlist: vec![],
643 entry_rules: Default::default(),
644 exit_rules,
645 risk: Default::default(),
646 execution: Default::default(),
647 llm: Default::default(),
648 notify: Default::default(),
649 };
650 let mark = SpreadMark {
651 entry_credit: 0.25,
652 debit_to_close: 0.10,
653 profit_pct: 60.0,
654 dte: 30,
655 source: "test".into(),
656 };
657 let exit = evaluate_exit_from_mark(&rules, Some(0.25), &mark);
658 assert_eq!(
659 exit.as_ref().map(|e| e.reason.as_str()),
660 Some("profit_target")
661 );
662 }
663
664 #[test]
665 fn profit_target_triggers_at_half_credit() {
666 let entry = 0.29;
667 let debit = 0.14;
668 let profit_pct = ((entry - debit) / entry) * 100.0;
669 assert!(profit_pct >= 50.0);
670 }
671
672 #[test]
673 fn stop_loss_triggers_at_double_credit() {
674 let entry = 0.29;
675 let stop_debit = entry * 2.0;
676 assert!(0.58 >= stop_debit - 0.001);
677 }
678
679 #[test]
680 fn infers_credit_from_leg_averages() {
681 let legs = vec![
682 OptionPositionLeg {
683 symbol: "IWM".into(),
684 underlying: "IWM".into(),
685 quantity: -1.0,
686 market_value: -100.0,
687 average_price: Some(0.29),
688 parsed: None,
689 },
690 OptionPositionLeg {
691 symbol: "IWM".into(),
692 underlying: "IWM".into(),
693 quantity: 1.0,
694 market_value: 50.0,
695 average_price: Some(0.05),
696 parsed: None,
697 },
698 ];
699 let credit = infer_entry_credit_from_legs(&legs).unwrap();
700 assert!((credit - 0.24).abs() < 0.001);
701 }
702
703 #[test]
704 fn infers_vertical_max_loss_from_live_group() {
705 let group = OptionPositionGroup {
706 id: "IWM|2026-07-31".into(),
707 underlying: "IWM".into(),
708 expiry: "2026-07-31".into(),
709 strategy_hint: "vertical".into(),
710 legs: vec![
711 OptionPositionLeg {
712 symbol: "IWM 260731P00282000".into(),
713 underlying: "IWM".into(),
714 quantity: -2.0,
715 market_value: -64.0,
716 average_price: Some(0.29),
717 parsed: crate::options::symbology::parse_option_symbol("IWM 260731P00282000")
718 .ok(),
719 },
720 OptionPositionLeg {
721 symbol: "IWM 260731P00280000".into(),
722 underlying: "IWM".into(),
723 quantity: 2.0,
724 market_value: 10.0,
725 average_price: Some(0.05),
726 parsed: crate::options::symbology::parse_option_symbol("IWM 260731P00280000")
727 .ok(),
728 },
729 ],
730 net_market_value: -54.0,
731 };
732 let max_loss = infer_max_loss_from_group(&group).unwrap();
733 assert!((max_loss - 352.0).abs() < 0.01);
734 }
735
736 #[test]
737 fn find_expiry_strikes_matches_schwab_key() {
738 let chain = json!({
739 "putExpDateMap": {
740 "2026-07-31:36": { "282.0": [] }
741 }
742 });
743 let strikes = find_expiry_strikes(&chain, "putExpDateMap", "2026-07-31").unwrap();
744 assert!(strikes.is_object());
745 }
746
747 #[test]
748 fn format_chain_strike_uses_one_decimal_for_whole_strikes() {
749 assert_eq!(format_chain_strike(282.0), "282.0");
750 assert_eq!(format_chain_strike(282.5), "282.50");
751 }
752}