Skip to main content

sample_app/
sample_app.rs

1//! Sample app: an order-lifecycle supervisor on a matching engine, built on
2//! `subms-timer-wheel`. Run the base with `cargo run --example sample_app`;
3//! add `--features full` to see each opt-in section light up.
4//!
5//! Every resting order arms an expiry timer sized to its time-in-force. A
6//! fill cancels it, an amend reschedules it, and end-of-session drains what
7//! is left. Session time is a tick counter and the clock-driven sections use
8//! an injected clock, so the printed output is identical on every run.
9//!
10//! * base               - the TIF supervisor: arm, cancel on fill, amend, drain
11//! * hierarchical       - good-til-date orders whose horizons span seconds to a session
12//! * concurrent         - quote-timeout timers armed from many market-data threads
13//! * deadline-scheduler - FIX session idle timeout, bumped by inbound traffic
14//! * cron               - a recurring mark-to-market risk snapshot
15//! * metrics            - the supervisor reporting its own cadence
16
17use subms_timer_wheel::TimerWheel;
18
19fn main() {
20    tif_supervisor();
21
22    #[cfg(feature = "hierarchical")]
23    hierarchical_gtd();
24
25    #[cfg(feature = "concurrent")]
26    concurrent_quote_timeouts();
27
28    #[cfg(feature = "deadline-scheduler")]
29    deadline_session_idle();
30
31    #[cfg(feature = "cron")]
32    cron_risk_snapshot();
33
34    #[cfg(feature = "metrics")]
35    metered_expiry_wheel();
36}
37
38/// What the matching engine hands the supervisor, at a given session second.
39enum Event {
40    /// A new resting order with a time-in-force in seconds.
41    Rest(&'static str, usize),
42    /// Fully filled: its expiry must not fire.
43    Fill(&'static str),
44    /// Amended to a longer time-in-force, measured from now.
45    Amend(&'static str, usize),
46}
47
48/// Base API. One tick is one second of session time. The supervisor holds an
49/// order id to timer id map because the wheel hands back a timer id, and the
50/// engine only ever speaks order ids.
51fn tif_supervisor() {
52    println!("== base: order time-in-force supervisor ==");
53
54    let tape = [
55        (0usize, Event::Rest("ORD-A", 3)),
56        (0, Event::Rest("ORD-B", 5)),
57        (0, Event::Rest("ORD-C", 9)),
58        (0, Event::Rest("ORD-D", 12)),
59        (2, Event::Fill("ORD-B")),
60        (4, Event::Amend("ORD-C", 6)),
61    ];
62
63    let mut expiries: TimerWheel<&'static str> = TimerWheel::new(256);
64    let mut timer_of: Vec<(&'static str, u64)> = Vec::new();
65    let lookup = |map: &Vec<(&'static str, u64)>, ord: &str| {
66        map.iter().find(|(o, _)| *o == ord).map(|(_, t)| *t)
67    };
68
69    let session_secs = 11;
70    for second in 0..=session_secs {
71        for (at, ev) in tape.iter() {
72            if *at != second {
73                continue;
74            }
75            match ev {
76                Event::Rest(ord, tif) => {
77                    let id = expiries.schedule(*tif, ord);
78                    timer_of.push((ord, id));
79                    println!("  t={second}s rest {ord} tif={tif}s");
80                }
81                Event::Fill(ord) => {
82                    let id = lookup(&timer_of, ord).expect("a resting order");
83                    expiries.cancel(id);
84                    println!("  t={second}s fill {ord} -> expiry cancelled");
85                }
86                Event::Amend(ord, tif) => {
87                    let id = lookup(&timer_of, ord).expect("a resting order");
88                    expiries.reschedule(id, *tif);
89                    println!("  t={second}s amend {ord} tif -> {tif}s from now");
90                }
91            }
92        }
93        if second == session_secs {
94            break;
95        }
96        for ord in expiries.tick() {
97            println!("  t={}s expire {ord}", second + 1);
98        }
99    }
100
101    let unfilled = expiries.drain();
102    println!(
103        "  session close: {} orders still resting {:?}",
104        unfilled.len(),
105        unfilled
106    );
107    println!("  pending after drain: {}", expiries.pending());
108
109    assert_eq!(
110        unfilled,
111        vec!["ORD-D"],
112        "only the 12s TIF outlives the session"
113    );
114    assert_eq!(expiries.pending(), 0);
115}
116
117/// `hierarchical` feature: good-til-date orders expire anywhere from a few
118/// seconds to a full session out. A single flat wheel would need a slot per
119/// tick of the longest horizon; the hierarchical wheel holds far-out orders
120/// on a coarse level and cascades them down as their deadline approaches,
121/// from a fixed 192-bucket footprint.
122#[cfg(feature = "hierarchical")]
123fn hierarchical_gtd() {
124    use subms_timer_wheel::HierarchicalTimerWheel;
125    println!("\n== hierarchical: good-til-date across horizons ==");
126    let mut gtd: HierarchicalTimerWheel<&'static str> = HierarchicalTimerWheel::new();
127
128    gtd.schedule(30, "GTD-near"); // intraday, lands on the fine wheel
129    let far = gtd.schedule(5000, "GTD-far"); // deep on the coarse wheel
130    println!("  armed 2 GTD orders, {} pending", gtd.pending());
131
132    // The desk pulls the far order in to the close of the current session.
133    gtd.reschedule(far, 300);
134    println!("  GTD-far pulled in to t=300");
135
136    let mut near_at = None;
137    let mut far_at = None;
138    for t in 1..=300 {
139        for id in gtd.tick() {
140            match id {
141                "GTD-near" => near_at = Some(t),
142                "GTD-far" => far_at = Some(t),
143                _ => {}
144            }
145        }
146    }
147    println!(
148        "  near fired at t={:?}, far fired at t={:?}",
149        near_at, far_at
150    );
151    println!("  cascade events: {}", gtd.cascades());
152
153    assert_eq!(near_at, Some(30), "near GTD fires on its deadline");
154    assert_eq!(
155        far_at,
156        Some(300),
157        "the rescheduled GTD fires on its new deadline"
158    );
159    assert!(gtd.cascades() >= 1, "the far order cascaded down a level");
160    assert_eq!(gtd.pending(), 0);
161}
162
163/// `concurrent` feature: several market-data threads each arm quote-timeout
164/// timers against one shared wheel. `Clone` shares the handle; every op
165/// serializes on a short mutex. A single ticker thread then drains expiries.
166#[cfg(feature = "concurrent")]
167fn concurrent_quote_timeouts() {
168    use std::thread;
169    use subms_timer_wheel::ConcurrentTimerWheel;
170    println!("\n== concurrent: quote timeouts from many feeds ==");
171    let wheel: ConcurrentTimerWheel<usize> = ConcurrentTimerWheel::new(256);
172
173    let feeds = 4;
174    let per_feed = 50;
175    let mut handles = Vec::new();
176    for feed in 0..feeds {
177        let wheel = wheel.clone();
178        handles.push(thread::spawn(move || {
179            for i in 0..per_feed {
180                wheel.schedule(1 + (i % 8), feed * 1000 + i);
181            }
182        }));
183    }
184    for h in handles {
185        h.join().unwrap();
186    }
187    println!(
188        "  {} quote timeouts armed across {feeds} feeds",
189        wheel.pending()
190    );
191
192    let fired = wheel.advance(16).len();
193    println!("  {feeds} feeds x {per_feed} quotes -> {fired} timeouts fired");
194    assert_eq!(
195        fired,
196        feeds * per_feed,
197        "every armed timeout fired exactly once"
198    );
199    assert!(wheel.is_empty());
200}
201
202/// `deadline-scheduler` feature: a FIX session must see inbound traffic
203/// inside its idle window or be torn down. One timer per session, bumped on
204/// every message rather than cancelled and re-armed. Callers think in
205/// instants; the layer maps them to ticks through the injected clock, and
206/// `poll()` fires the catch-up batch. A hand-stepped clock keeps the demo
207/// deterministic instead of sleeping.
208#[cfg(feature = "deadline-scheduler")]
209fn deadline_session_idle() {
210    use std::time::Duration;
211    use subms_timer_wheel::{DeadlineScheduler, TestClock};
212    println!("\n== deadline-scheduler: FIX session idle timeout ==");
213
214    let idle = Duration::from_millis(30);
215    let mut sched: DeadlineScheduler<&'static str, TestClock> =
216        DeadlineScheduler::new(256, TestClock::new(), Duration::from_millis(1));
217
218    let session = sched.schedule_after(idle, "SESSION-1");
219    let mut elapsed = 0u64;
220    for gap in [10u64, 15] {
221        sched.clock().advance(Duration::from_millis(gap));
222        elapsed += gap;
223        assert!(sched.poll().is_empty(), "traffic keeps the session alive");
224        sched.reschedule_after(session, idle);
225        println!(
226            "  inbound msg at +{elapsed}ms, idle deadline now +{}ms",
227            elapsed + 30
228        );
229    }
230    // Then the counterparty goes quiet.
231    sched.clock().advance(Duration::from_millis(30));
232    let dead = sched.poll();
233    println!("  no traffic for {}ms -> {:?}", idle.as_millis(), dead);
234    assert_eq!(dead, vec!["SESSION-1"], "the idle timeout fires");
235}
236
237/// `cron` feature: a recurring risk snapshot on a wall-clock cadence. The
238/// scheduler parses the 5-field expression once, then re-arms the next
239/// matching second each time the current one fires.
240#[cfg(feature = "cron")]
241fn cron_risk_snapshot() {
242    use subms_timer_wheel::{CronSchedule, CronScheduler};
243    println!("\n== cron: mark-to-market every 5 minutes ==");
244    let schedule = CronSchedule::parse("*/5 * * * *").expect("valid cron");
245
246    // 2024-01-01 00:00:01 UTC. Next */5 boundary is 00:05:00.
247    let start = 1_704_067_201;
248    let mut scheduler = CronScheduler::new(schedule, start);
249
250    let first = scheduler.next_fire(start).expect("a next fire exists");
251    scheduler.record_fire(first);
252    let second = scheduler.next_fire(first).expect("a next fire exists");
253    println!("  first snapshot at epoch {first}, next at {second}");
254
255    assert_eq!(
256        first, 1_704_067_500,
257        "first fire lands on the 5-minute grid"
258    );
259    assert_eq!(second, first + 300, "re-arms exactly 5 minutes later");
260}
261
262/// `metrics` feature: the supervisor reports its own cadence. The counters
263/// are plain fields (the wheel is single-threaded), read through a snapshot.
264/// A drained timer is counted apart from a fired one, so a session close does
265/// not read as a burst of expiries.
266#[cfg(feature = "metrics")]
267fn metered_expiry_wheel() {
268    use subms_timer_wheel::MeteredTimerWheel;
269    println!("\n== metrics: self-reporting expiry counters ==");
270    let mut wheel: MeteredTimerWheel<&'static str> = MeteredTimerWheel::new(64);
271
272    let a = wheel.schedule(2, "ORD-A");
273    let b = wheel.schedule(2, "ORD-B");
274    let c = wheel.schedule(2, "ORD-C");
275    wheel.cancel(b);
276    wheel.reschedule(c, 20);
277    let _ = a;
278
279    let fired = wheel.advance(3).len();
280    let left = wheel.drain();
281    let m = wheel.metrics();
282    println!(
283        "  scheduled={} fired={} cancelled={} rescheduled={} drained={} ticks={}",
284        m.scheduled, m.fired, m.cancelled, m.rescheduled, m.drained, m.ticks
285    );
286
287    assert_eq!(m.scheduled, 3);
288    assert_eq!(m.cancelled, 1);
289    assert_eq!(m.rescheduled, 1);
290    assert_eq!(fired, 1, "only the untouched order fired");
291    assert_eq!(m.fired, 1);
292    assert_eq!(left, vec!["ORD-C"]);
293    assert_eq!(m.drained, 1);
294    assert_eq!(m.ticks, 3);
295}