1use chrono::{DateTime, Utc};
11use fnv::FnvHashMap;
12use rust_decimal::Decimal;
13use rustrade_instrument::instrument::name::InstrumentNameExchange;
14use serde::{Deserialize, Serialize};
15use std::{collections::BTreeMap, fmt::Debug};
16
17#[non_exhaustive]
29#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub struct Shortability {
31 pub shortable: Option<bool>,
33 pub easy_to_borrow: Option<bool>,
35 pub available: Option<Decimal>,
38 pub fee_rate: Option<Decimal>,
45 pub delisting_at: Option<DateTime<Utc>>,
53}
54
55impl Shortability {
56 pub fn new() -> Self {
58 Self::default()
59 }
60
61 #[must_use]
63 pub fn with_shortable(mut self, shortable: bool) -> Self {
64 self.shortable = Some(shortable);
65 self
66 }
67
68 #[must_use]
70 pub fn with_easy_to_borrow(mut self, easy_to_borrow: bool) -> Self {
71 self.easy_to_borrow = Some(easy_to_borrow);
72 self
73 }
74
75 #[must_use]
77 pub fn with_available(mut self, available: Decimal) -> Self {
78 self.available = Some(available);
79 self
80 }
81
82 #[must_use]
84 pub fn with_fee_rate(mut self, fee_rate: Decimal) -> Self {
85 self.fee_rate = Some(fee_rate);
86 self
87 }
88
89 #[must_use]
91 pub fn with_delisting_at(mut self, delisting_at: DateTime<Utc>) -> Self {
92 self.delisting_at = Some(delisting_at);
93 self
94 }
95}
96
97pub trait ShortabilityProvider: Debug + Send + Sync {
105 fn shortability(
107 &self,
108 instrument: &InstrumentNameExchange,
109 time: DateTime<Utc>,
110 ) -> Shortability;
111
112 fn short_sale_restricted(
119 &self,
120 _instrument: &InstrumentNameExchange,
121 _time: DateTime<Utc>,
122 ) -> bool {
123 false
124 }
125}
126
127#[derive(Debug, Clone, Default)]
134pub struct ShortabilityTable {
135 shortability: FnvHashMap<InstrumentNameExchange, BTreeMap<DateTime<Utc>, Shortability>>,
136 restrictions: FnvHashMap<InstrumentNameExchange, Vec<(DateTime<Utc>, DateTime<Utc>)>>,
137}
138
139impl ShortabilityTable {
140 pub fn new() -> Self {
142 Self::default()
143 }
144
145 #[must_use]
148 pub fn with_shortability(
149 mut self,
150 instrument: InstrumentNameExchange,
151 from: DateTime<Utc>,
152 shortability: Shortability,
153 ) -> Self {
154 self.shortability
155 .entry(instrument)
156 .or_default()
157 .insert(from, shortability);
158 self
159 }
160
161 #[must_use]
168 pub fn with_short_sale_restriction(
169 mut self,
170 instrument: InstrumentNameExchange,
171 from: DateTime<Utc>,
172 until: DateTime<Utc>,
173 ) -> Self {
174 assert!(
175 from < until,
176 "a short-sale restriction on {instrument} must end after it starts: {from} to {until}"
177 );
178 self.restrictions
179 .entry(instrument)
180 .or_default()
181 .push((from, until));
182 self
183 }
184}
185
186impl ShortabilityProvider for ShortabilityTable {
187 fn shortability(
188 &self,
189 instrument: &InstrumentNameExchange,
190 time: DateTime<Utc>,
191 ) -> Shortability {
192 self.shortability
193 .get(instrument)
194 .and_then(|series| series.range(..=time).next_back())
195 .map(|(_, shortability)| shortability.clone())
196 .unwrap_or_default()
197 }
198
199 fn short_sale_restricted(
200 &self,
201 instrument: &InstrumentNameExchange,
202 time: DateTime<Utc>,
203 ) -> bool {
204 self.restrictions.get(instrument).is_some_and(|windows| {
205 windows
206 .iter()
207 .any(|(from, until)| *from <= time && time < *until)
208 })
209 }
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used)] mod tests {
215 use super::*;
216 use rust_decimal_macros::dec;
217
218 fn utc(text: &str) -> DateTime<Utc> {
219 DateTime::parse_from_rfc3339(text).unwrap().to_utc()
220 }
221
222 fn name() -> InstrumentNameExchange {
223 InstrumentNameExchange::new("AAPL")
224 }
225
226 #[test]
227 fn shortability_starts_unknown_and_each_setter_fills_one_field() {
228 assert_eq!(
229 Shortability::new(),
230 Shortability {
231 shortable: None,
232 easy_to_borrow: None,
233 available: None,
234 fee_rate: None,
235 delisting_at: None,
236 }
237 );
238
239 let full = Shortability::new()
240 .with_shortable(true)
241 .with_easy_to_borrow(false)
242 .with_available(dec!(1500))
243 .with_fee_rate(dec!(0.12))
244 .with_delisting_at(utc("2026-10-20T00:00:00Z"));
245 assert_eq!(full.shortable, Some(true));
246 assert_eq!(full.easy_to_borrow, Some(false));
247 assert_eq!(full.available, Some(dec!(1500)));
248 assert_eq!(full.fee_rate, Some(dec!(0.12)));
249 assert_eq!(full.delisting_at, Some(utc("2026-10-20T00:00:00Z")));
250 }
251
252 #[test]
253 fn shortability_round_trips_through_serde_and_reads_missing_fields_as_unknown() {
254 let full = Shortability::new()
255 .with_shortable(true)
256 .with_available(dec!(10))
257 .with_delisting_at(utc("2026-10-20T00:00:00Z"));
258 let json = serde_json::to_string(&full).unwrap();
259 assert_eq!(serde_json::from_str::<Shortability>(&json).unwrap(), full);
260
261 let sparse: Shortability = serde_json::from_str(r#"{"shortable":false}"#).unwrap();
262 assert_eq!(sparse, Shortability::new().with_shortable(false));
263 }
264
265 #[test]
266 fn a_table_steps_at_each_entry_and_knows_nothing_before_the_first() {
267 let hard = Shortability::new()
268 .with_shortable(true)
269 .with_easy_to_borrow(false);
270 let closed = Shortability::new().with_shortable(false);
271 let table = ShortabilityTable::new()
273 .with_shortability(name(), utc("2026-01-03T00:00:00Z"), closed.clone())
274 .with_shortability(name(), utc("2026-01-01T00:00:00Z"), hard.clone());
275
276 let at = |text| table.shortability(&name(), utc(text));
277 assert_eq!(at("2025-12-31T23:59:59Z"), Shortability::new());
278 assert_eq!(at("2026-01-01T00:00:00Z"), hard);
279 assert_eq!(at("2026-01-02T23:59:59Z"), hard);
280 assert_eq!(at("2026-01-03T00:00:00Z"), closed);
281 assert_eq!(
282 table.shortability(
283 &InstrumentNameExchange::new("MSFT"),
284 utc("2026-01-03T00:00:00Z")
285 ),
286 Shortability::new()
287 );
288 }
289
290 #[test]
291 fn a_later_entry_at_the_same_instant_replaces_the_earlier() {
292 let instant = utc("2026-01-01T00:00:00Z");
293 let table = ShortabilityTable::new()
294 .with_shortability(name(), instant, Shortability::new().with_shortable(true))
295 .with_shortability(name(), instant, Shortability::new().with_shortable(false));
296
297 assert_eq!(table.shortability(&name(), instant).shortable, Some(false));
298 }
299
300 #[test]
301 fn a_restriction_is_in_effect_from_its_start_until_just_before_its_end() {
302 let table = ShortabilityTable::new()
303 .with_short_sale_restriction(
304 name(),
305 utc("2026-01-01T15:00:00Z"),
306 utc("2026-01-02T21:00:00Z"),
307 )
308 .with_short_sale_restriction(
309 name(),
310 utc("2026-01-05T15:00:00Z"),
311 utc("2026-01-06T21:00:00Z"),
312 );
313
314 let restricted = |text| table.short_sale_restricted(&name(), utc(text));
315 assert!(!restricted("2026-01-01T14:59:59Z"));
316 assert!(restricted("2026-01-01T15:00:00Z"));
317 assert!(restricted("2026-01-02T20:59:59Z"));
318 assert!(!restricted("2026-01-02T21:00:00Z"));
319 assert!(restricted("2026-01-06T00:00:00Z"));
320 assert!(!table.short_sale_restricted(
321 &InstrumentNameExchange::new("MSFT"),
322 utc("2026-01-01T16:00:00Z")
323 ));
324 }
325
326 #[test]
327 #[should_panic(expected = "must end after it starts")]
328 fn a_restriction_that_ends_before_it_starts_panics() {
329 let _ = ShortabilityTable::new().with_short_sale_restriction(
330 name(),
331 utc("2026-01-02T00:00:00Z"),
332 utc("2026-01-01T00:00:00Z"),
333 );
334 }
335
336 #[test]
337 fn a_provider_is_unrestricted_unless_it_says_otherwise() {
338 #[derive(Debug)]
339 struct Constant;
340 impl ShortabilityProvider for Constant {
341 fn shortability(&self, _: &InstrumentNameExchange, _: DateTime<Utc>) -> Shortability {
342 Shortability::new()
343 }
344 }
345
346 assert!(!Constant.short_sale_restricted(&name(), utc("2026-01-01T00:00:00Z")));
347 }
348}